Merge remote-tracking branch 'origin/main'

This commit is contained in:
Hosted Weblate 2026-05-15 19:06:59 +02:00
commit 75f64dc174
No known key found for this signature in database
GPG key ID: A3FAAA06E6569B4C
247 changed files with 43321 additions and 7087 deletions

1
.gitignore vendored
View file

@ -22,5 +22,6 @@ google-services.json
third_party/pdfium/ third_party/pdfium/
*.tgz *.tgz
kcef-bundle/ kcef-bundle/
kcef-bundle-linux-x64/
cache/ cache/
worker/ worker/

View file

@ -0,0 +1,594 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-2134210023">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-2134130793">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-2119051914">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-2017379224">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1962843145">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1774674349">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1676769033">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1630125610">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1394902796">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1210366718">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1191428227">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1040221835">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-629314366">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-586759775">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-568130230">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-456965410">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-432405112">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-316488326">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-154934439">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="16756843">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="28629151">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="141172066">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="166282389">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="371509721">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="595536818">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="611512768">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="622787912">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="735688657">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="796826093">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="849020195">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="855385664">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="873767244">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="928257871">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="938129397">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1036269560">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1111069289">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1235102506">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1265980310">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1345410773">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1441470482">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1562612490">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1577909377">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1653774323">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Pixel_9_Pro" value="120" />
<entry key="Tests" value="360" />
<entry key="samsung SM-G990B2" value="120" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1832928338">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

View file

@ -78,6 +78,19 @@ class AppNavigationTest {
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute) assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
} }
@Test
fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PPTX,
selectedPdfUri = Uri.parse("content://dummy.pptx")
)
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
}
@Test @Test
fun appNavigation_whenEpubSelected_navigatesToEpubReader() { fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
// Trigger state change // Trigger state change
@ -115,4 +128,19 @@ class AppNavigationTest {
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute) assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
} }
@Test
fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() {
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PDF,
selectedPdfUri = Uri.parse("content://dummy.pdf")
)
composeTestRule.waitForIdle()
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN)
composeTestRule.waitForIdle()
assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route)
}
} }

View file

@ -352,6 +352,39 @@ class HtmlParserTest {
assertThat(pBlock.text).isEqualTo("Line one.\nLine two.") assertThat(pBlock.text).isEqualTo("Line one.\nLine two.")
} }
@Test
fun htmlToSemanticBlocks_veryLongInlineParagraph_splitsIntoBoundedParagraphs() {
val longText = "a".repeat(40_000)
val blocks = parse("<p>$longText</p>")
val paragraphs = blocks.filterIsInstance<SemanticParagraph>()
assertThat(paragraphs.size).isAtLeast(2)
assertThat(paragraphs.sumOf { it.text.length }).isEqualTo(longText.length)
assertThat(paragraphs.all { it.text.length <= 32_000 }).isTrue()
assertThat(
paragraphs.zipWithNext().all { (previous, next) ->
next.startCharOffsetInSource > previous.startCharOffsetInSource
}
).isTrue()
}
@Test
fun htmlToSemanticBlocks_deepInlineWrapperWithBlockDescendant_parsesWithoutSelectorRecursion() {
val mathId = "deep-math"
val mathPlaceholder = """<math-placeholder id="$mathId" alttext="Deep math"></math-placeholder>"""
val nestedHtml = (1..600).fold(mathPlaceholder) { content, _ ->
"<span>$content</span>"
}
val blocks = parse(
html = nestedHtml,
mathSvgCache = mapOf(mathId to "<svg><text>x</text></svg>")
)
assertThat(blocks).hasSize(1)
assertThat(blocks.first()).isInstanceOf(SemanticMath::class.java)
}
@Test @Test
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() { fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
// SETUP // SETUP

View file

@ -164,6 +164,16 @@
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" /> <data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
</intent-filter> </intent-filter>
<!-- PPTX -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
</intent-filter>
<!-- ODT & FODT --> <!-- ODT & FODT -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -82,6 +82,34 @@
max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain; max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain;
} }
#content-container a[href],
#content-container a[href]:link,
#content-container a[href]:visited,
body a[href],
body a[href]:link,
body a[href]:visited,
a[href],
a[href]:link,
a[href]:visited {
color: var(--reader-link, #005FCC) !important;
cursor: pointer;
text-decoration-line: underline !important;
text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important;
text-decoration-thickness: 0.08em;
text-decoration-thickness: max(1px, 0.08em);
text-underline-offset: 0.14em;
text-decoration-skip-ink: auto;
background-image: linear-gradient(transparent 62%, var(--reader-link-bg, rgba(0, 95, 204, 0.16)) 62%);
border-radius: 2px;
}
#content-container a[href] *,
body a[href] *,
a[href] * {
color: var(--reader-link, #005FCC) !important;
text-decoration-color: var(--reader-link-decoration, var(--reader-link, #005FCC)) !important;
}
figure img { figure img {
height: auto !important; height: auto !important;
} }
@ -206,6 +234,7 @@
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF'); var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000'); var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
var linkPalette = getReaderLinkPalette(isDark, effectiveBg, effectiveText);
var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha)); var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha));
var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg); var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
@ -221,6 +250,9 @@
:root { :root {
--reader-bg: ${effectiveBg}; --reader-bg: ${effectiveBg};
--reader-text: ${effectiveText}; --reader-text: ${effectiveText};
--reader-link: ${linkPalette.color};
--reader-link-decoration: ${linkPalette.color};
--reader-link-bg: ${linkPalette.background};
} }
html.${themeClassName}, html.${themeClassName} body { html.${themeClassName}, html.${themeClassName} body {
background-color: var(--reader-bg) !important; background-color: var(--reader-bg) !important;
@ -228,21 +260,46 @@
${textureCss} ${textureCss}
} }
html.${themeClassName} a { html.${themeClassName} body a[href],
color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important; html.${themeClassName} body a[href]:link,
html.${themeClassName} body a[href]:visited,
html.${themeClassName} a[href],
html.${themeClassName} a[href]:link,
html.${themeClassName} a[href]:visited {
color: var(--reader-link) !important;
cursor: pointer;
text-decoration-line: underline !important;
text-decoration-color: var(--reader-link-decoration) !important;
text-decoration-thickness: 0.08em;
text-decoration-thickness: max(1px, 0.08em);
text-underline-offset: 0.14em;
text-decoration-skip-ink: auto;
background-image: linear-gradient(transparent 62%, var(--reader-link-bg) 62%);
border-radius: 2px;
} }
html.${themeClassName} a p, html.${themeClassName} body a[href] p,
html.${themeClassName} a div, html.${themeClassName} body a[href] div,
html.${themeClassName} a span, html.${themeClassName} body a[href] span,
html.${themeClassName} a li, html.${themeClassName} body a[href] li,
html.${themeClassName} a h1, html.${themeClassName} body a[href] h1,
html.${themeClassName} a h2, html.${themeClassName} body a[href] h2,
html.${themeClassName} a h3, html.${themeClassName} body a[href] h3,
html.${themeClassName} a h4, html.${themeClassName} body a[href] h4,
html.${themeClassName} a h5, html.${themeClassName} body a[href] h5,
html.${themeClassName} a h6 { html.${themeClassName} body a[href] h6,
color: var(--reader-text) !important; html.${themeClassName} a[href] p,
html.${themeClassName} a[href] div,
html.${themeClassName} a[href] span,
html.${themeClassName} a[href] li,
html.${themeClassName} a[href] h1,
html.${themeClassName} a[href] h2,
html.${themeClassName} a[href] h3,
html.${themeClassName} a[href] h4,
html.${themeClassName} a[href] h5,
html.${themeClassName} a[href] h6 {
color: var(--reader-link) !important;
text-decoration-color: var(--reader-link-decoration) !important;
background-color: transparent !important; background-color: transparent !important;
} }
@ -290,6 +347,67 @@
} : {r:255,g:255,b:255}; } : {r:255,g:255,b:255};
} }
function rgbToHex(rgb) {
function channel(value) {
var hex = Math.max(0, Math.min(255, value)).toString(16);
return hex.length < 2 ? '0' + hex : hex;
}
return '#' + channel(rgb.r) + channel(rgb.g) + channel(rgb.b);
}
function contrastRatio(first, second) {
var firstLum = getLuminance(first.r, first.g, first.b);
var secondLum = getLuminance(second.r, second.g, second.b);
var lighter = Math.max(firstLum, secondLum);
var darker = Math.min(firstLum, secondLum);
return (lighter + 0.05) / (darker + 0.05);
}
function getReaderLinkPalette(isDark, bgHex, textHex) {
var bg = hexToRgb(bgHex);
var text = hexToRgb(textHex);
var bgLum = getLuminance(bg.r, bg.g, bg.b);
var textLum = getLuminance(text.r, text.g, text.b);
var candidates = (isDark || bgLum < 0.45)
? [
{ r: 125, g: 211, b: 252 },
{ r: 94, g: 234, b: 212 },
{ r: 165, g: 180, b: 252 },
{ r: 253, g: 230, b: 138 },
{ r: 255, g: 255, b: 255 }
]
: [
{ r: 0, g: 95, b: 204 },
{ r: 0, g: 109, b: 117 },
{ r: 122, g: 30, b: 82 },
{ r: 74, g: 20, b: 140 },
{ r: 17, g: 24, b: 39 }
];
var best = candidates[0];
var bestScore = -1;
for (var i = 0; i < candidates.length; i++) {
var candidate = candidates[i];
var contrast = contrastRatio(candidate, bg);
var separation = Math.abs(getLuminance(candidate.r, candidate.g, candidate.b) - textLum);
if (contrast >= 4.5 && separation >= 0.08) {
best = candidate;
break;
}
var score = contrast * 10 + separation;
if (score > bestScore) {
bestScore = score;
best = candidate;
}
}
var alpha = bgLum < 0.45 ? 0.24 : 0.16;
return {
color: rgbToHex(best),
background: `rgba(${best.r}, ${best.g}, ${best.b}, ${alpha})`
};
}
function rgbStringToRgb(rgbStr) { function rgbStringToRgb(rgbStr) {
var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (parts) { if (parts) {
@ -346,6 +464,7 @@
var elements = document.querySelectorAll('[style*="color"]'); var elements = document.querySelectorAll('[style*="color"]');
elements.forEach(function(el) { elements.forEach(function(el) {
if (el.closest && el.closest('a[href]')) return;
var style = window.getComputedStyle(el); var style = window.getComputedStyle(el);
var colorStr = style.color; var colorStr = style.color;
var rgb = rgbStringToRgb(colorStr); var rgb = rgbStringToRgb(colorStr);

View file

@ -333,6 +333,58 @@ static bool init_pdfium() {
return get_annot_count_func != nullptr; return get_annot_count_func != nullptr;
} }
static constexpr int kMaxSafeAnnotCount = 100000;
static int get_safe_annot_count(void* page) {
if (!get_annot_count_func || page == nullptr) return 0;
int count = get_annot_count_func(page);
if (count < 0 || count > kMaxSafeAnnotCount) {
LOGE("Ignoring invalid annotation count: %d", count);
return 0;
}
return count;
}
class ScopedPdfAnnot {
public:
explicit ScopedPdfAnnot(void* annot) : annot_(annot) {}
~ScopedPdfAnnot() {
if (annot_ && close_annot_func) {
close_annot_func(annot_);
}
}
ScopedPdfAnnot(const ScopedPdfAnnot&) = delete;
ScopedPdfAnnot& operator=(const ScopedPdfAnnot&) = delete;
ScopedPdfAnnot(ScopedPdfAnnot&& other) noexcept : annot_(other.annot_) {
other.annot_ = nullptr;
}
ScopedPdfAnnot& operator=(ScopedPdfAnnot&& other) noexcept {
if (this != &other) {
if (annot_ && close_annot_func) {
close_annot_func(annot_);
}
annot_ = other.annot_;
other.annot_ = nullptr;
}
return *this;
}
void* get() const { return annot_; }
private:
void* annot_;
};
static ScopedPdfAnnot get_annot_checked(void* page, jint index) {
if (!get_annot_func || page == nullptr || index < 0) return ScopedPdfAnnot(nullptr);
int count = get_safe_annot_count(page);
if (index >= count) return ScopedPdfAnnot(nullptr);
return ScopedPdfAnnot(get_annot_func(page, index));
}
extern "C" JNIEXPORT jdouble JNICALL extern "C" JNIEXPORT jdouble JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
@ -420,17 +472,18 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas
extern "C" JNIEXPORT jstring JNICALL extern "C" JNIEXPORT jstring JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr; if (!init_pdfium() || !get_annot_string_func || pagePtr == 0 || key == nullptr) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
void* annot = get_annot_func(page, index); ScopedPdfAnnot annot = get_annot_checked(page, index);
if (!annot) return nullptr; if (!annot.get()) return nullptr;
const char* nativeKey = env->GetStringUTFChars(key, nullptr); const char* nativeKey = env->GetStringUTFChars(key, nullptr);
if (!nativeKey) return nullptr;
if (strcmp(nativeKey, "IRT") == 0) { if (strcmp(nativeKey, "IRT") == 0) {
if (get_linked_annot_func && close_annot_func) { if (get_linked_annot_func && close_annot_func) {
void* parentAnnot = get_linked_annot_func(annot, "IRT"); void* parentAnnot = get_linked_annot_func(annot.get(), "IRT");
if (parentAnnot) { if (parentAnnot) {
unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0); unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0);
jstring result = nullptr; jstring result = nullptr;
@ -448,7 +501,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
return nullptr; return nullptr;
} }
unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0); unsigned long len = get_annot_string_func(annot.get(), nativeKey, nullptr, 0);
if (len <= 2) { if (len <= 2) {
env->ReleaseStringUTFChars(key, nativeKey); env->ReleaseStringUTFChars(key, nativeKey);
@ -456,7 +509,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
} }
std::vector<unsigned short> buffer(len / 2); std::vector<unsigned short> buffer(len / 2);
get_annot_string_func(annot, nativeKey, buffer.data(), len); get_annot_string_func(annot.get(), nativeKey, buffer.data(), len);
jstring result = env->NewString(reinterpret_cast<const jchar*>(buffer.data()), (jsize)(buffer.size() - 1)); jstring result = env->NewString(reinterpret_cast<const jchar*>(buffer.data()), (jsize)(buffer.size() - 1));
@ -1592,15 +1645,17 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1; if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || !get_annot_subtype_func || pagePtr == 0) return -1;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
int count = get_annot_count_func(page); int count = get_safe_annot_count(page);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
void* annot = get_annot_func(page, i); ScopedPdfAnnot annot = get_annot_checked(page, i);
if (!annot.get()) continue;
float r[4]; // L, B, R, T float r[4]; // L, B, R, T
if (get_annot_rect_func(annot, r)) { if (get_annot_rect_func(annot.get(), r)) {
// FIX: Use min/max to handle inverted PDF rectangles // FIX: Use min/max to handle inverted PDF rectangles
float minX = fmin(r[0], r[2]); float minX = fmin(r[0], r[2]);
float maxX = fmax(r[0], r[2]); float maxX = fmax(r[0], r[2]);
@ -1608,8 +1663,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
float maxY = fmax(r[1], r[3]); float maxY = fmax(r[1], r[3]);
if (x >= minX && x <= maxX && y >= minY && y <= maxY) { if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, get_annot_subtype_func(annot)); int subtype = get_annot_subtype_func(annot.get());
return get_annot_subtype_func(annot); LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, subtype);
return subtype;
} }
} }
} }
@ -1619,15 +1675,22 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
extern "C" JNIEXPORT jfloatArray JNICALL extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
int count = get_annot_count_func(page); int count = get_safe_annot_count(page);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
void* annot = get_annot_func(page, i); ScopedPdfAnnot annot = get_annot_checked(page, i);
if (!annot.get()) continue;
float rect[4]; float rect[4];
if (get_annot_rect_func(annot, rect)) { if (get_annot_rect_func(annot.get(), rect)) {
if (x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]) { float minX = fminf(rect[0], rect[2]);
float maxX = fmaxf(rect[0], rect[2]);
float minY = fminf(rect[1], rect[3]);
float maxY = fmaxf(rect[1], rect[3]);
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
jfloatArray result = env->NewFloatArray(4); jfloatArray result = env->NewFloatArray(4);
env->SetFloatArrayRegion(result, 0, 4, rect); env->SetFloatArrayRegion(result, 0, 4, rect);
return result; return result;
@ -1641,26 +1704,26 @@ extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0; if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
return get_annot_count_func(reinterpret_cast<void*>(pagePtr)); return get_safe_annot_count(reinterpret_cast<void*>(pagePtr));
} }
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0; if (!init_pdfium() || !get_annot_subtype_func || pagePtr == 0) return 0;
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index); ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast<void*>(pagePtr), index);
return annot ? get_annot_subtype_func(annot) : 0; return annot.get() ? get_annot_subtype_func(annot.get()) : 0;
} }
extern "C" JNIEXPORT jfloatArray JNICALL extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; if (!init_pdfium() || !get_annot_rect_func || pagePtr == 0) return nullptr;
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index); ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast<void*>(pagePtr), index);
if (!annot) return nullptr; if (!annot.get()) return nullptr;
float rect[4]; float rect[4];
if (!get_annot_rect_func(annot, rect)) return nullptr; if (!get_annot_rect_func(annot.get(), rect)) return nullptr;
jfloatArray result = env->NewFloatArray(4); jfloatArray result = env->NewFloatArray(4);
env->SetFloatArrayRegion(result, 0, 4, rect); env->SetFloatArrayRegion(result, 0, 4, rect);
@ -1670,31 +1733,30 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl
extern "C" JNIEXPORT jboolean JNICALL extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE; if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || !get_annot_subtype_func || pagePtr == 0) return JNI_FALSE;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
int count = get_annot_count_func(page); int count = get_safe_annot_count(page);
void* hitAnnot = nullptr; int hitSubtype = 0;
LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count); LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
void* annot = get_annot_func(page, i); ScopedPdfAnnot annot = get_annot_checked(page, i);
if (!annot) continue; if (!annot.get()) continue;
float r[4]; float r[4];
if (get_annot_rect_func(annot, r)) { if (get_annot_rect_func(annot.get(), r)) {
float minX = fminf(r[0], r[2]); float minX = fminf(r[0], r[2]);
float maxX = fmaxf(r[0], r[2]); float maxX = fmaxf(r[0], r[2]);
float minY = fminf(r[1], r[3]); float minY = fminf(r[1], r[3]);
float maxY = fmaxf(r[1], r[3]); float maxY = fmaxf(r[1], r[3]);
if (x >= minX && x <= maxX && y >= minY && y <= maxY) { if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
hitAnnot = annot; hitSubtype = get_annot_subtype_func(annot.get());
int subtype = get_annot_subtype_func(hitAnnot); LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, hitSubtype);
LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, subtype);
if (get_annot_flags_func) { if (get_annot_flags_func) {
int flags = get_annot_flags_func(hitAnnot); int flags = get_annot_flags_func(annot.get());
LOGI("PdfLinkDiagnostic: [C++] Flags for hit annot: %d", flags); LOGI("PdfLinkDiagnostic: [C++] Flags for hit annot: %d", flags);
} }
break; break;
@ -1702,25 +1764,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
} }
} }
if (hitAnnot) { if (hitSubtype != 0) {
int subtype = get_annot_subtype_func(hitAnnot); if (hitSubtype == 19 || hitSubtype == 20) {
LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", hitSubtype);
if (subtype == 19 || subtype == 20) {
LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", subtype);
bool anyChanged = false; bool anyChanged = false;
for (int j = 0; j < count; j++) { for (int j = 0; j < count; j++) {
void* target = get_annot_func(page, j); ScopedPdfAnnot target = get_annot_checked(page, j);
if (!target || !get_annot_flags_func || !set_annot_flags_func) continue; if (!target.get() || !get_annot_flags_func || !set_annot_flags_func) continue;
int flags = get_annot_flags_func(target); int flags = get_annot_flags_func(target.get());
// We check for: Invisible (1), Hidden (2), or NoView (32) // We check for: Invisible (1), Hidden (2), or NoView (32)
if (flags & (1 | 2 | 32)) { if (flags & (1 | 2 | 32)) {
LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags); LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags);
// Clear bits 1, 2, and 6 (1 + 2 + 32 = 35) // Clear bits 1, 2, and 6 (1 + 2 + 32 = 35)
set_annot_flags_func(target, flags & ~35); set_annot_flags_func(target.get(), flags & ~35);
anyChanged = true; anyChanged = true;
} }
} }

View file

@ -0,0 +1,49 @@
package com.aryan.reader
import com.aryan.reader.shared.SharedFeaturePolicy
import com.aryan.reader.shared.SharedSettingsHubInput
import com.aryan.reader.shared.SharedSettingsPlatform
fun androidSettingsHubInput(
uiState: ReaderScreenState,
isOssBuild: Boolean = BuildConfig.FLAVOR == "oss",
isOfflineBuild: Boolean = BuildConfig.IS_OFFLINE,
isDebugBuild: Boolean = BuildConfig.DEBUG,
hideReaderAi: Boolean = false
): SharedSettingsHubInput {
val supportsSync = !isOssBuild && !isOfflineBuild
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
val featurePolicy = if (isOfflineBuild) {
SharedFeaturePolicy.OssOffline
} else {
SharedFeaturePolicy.Standard
}
return SharedSettingsHubInput(
platform = SharedSettingsPlatform.ANDROID,
featurePolicy = featurePolicy,
isDebugBuild = isDebugBuild,
isSignedIn = uiState.currentUser != null,
isProUser = uiState.isProUser,
syncAvailable = supportsSync,
folderSyncAvailable = supportsSync,
aiSettingsAvailable = supportsOssAiKeys,
ttsSettingsAvailable = true,
includePdfReaderDefaults = true,
includeReaderToolbar = true,
includeLanguage = true,
includeScreenCaptureProtection = true,
includeExternalFileBehavior = true,
includeRecentLimit = true,
includeCustomFonts = true,
includeStrictFileFilter = true,
includeHideReaderAi = !isOfflineBuild,
includeCloudLocalDataClear = supportsSync,
supportProjectAvailable = isOssBuild,
isTabsEnabled = uiState.isTabsEnabled,
isSyncEnabled = uiState.isSyncEnabled,
isFolderSyncEnabled = uiState.isFolderSyncEnabled,
useStrictFileFilter = uiState.useStrictFileFilter,
isScreenCaptureProtectionEnabled = uiState.isScreenCaptureProtectionEnabled,
hideReaderAi = hideReaderAi
)
}

View file

@ -0,0 +1,235 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.AppAction as SharedAppAction
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import com.aryan.reader.shared.SharedFolderPathResolver
import com.aryan.reader.shared.SharedLibraryProjectionInput
import com.aryan.reader.shared.SharedLibraryStateProjector
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.reduce
internal object AndroidSharedStateBridge {
fun prepareLibraryProjection(
input: LibraryProjectionInput,
folderPathResolver: FolderPathResolver
): AndroidSharedLibraryProjectionContext {
val taggedBooks = input.recentFilesFromDb.withResolvedTags(input.dbTags, input.tagRefs)
val androidBooksById = taggedBooks
.filterNot { it.bookId.endsWith("_reflow") }
.associateBy { it.bookId }
val projectionState = input.state.withAndroidFolderFallbacks(androidBooksById.values)
val sharedInput = SharedLibraryProjectionInput(
state = projectionState.toSharedReaderScreenState(
rawBooks = taggedBooks,
dbTags = input.dbTags
),
booksFromStore = taggedBooks
.filterNot { it.bookId.endsWith("_reflow") }
.map { it.toSharedProjectionBookItem() },
shelfRecords = input.dbShelves.map { it.toSharedShelfRecord() },
shelfRefs = input.shelfRefs.map { it.toSharedBookShelfRef() },
tags = input.dbTags.map { it.toSharedTag() }
)
return AndroidSharedLibraryProjectionContext(
projectionState = projectionState,
sharedInput = sharedInput,
androidBooksById = androidBooksById,
tagEntitiesById = input.dbTags.associateBy { it.id },
folderKeys = projectionState.syncedFolders.map { AndroidSharedFolderProjectionKey(it.uriString, it.name) },
folderPathResolver = SharedFolderPathResolver { item ->
androidBooksById[item.id]?.let(folderPathResolver::relativeFolderSegments).orEmpty()
}
)
}
fun projectLibrary(context: AndroidSharedLibraryProjectionContext): SharedReaderScreenState {
return SharedLibraryStateProjector(context.folderPathResolver).project(context.sharedInput)
}
fun toAndroidState(
base: ReaderScreenState,
sharedState: SharedReaderScreenState,
androidBooksById: Map<String, RecentFileItem>,
tagEntitiesById: Map<String, TagEntity>
): ReaderScreenState {
return sharedState.toAndroidReaderScreenState(
base = base,
androidBooksById = androidBooksById,
tagEntitiesById = tagEntitiesById
)
}
fun reduceLibraryAction(
current: ReaderScreenState,
projectedState: ReaderScreenState,
action: SharedLibraryAction
): ReaderScreenState {
val rawBooks = projectedState.rawLibraryFiles.ifEmpty { current.rawLibraryFiles }
val androidBooksById = (rawBooks + current.contextualActionItems).associateBy { it.bookId }
val reduced = current.toBridgeSharedState(projectedState).reduce(action)
return current.copy(
searchQuery = reduced.searchQuery,
sortOrder = reduced.sortOrder.toAndroidSortOrder(),
libraryFilters = reduced.libraryFilters.toAndroidLibraryFilters(),
contextualActionItems = reduced.selectedBookIds.mapNotNullTo(mutableSetOf()) { androidBooksById[it] },
contextualActionShelfIds = reduced.selectedShelfIds,
libraryScreenStartPage = reduced.libraryScreenStartPage,
recentFilesLimit = reduced.recentFilesLimit
)
}
fun reduceAppAction(
current: ReaderScreenState,
projectedState: ReaderScreenState,
action: SharedAppAction
): ReaderScreenState {
val reduced = current.toBridgeSharedState(projectedState).reduce(action)
return current.copy(
appThemeMode = reduced.appThemeMode.toAndroidAppThemeMode(),
appContrastOption = reduced.appContrastOption.toAndroidAppContrastOption(),
appTextDimFactorLight = reduced.appTextDimFactorLight,
appTextDimFactorDark = reduced.appTextDimFactorDark,
appSeedColor = reduced.appSeedColor,
customAppThemes = reduced.customAppThemes.map { it.toAndroidCustomAppTheme() }
)
}
fun setTabsEnabled(
current: ReaderScreenState,
projectedState: ReaderScreenState,
enabled: Boolean
): ReaderScreenState {
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.TabsEnabledChanged(enabled))
if (enabled) return current.withTabStateFrom(reduced)
val activeTab = current.activeTabBookId
return current.copy(
isTabsEnabled = reduced.isTabsEnabled,
openTabIds = if (activeTab == null) emptyList() else listOf(activeTab),
activeTabBookId = activeTab
)
}
fun openBookTab(
current: ReaderScreenState,
projectedState: ReaderScreenState,
bookId: String
): ReaderScreenState {
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.BookTabOpened(bookId))
return current.withTabStateFrom(reduced)
}
fun closeBookTab(
current: ReaderScreenState,
projectedState: ReaderScreenState,
bookId: String
): ReaderScreenState {
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.BookTabClosed(bookId))
return current.withTabStateFrom(reduced)
}
fun closeAllTabs(current: ReaderScreenState, projectedState: ReaderScreenState): ReaderScreenState {
val reduced = current.toBridgeSharedState(projectedState).reduce(SharedAppAction.AllTabsClosed)
return current.withTabStateFrom(reduced)
}
fun togglePinsForSelectedBooks(
current: ReaderScreenState,
projectedState: ReaderScreenState,
isHome: Boolean
): ReaderScreenState {
val selectedIds = current.contextualActionItems.mapTo(linkedSetOf()) { it.bookId }
if (selectedIds.isEmpty()) return current
val currentPins = if (isHome) current.pinnedHomeBookIds else current.pinnedLibraryBookIds
val idsToToggle = if (selectedIds.all { it in currentPins }) {
selectedIds
} else {
selectedIds - currentPins
}
val reduced = idsToToggle.fold(current.toBridgeSharedState(projectedState)) { state, bookId ->
state.reduce(
if (isHome) {
SharedAppAction.HomePinToggled(bookId)
} else {
SharedAppAction.LibraryPinToggled(bookId)
}
)
}
return if (isHome) {
current.copy(
pinnedHomeBookIds = reduced.pinnedHomeBookIds,
contextualActionItems = emptySet()
)
} else {
current.copy(
pinnedLibraryBookIds = reduced.pinnedLibraryBookIds,
contextualActionItems = emptySet()
)
}
}
fun replaceBookSelectionWithVisibleBooks(
current: ReaderScreenState,
projectedState: ReaderScreenState,
visibleBooks: Collection<RecentFileItem>
): ReaderScreenState {
val visibleIds = visibleBooks.mapTo(linkedSetOf()) { it.bookId }
val selectedIds = current.contextualActionItems.mapTo(linkedSetOf()) { it.bookId }
val action = if (visibleIds.isNotEmpty() && selectedIds.containsAll(visibleIds)) {
SharedLibraryAction.SelectionCleared
} else {
SharedLibraryAction.BookSelectionReplaced(visibleIds)
}
return reduceLibraryAction(
current = current,
projectedState = projectedState,
action = action
)
}
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
return toSharedReaderScreenState(
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
dbTags = projectedState.allTags.ifEmpty { allTags }
)
}
private fun ReaderScreenState.withTabStateFrom(sharedState: SharedReaderScreenState): ReaderScreenState {
return copy(
isTabsEnabled = sharedState.isTabsEnabled,
openTabIds = sharedState.openTabIds,
activeTabBookId = sharedState.activeTabBookId
)
}
}
internal data class AndroidSharedLibraryProjectionContext(
val projectionState: ReaderScreenState,
val sharedInput: SharedLibraryProjectionInput,
val androidBooksById: Map<String, RecentFileItem>,
val tagEntitiesById: Map<String, TagEntity>,
val folderKeys: List<AndroidSharedFolderProjectionKey>,
val folderPathResolver: SharedFolderPathResolver
)
internal data class AndroidSharedFolderProjectionKey(
val uriString: String,
val name: String
)
private fun ReaderScreenState.withAndroidFolderFallbacks(books: Collection<RecentFileItem>): ReaderScreenState {
val knownFolders = syncedFolders.mapTo(mutableSetOf()) { it.uriString }
val missingFolders = books
.mapNotNull { it.sourceFolderUri }
.filterTo(linkedSetOf()) { it !in knownFolders }
.map { uri -> SyncedFolder(uriString = uri, name = "Local Folder", lastScanTime = 0L) }
return if (missingFolders.isEmpty()) {
this
} else {
copy(syncedFolders = syncedFolders + missingFolders)
}
}

View file

@ -65,6 +65,7 @@ object AppDestinations {
const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route" const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route"
const val FONTS_SCREEN_ROUTE = "fonts_screen_route" const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
} }
private fun NavHostController.isReadyForBackStackChange(): Boolean { private fun NavHostController.isReadyForBackStackChange(): Boolean {
@ -146,7 +147,7 @@ fun AppNavigation(
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) { if (!uiState.isLoading) {
when (uiState.selectedFileType) { when (uiState.selectedFileType) {
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX -> {
if (uiState.selectedPdfUri != null) { if (uiState.selectedPdfUri != null) {
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) { if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE) navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
@ -165,6 +166,11 @@ fun AppNavigation(
navController.syncRouteTo(AppDestinations.MAIN_ROUTE) navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
} }
} }
FileType.UNKNOWN -> {
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
}
}
} }
} }
} }
@ -225,6 +231,8 @@ fun AppNavigation(
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} else if (uiState.isLoading) { } else if (uiState.isLoading) {
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.") Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
@ -301,6 +309,8 @@ fun AppNavigation(
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
CustomTopBanner(bannerMessage = uiState.bannerMessage)
} }
} }
isLoading -> { isLoading -> {
@ -361,5 +371,13 @@ fun AppNavigation(
onBackClick = { navController.popBackStackIfReady() } onBackClick = { navController.popBackStackIfReady() }
) )
} }
composable(route = AppDestinations.SETTINGS_SCREEN_ROUTE) {
SettingsScreen(
viewModel = viewModel,
navController = navController,
onBackClick = { navController.popBackStackIfReady() }
)
}
} }
} }

View file

@ -8,7 +8,11 @@ import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
import java.util.Date import java.util.Date
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false) typealias BannerMessage = com.aryan.reader.shared.BannerMessage
typealias UserData = com.aryan.reader.shared.UserData
typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
data class ImportResult( data class ImportResult(
val internalUri: Uri, val internalUri: Uri,
@ -17,37 +21,12 @@ data class ImportResult(
val bundleResult: CalibreBundleResult? = null val bundleResult: CalibreBundleResult? = null
) )
data class UserData(
val uid: String,
val displayName: String?,
val photoUrl: String?,
val email: String?
)
data class NavigationEvent( data class NavigationEvent(
val route: String, val route: String,
val bookId: String? = null, val bookId: String? = null,
val uri: Uri? = null val uri: Uri? = null
) )
enum class AppThemeMode {
SYSTEM,
LIGHT,
DARK
}
enum class AppContrastOption(val value: Double) {
STANDARD(0.0),
MEDIUM(0.5),
HIGH(1.0)
}
data class CustomAppTheme(
val id: String,
val name: String,
val seedColor: androidx.compose.ui.graphics.Color
)
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?) data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
data class DeviceLimitReachedState( data class DeviceLimitReachedState(
@ -109,7 +88,7 @@ data class ReaderScreenState(
val pinnedLibraryBookIds: Set<String> = emptySet(), val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(), val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0, val recentFilesLimit: Int = 0,
val isTabsEnabled: Boolean = false, val isTabsEnabled: Boolean = true,
val openTabIds: List<String> = emptyList(), val openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(), val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null, val activeTabBookId: String? = null,

View file

@ -109,6 +109,7 @@ class BookImporter(private val context: Context) {
when (context.contentResolver.getType(uri)) { when (context.contentResolver.getType(uri)) {
"application/pdf" -> "pdf" "application/pdf" -> "pdf"
"application/epub+zip" -> "epub" "application/epub+zip" -> "epub"
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx"
else -> "tmp" else -> "tmp"
} }
} }

View file

@ -4,6 +4,7 @@
package com.aryan.reader package com.aryan.reader
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
@ -209,6 +210,7 @@ import javax.crypto.spec.GCMParameterSpec
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt
const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize" const val summarizeEndpoint = "/summarize"
@ -2487,11 +2489,75 @@ fun importReaderTexture(context: Context, uri: Uri): String? {
} }
} }
private const val DEFAULT_CANVAS_SAFE_BITMAP_BYTES = 64L * 1024L * 1024L
private const val DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION = 4096
private const val MAX_READER_TEXTURE_DIMENSION_PX = 1024
fun Bitmap.safeAllocationByteCount(): Long {
return try {
allocationByteCount.toLong()
} catch (_: Exception) {
width.toLong() * height.toLong() * 4L
}
}
fun Bitmap.isCanvasSafeBitmap(
maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION
): Boolean {
return !isRecycled &&
width > 0 &&
height > 0 &&
width <= maxDimension &&
height <= maxDimension &&
safeAllocationByteCount() <= maxBytes
}
fun Bitmap.scaledToCanvasLimit(
maxBytes: Long = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
maxDimension: Int = DEFAULT_CANVAS_SAFE_BITMAP_DIMENSION
): Bitmap {
if (isCanvasSafeBitmap(maxBytes, maxDimension)) return this
val byteScale = sqrt(maxBytes.toDouble() / safeAllocationByteCount().coerceAtLeast(1L).toDouble())
val dimensionScale = maxDimension.toDouble() / max(width, height).coerceAtLeast(1).toDouble()
val scale = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
val targetWidth = (width * scale).roundToInt().coerceAtLeast(1)
val targetHeight = (height * scale).roundToInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(this, targetWidth, targetHeight, true)
}
private fun decodeSampledBitmapFile(path: String, maxDimension: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val options = BitmapFactory.Options().apply {
inSampleSize = calculateBitmapSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
}
return BitmapFactory.decodeFile(path, options)
}
private fun calculateBitmapSampleSize(width: Int, height: Int, maxDimension: Int): Int {
var sampleSize = 1
var sampledWidth = width
var sampledHeight = height
while (sampledWidth / 2 >= maxDimension || sampledHeight / 2 >= maxDimension) {
sampleSize *= 2
sampledWidth /= 2
sampledHeight /= 2
}
return sampleSize.coerceAtLeast(1)
}
fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? { fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? {
if (textureId == null) return null if (textureId == null) return null
return try { return try {
val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
BitmapFactory.decodeFile(textureId.removePrefix(TEXTURE_FILE_PREFIX)) decodeSampledBitmapFile(
path = textureId.removePrefix(TEXTURE_FILE_PREFIX),
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
)
} else { } else {
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
when { when {
@ -2500,7 +2566,14 @@ fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap?
else -> null else -> null
} }
} }
bitmap?.asImageBitmap() val safeBitmap = bitmap?.scaledToCanvasLimit(
maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
)
if (bitmap != null && safeBitmap !== bitmap && !bitmap.isRecycled) {
bitmap.recycle()
}
safeBitmap?.asImageBitmap()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to load reader texture bitmap: $textureId") Timber.e(e, "Failed to load reader texture bitmap: $textureId")
null null
@ -2513,8 +2586,24 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
var mimeType = "image/png" var mimeType = "image/png"
val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX)) val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX))
mimeType = imageMimeTypeForExtension(file.extension) mimeType = "image/png"
file.readBytes() val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX)
?: return null
val bitmap = decodedBitmap.scaledToCanvasLimit(
maxBytes = DEFAULT_CANVAS_SAFE_BITMAP_BYTES,
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
)
try {
ByteArrayOutputStream().use { out ->
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 90, out)
out.toByteArray()
}
} finally {
if (bitmap !== decodedBitmap && !decodedBitmap.isRecycled) {
decodedBitmap.recycle()
}
bitmap.recycle()
}
} else { } else {
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
when { when {

View file

@ -0,0 +1,728 @@
package com.aryan.reader
import android.util.Xml
import org.xmlpull.v1.XmlPullParser
import java.io.InputStream
import java.net.URLDecoder
import java.nio.charset.Charset
import java.util.Base64
import java.util.zip.ZipInputStream
internal data class EmbeddedEbookMetadata(
val title: String? = null,
val author: String? = null,
val description: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val cover: EmbeddedEbookCover? = null
)
internal data class EmbeddedEbookCover(
val bytes: ByteArray,
val extension: String
)
internal object EmbeddedEbookMetadataExtractor {
private const val MAX_XML_ENTRY_BYTES = 512 * 1024
private const val MAX_COVER_BYTES = 24 * 1024 * 1024
private const val MAX_MOBI_HEADER_RECORD_BYTES = 4 * 1024 * 1024
private const val MAX_MOBI_RECORDS = 65_535
private const val MAX_MOBI_EXTH_RECORDS = 10_000
private const val MOBI_NOT_SET = -1
private val ebookCoverTypes = setOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
fun canExtractEmbeddedCover(type: FileType): Boolean = type in ebookCoverTypes
fun extract(
type: FileType,
displayName: String,
openStream: () -> InputStream?,
extractCover: Boolean = true
): EmbeddedEbookMetadata {
return when (type) {
FileType.EPUB -> extractEpub(openStream, extractCover)
FileType.MOBI -> extractMobi(openStream, extractCover)
FileType.FB2 -> extractFb2(displayName, openStream, extractCover)
else -> EmbeddedEbookMetadata()
}
}
private fun extractEpub(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata {
val containerXml = openStream()?.use { input ->
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
name.equals("META-INF/container.xml", ignoreCase = true)
}?.text
}
val declaredOpfPath = containerXml
?.let(::parseEpubRootfilePath)
?.let(::normalizeZipPath)
?.takeIf { it.isNotBlank() }
val opfEntry = declaredOpfPath
?.let { path ->
openStream()?.use { input ->
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
name.equals(path, ignoreCase = true)
}
}
}
?: openStream()?.use { input ->
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
name.endsWith(".opf", ignoreCase = true)
}
}
?: return EmbeddedEbookMetadata()
val opfPath = opfEntry.path
val opf = opfEntry.text
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
.let { if (it.isBlank()) "" else "$it/" }
val manifest = parseEpubManifest(opf)
val cover = if (extractCover) {
val coverItem = findExplicitEpubCover(opf, manifest)
coverItem
?.takeIf { it.rasterExtension != null }
?.let { item ->
val rawPath = resolveEpubZipPath(basePath, item.href)
val decodedPath = resolveEpubZipPath(basePath, item.href.percentDecodedOrSelf())
listOf(decodedPath, rawPath).distinct().firstNotNullOfOrNull { path ->
readZipEntryBytes(openStream, path, MAX_COVER_BYTES)?.let { bytes ->
EmbeddedEbookCover(bytes = bytes, extension = item.rasterExtension ?: "png")
}
}
}
} else {
null
}
return EmbeddedEbookMetadata(
title = opf.tagText("title"),
author = opf.tagText("creator"),
description = opf.tagInnerContent("description"),
seriesName = opf.metaContent("calibre:series"),
seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(),
cover = cover
)
}
private fun readFirstZipTextEntry(
input: InputStream,
maxBytes: Int,
matches: (String) -> Boolean
): ZipTextEntry? {
var result: ZipTextEntry? = null
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
try {
if (entry.isDirectory) continue
val name = normalizeZipPath(entry.name)
if (matches(name)) {
result = zip.readBytesLimited(maxBytes)
?.toString(Charsets.UTF_8)
?.let { ZipTextEntry(path = name, text = it) }
break
}
} finally {
zip.closeEntry()
}
}
}
return result
}
private fun readZipEntryBytes(
openStream: () -> InputStream?,
targetPath: String,
maxBytes: Int
): ByteArray? {
return openStream()?.use { input ->
var result: ByteArray? = null
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
try {
if (!entry.isDirectory && normalizeZipPath(entry.name).equals(targetPath, ignoreCase = true)) {
result = zip.readBytesLimited(maxBytes)
break
}
} finally {
zip.closeEntry()
}
}
}
result
}
}
private fun parseEpubRootfilePath(containerXml: String): String? {
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
.find(containerXml)
?.groupValues
?.get(1)
?.decodeEntities()
?.takeIf { it.isNotBlank() }
}
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
.findAll(opf)
.mapNotNull { match ->
val item = match.value
val id = item.attr("id")
val href = item.attr("href")
if (id.isBlank() || href.isBlank()) {
null
} else {
EpubManifestItem(
id = id,
href = href.decodeEntities(),
mediaType = item.attr("media-type"),
properties = item.attr("properties")
)
}
}
.toList()
}
private fun findExplicitEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
.findAll(opf)
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
?.value
?.attr("content")
?.takeIf { it.isNotBlank() }
return manifest.firstOrNull { it.id == coverId }
?: manifest.firstOrNull { item ->
item.properties.split(Regex("\\s+")).any { it.equals("cover-image", ignoreCase = true) }
}
}
private fun extractFb2(
displayName: String,
openStream: () -> InputStream?,
extractCover: Boolean
): EmbeddedEbookMetadata {
return openStream()?.use { input ->
if (displayName.endsWith(".zip", ignoreCase = true)) {
ZipInputStream(input.buffered()).use { zip ->
var metadata: EmbeddedEbookMetadata? = null
while (true) {
val entry = zip.nextEntry ?: break
try {
if (!entry.isDirectory && entry.name.endsWith(".fb2", ignoreCase = true)) {
metadata = parseFb2Xml(zip, extractCover)
break
}
} finally {
zip.closeEntry()
}
}
metadata ?: EmbeddedEbookMetadata()
}
} else {
parseFb2Xml(input, extractCover)
}
} ?: EmbeddedEbookMetadata()
}
private fun parseFb2Xml(input: InputStream, extractCover: Boolean): EmbeddedEbookMetadata {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(input, null)
var title: String? = null
val authors = mutableListOf<String>()
var inAuthor = false
var inBody = false
var inCoverPage = false
val authorParts = mutableListOf<String>()
var coverImageId: String? = null
var cover: EmbeddedEbookCover? = null
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> {
when (parser.name.localXmlName()) {
"body" -> inBody = true
"coverpage" -> {
if (!inBody) inCoverPage = true
}
"author" -> {
if (!inBody) {
inAuthor = true
authorParts.clear()
}
}
"book-title" -> {
if (title == null) {
title = parser.nextTextOrNull()
}
}
"first-name", "middle-name", "last-name", "nickname" -> {
if (inAuthor) {
parser.nextTextOrNull()?.let(authorParts::add)
}
}
"image" -> {
if (inCoverPage && coverImageId == null) {
coverImageId = parser.hrefAttr()?.removePrefix("#")?.takeIf { it.isNotBlank() }
}
}
"binary" -> {
val id = parser.attrValue("id")
val contentType = parser.attrValue("content-type")
val isExplicitCover = id != null && id == coverImageId
if (extractCover && cover == null && isExplicitCover) {
val encoded = parser.nextTextOrNull()
val decoded = encoded
?.takeIf { it.length <= MAX_COVER_BYTES * 2 }
?.decodeBase64OrNull()
val extension = extensionFromMimeType(contentType)
?: id?.rasterExtension()
?: decoded?.rasterExtensionFromMagic()
?: "jpg"
if (decoded != null && decoded.size <= MAX_COVER_BYTES && extension in rasterCoverExtensions) {
cover = EmbeddedEbookCover(bytes = decoded, extension = extension)
}
}
}
}
}
XmlPullParser.END_TAG -> {
when (parser.name.localXmlName()) {
"body" -> inBody = false
"coverpage" -> inCoverPage = false
"author" -> {
if (inAuthor && authorParts.isNotEmpty()) {
authors += authorParts.joinToString(" ").replace(Regex("\\s+"), " ").trim()
}
inAuthor = false
authorParts.clear()
}
}
}
}
event = parser.next()
}
return EmbeddedEbookMetadata(
title = title,
author = authors.distinct().joinToString(", ").takeIf { it.isNotBlank() },
cover = cover
)
}
private fun extractMobi(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata {
val scan = openStream()?.use { readMobiRecordScan(it) } ?: return EmbeddedEbookMetadata()
val header = scan.headerRecord
val headerInfo = parseMobiHeaderInfo(header)
val charset = when (headerInfo.encoding ?: 1252) {
65001 -> Charsets.UTF_8
1200 -> Charsets.UTF_16
1252 -> Charset.forName("windows-1252")
else -> Charsets.UTF_8
}
val exth = parseMobiExth(header, charset)
val cover = if (extractCover) {
headerInfo.imageIndex
?.let { imageIndex -> exth.coverOffset?.let { imageIndex + it } }
?.takeIf { it > 0 }
?.let { recordIndex -> readMobiRecord(openStream, scan.offsets, recordIndex, MAX_COVER_BYTES) }
?.takeIf { it.size <= MAX_COVER_BYTES }
?.let { imageBytes ->
imageBytes.rasterExtensionFromMagic()?.let { extension ->
EmbeddedEbookCover(bytes = imageBytes, extension = extension)
}
}
} else {
null
}
return EmbeddedEbookMetadata(
title = exth.title,
author = exth.author,
cover = cover
)
}
private fun readMobiRecordScan(input: InputStream): MobiRecordScan? {
val buffered = input.buffered()
val palmHeader = buffered.readExactBytesOrNull(78) ?: return null
val recordCount = palmHeader.u16(76)
if (recordCount <= 0 || recordCount > MAX_MOBI_RECORDS) return null
val recordTable = buffered.readExactBytesOrNull(recordCount * 8) ?: return null
val offsets = (0 until recordCount).map { index ->
recordTable.u32(index * 8).toInt()
}
val record0Start = offsets.getOrNull(0) ?: return null
val record0End = offsets.getOrNull(1)
if (record0Start < 78 + recordTable.size) return null
val headerRecord = readRecordFromCurrentStream(
input = buffered,
currentOffset = 78 + recordTable.size,
recordStart = record0Start,
recordEnd = record0End,
maxBytes = MAX_MOBI_HEADER_RECORD_BYTES
) ?: return null
return MobiRecordScan(offsets = offsets, headerRecord = headerRecord)
}
private fun readMobiRecord(
openStream: () -> InputStream?,
offsets: List<Int>,
recordIndex: Int,
maxBytes: Int
): ByteArray? {
val recordStart = offsets.getOrNull(recordIndex) ?: return null
val recordEnd = offsets.getOrNull(recordIndex + 1)
if (recordStart < 0) return null
return openStream()?.use { input ->
val buffered = input.buffered()
readRecordFromCurrentStream(
input = buffered,
currentOffset = 0,
recordStart = recordStart,
recordEnd = recordEnd,
maxBytes = maxBytes
)
}
}
private fun readRecordFromCurrentStream(
input: InputStream,
currentOffset: Int,
recordStart: Int,
recordEnd: Int?,
maxBytes: Int
): ByteArray? {
if (recordStart < currentOffset) return null
if (!input.skipFully((recordStart - currentOffset).toLong())) return null
val length = recordEnd?.minus(recordStart)
return if (length != null) {
if (length <= 0 || length > maxBytes) return null
input.readExactBytesOrNull(length)
} else {
input.readBytesLimited(maxBytes)
}
}
private fun parseMobiHeaderInfo(header: ByteArray): MobiHeaderInfo {
if (header.size < 32 || header.asciiAt(16, 4) != "MOBI") return MobiHeaderInfo()
val mobiHeaderLength = header.u32(20).toInt()
fun u32InHeader(offset: Int): Int? {
if (mobiHeaderLength < offset + 4 || 16 + offset + 4 > header.size) return null
return header.u32(16 + offset).toInt()
.takeIf { it >= 0 && it != MOBI_NOT_SET }
}
return MobiHeaderInfo(
encoding = u32InHeader(12),
imageIndex = u32InHeader(92)
)
}
private fun parseMobiExth(header: ByteArray, charset: Charset): MobiExthMetadata {
if (header.size < 92 || header.asciiAt(16, 4) != "MOBI") return MobiExthMetadata()
val mobiHeaderLength = header.u32(20).toInt()
val fullNameOffset = header.u32(16 + 68).toInt()
val fullNameLength = header.u32(16 + 72).toInt()
val fullName = header.safeString(fullNameOffset, fullNameLength, charset)
var exthTitle: String? = null
var author: String? = null
var coverOffset: Int? = null
val exthOffsetLong = 16L + mobiHeaderLength
if (mobiHeaderLength <= 0 || exthOffsetLong > Int.MAX_VALUE - 12L) {
return MobiExthMetadata(title = fullName.takeUnlessBlank())
}
val exthOffset = exthOffsetLong.toInt()
if (exthOffset + 12 <= header.size && header.asciiAt(exthOffset, 4) == "EXTH") {
val recordCount = header.u32(exthOffset + 8).toInt()
var offset = exthOffset + 12
repeat(recordCount.coerceIn(0, MAX_MOBI_EXTH_RECORDS)) {
if (offset + 8 > header.size) return@repeat
val type = header.u32(offset).toInt()
val size = header.u32(offset + 4).toInt()
if (size < 8 || offset + size > header.size) return@repeat
val dataOffset = offset + 8
val dataSize = size - 8
when (type) {
99 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset)
100 -> author = author ?: header.safeString(dataOffset, dataSize, charset)
201 -> coverOffset = coverOffset ?: header.u32(dataOffset).toInt().takeIf { dataSize >= 4 }
503 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset)
}
offset += size
}
}
return MobiExthMetadata(
title = exthTitle.takeUnlessBlank() ?: fullName.takeUnlessBlank(),
author = author.takeUnlessBlank(),
coverOffset = coverOffset
)
}
private fun XmlPullParser.nextTextOrNull(): String? {
return try {
nextText()?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
private fun XmlPullParser.attrValue(localName: String): String? {
for (index in 0 until attributeCount) {
val name = getAttributeName(index).localXmlName()
if (name.equals(localName, ignoreCase = true)) {
return getAttributeValue(index)?.takeIf { it.isNotBlank() }
}
}
return null
}
private fun XmlPullParser.hrefAttr(): String? {
return attrValue("href")
?: getAttributeValue("http://www.w3.org/1999/xlink", "href")?.takeIf { it.isNotBlank() }
}
private fun InputStream.readBytesLimited(maxBytes: Int): ByteArray? {
val output = java.io.ByteArrayOutputStream()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0
while (true) {
val read = read(buffer)
if (read == -1) break
total += read
if (total > maxBytes) return null
output.write(buffer, 0, read)
}
return output.toByteArray()
}
private fun InputStream.readExactBytesOrNull(size: Int): ByteArray? {
if (size < 0) return null
val bytes = ByteArray(size)
var offset = 0
while (offset < size) {
val read = read(bytes, offset, size - offset)
if (read == -1) return null
offset += read
}
return bytes
}
private fun InputStream.skipFully(bytes: Long): Boolean {
var remaining = bytes
val scratch = ByteArray(DEFAULT_BUFFER_SIZE)
while (remaining > 0L) {
val skipped = skip(remaining)
if (skipped > 0L) {
remaining -= skipped
continue
}
val read = read(scratch, 0, minOf(scratch.size.toLong(), remaining).toInt())
if (read == -1) return false
remaining -= read
}
return true
}
private fun String.decodeBase64OrNull(): ByteArray? {
return runCatching {
Base64.getMimeDecoder().decode(this)
}.getOrNull()
}
private fun String.attr(name: String): String {
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.get(1)
.orEmpty()
}
private fun String.tagText(tag: String): String? {
return Regex(
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
.find(this)
?.groupValues
?.get(1)
?.replace(Regex("<[^>]+>"), " ")
?.decodeEntities()
?.replace(Regex("\\s+"), " ")
?.trim()
?.takeIf { it.isNotBlank() }
}
private fun String.tagInnerContent(tag: String): String? {
return Regex(
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
.find(this)
?.groupValues
?.get(1)
?.decodeEntities()
?.trim()
?.takeIf { it.isNotBlank() }
}
private fun String.metaContent(name: String): String? {
return Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
.findAll(this)
.firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) }
?.value
?.attr("content")
?.decodeEntities()
?.trim()
?.takeIf { it.isNotBlank() }
}
private fun String.decodeEntities(): String {
return replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
}
.replace(Regex("&#(\\d+);")) { match ->
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
}
}
private fun normalizeZipPath(path: String): String {
val parts = ArrayDeque<String>()
path.replace('\\', '/').trimStart('/').split('/').forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
private fun resolveEpubZipPath(basePath: String, href: String): String {
return normalizeZipPath(if (href.startsWith('/')) href else basePath + href)
}
private fun String.percentDecodedOrSelf(): String {
return runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this)
}
private fun String.localXmlName(): String = substringAfter(':').lowercase()
private fun String.rasterExtension(): String? {
val extension = substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
return extension.takeIf { it in rasterCoverExtensions }
}
private fun extensionFromMimeType(mimeType: String?): String? {
return when (mimeType?.lowercase()) {
"image/jpeg", "image/jpg" -> "jpg"
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
"image/bmp" -> "bmp"
else -> null
}
}
private fun ByteArray.rasterExtensionFromMagic(): String? {
return when {
size >= 3 &&
(this[0].toInt() and 0xFF) == 0xFF &&
(this[1].toInt() and 0xFF) == 0xD8 &&
(this[2].toInt() and 0xFF) == 0xFF -> "jpg"
size >= 8 && asciiAt(1, 3) == "PNG" -> "png"
size >= 6 && (asciiAt(0, 6) == "GIF87a" || asciiAt(0, 6) == "GIF89a") -> "gif"
size >= 12 && asciiAt(0, 4) == "RIFF" && asciiAt(8, 4) == "WEBP" -> "webp"
size >= 2 && asciiAt(0, 2) == "BM" -> "bmp"
else -> null
}
}
private fun ByteArray.u16(offset: Int): Int {
if (offset + 2 > size) return 0
return ((this[offset].toInt() and 0xFF) shl 8) or (this[offset + 1].toInt() and 0xFF)
}
private fun ByteArray.u32(offset: Int): Long {
if (offset + 4 > size) return 0
return ((this[offset].toLong() and 0xFF) shl 24) or
((this[offset + 1].toLong() and 0xFF) shl 16) or
((this[offset + 2].toLong() and 0xFF) shl 8) or
(this[offset + 3].toLong() and 0xFF)
}
private fun ByteArray.asciiAt(offset: Int, length: Int): String {
if (offset < 0 || offset + length > size) return ""
return copyOfRange(offset, offset + length).toString(Charsets.US_ASCII)
}
private fun ByteArray.safeString(offset: Int, length: Int, charset: Charset): String? {
if (offset < 0 || length <= 0 || offset + length > size) return null
return copyOfRange(offset, offset + length).toString(charset)
.trim('\u0000', ' ', '\n', '\r', '\t')
.takeUnlessBlank()
}
private fun String?.takeUnlessBlank(): String? {
return this?.trim()?.takeIf { it.isNotBlank() }
}
private val EpubManifestItem.rasterExtension: String?
get() {
href.rasterExtension()?.let { return it }
return extensionFromMimeType(mediaType)
}
private data class ZipTextEntry(
val path: String,
val text: String
)
private data class EpubManifestItem(
val id: String,
val href: String,
val mediaType: String,
val properties: String
)
private data class MobiHeaderInfo(
val encoding: Int? = null,
val imageIndex: Int? = null
)
private data class MobiRecordScan(
val offsets: List<Int>,
val headerRecord: ByteArray
)
private data class MobiExthMetadata(
val title: String? = null,
val author: String? = null,
val coverOffset: Int? = null
)
}

View file

@ -0,0 +1,115 @@
package com.aryan.reader
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
import com.aryan.reader.shared.reader.SharedEpubMetadataSnapshot
import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
data class AndroidEpubMetadataEditResult(
val metadata: SharedEpubMetadataSnapshot,
val fileSize: Long,
val fileContentModifiedTimestamp: Long
)
class EpubMetadataFileEditor(private val context: Context) {
suspend fun writeMetadata(
item: RecentFileItem,
metadata: BookMetadataEdit
): Result<AndroidEpubMetadataEditResult> = withContext(Dispatchers.IO) {
runCatching {
require(item.type == FileType.EPUB) { "Only EPUB metadata editing is supported." }
val sourceUri = item.uriString?.toUri() ?: error("Book file is not available.")
val token = UUID.randomUUID().toString()
val sourceCopy = File(context.cacheDir, "epub_metadata_source_$token.epub")
val editedCopy = File(context.cacheDir, "epub_metadata_edited_$token.epub")
try {
copyUriToFile(sourceUri, sourceCopy)
val rewritten = SharedEpubMetadataEditor.rewrite(
source = sourceCopy,
destination = editedCopy,
update = SharedEpubMetadataUpdate(
title = metadata.title,
author = metadata.author,
description = metadata.description,
seriesName = metadata.seriesName,
seriesIndex = metadata.seriesIndex
)
)
backupOriginalIfNeeded(item, sourceCopy)
replaceUriBytes(sourceUri, editedCopy)
AndroidEpubMetadataEditResult(
metadata = rewritten,
fileSize = queryFileSize(sourceUri).takeIf { it > 0L } ?: editedCopy.length(),
fileContentModifiedTimestamp = queryLastModified(sourceUri).takeIf { it > 0L }
?: System.currentTimeMillis()
)
} finally {
sourceCopy.delete()
editedCopy.delete()
}
}
}
private fun copyUriToFile(uri: Uri, destination: File) {
context.contentResolver.openInputStream(uri)?.use { input ->
destination.outputStream().use { output -> input.copyTo(output) }
} ?: error("Unable to read EPUB source.")
}
private fun replaceUriBytes(uri: Uri, editedFile: File) {
if (uri.scheme == "file") {
val target = File(uri.path ?: error("Invalid file URI."))
editedFile.inputStream().use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
return
}
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
editedFile.inputStream().use { input -> input.copyTo(output) }
} ?: error("Unable to write EPUB source.")
}
private fun backupOriginalIfNeeded(item: RecentFileItem, sourceCopy: File) {
val backupFile = File(
File(context.filesDir, "metadata_backups").apply { mkdirs() },
"${item.bookId.toSafeBackupName()}.epub"
)
if (!backupFile.exists()) {
sourceCopy.inputStream().use { input ->
backupFile.outputStream().use { output -> input.copyTo(output) }
}
}
}
private fun queryFileSize(uri: Uri): Long {
return if (uri.scheme == "file") {
uri.path?.let { File(it).length() } ?: 0L
} else {
DocumentFile.fromSingleUri(context, uri)?.length() ?: 0L
}
}
private fun queryLastModified(uri: Uri): Long {
return if (uri.scheme == "file") {
uri.path?.let { File(it).lastModified() } ?: 0L
} else {
DocumentFile.fromSingleUri(context, uri)?.lastModified() ?: 0L
}
}
}
private fun String.toSafeBackupName(): String {
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
}

View file

@ -1,130 +1,66 @@
package com.aryan.reader package com.aryan.reader
private val codeOrDataExtensions = setOf( import com.aryan.reader.shared.SharedFileCapabilities
"csv",
"tsv",
"json",
"xml",
"log",
"java",
"kt",
"py",
"js",
"cpp",
"c",
"cs",
"rb",
"go"
)
private val manualOnlyReaderMimeTypes = setOf(
"text/csv",
"text/comma-separated-values",
"text/tab-separated-values",
"application/json",
"application/xml",
"text/xml",
"text/x-java-source",
"text/x-python",
"text/x-kotlin",
"text/javascript",
"application/javascript",
"text/x-c",
"text/x-c++",
"text/x-csharp",
"text/x-ruby",
"text/x-go",
"text/x-log"
)
internal fun resolveFileTypeFromName(fileName: String?): FileType? { internal fun resolveFileTypeFromName(fileName: String?): FileType? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null return SharedFileCapabilities.resolveFileTypeForName(fileName)
val effectiveName = lowerName.withTransparentTextSuffix() }
return when { internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? {
effectiveName.endsWith(".cbz") -> FileType.CBZ val normalizedMimeType = mimeType
effectiveName.endsWith(".cbr") -> FileType.CBR ?.substringBefore(';')
effectiveName.endsWith(".cb7") -> FileType.CB7 ?.trim()
effectiveName.endsWith(".pdf") -> FileType.PDF ?.lowercase()
effectiveName.endsWith(".epub") -> FileType.EPUB return when (normalizedMimeType) {
effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI "application/vnd.oasis.opendocument.text" -> FileType.ODT
effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2 "application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
effectiveName.endsWith(".docx") -> FileType.DOCX "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
effectiveName.endsWith(".odt") -> FileType.ODT when {
effectiveName.endsWith(".fodt") -> FileType.FODT fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
effectiveName.endsWith(".txt") -> FileType.TXT
else -> null else -> null
} }
}
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
}
"application/x-cb7", "application/x-7z-compressed" -> {
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
}
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
"text/markdown", "text/x-markdown" -> FileType.MD
"text/html", "application/xhtml+xml" -> FileType.HTML
"text/csv", "text/comma-separated-values", "text/tab-separated-values",
"application/json", "application/xml", "text/xml",
"text/x-java-source", "text/x-python", "text/x-kotlin",
"text/javascript", "application/javascript",
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
"text/plain" -> resolveFileTypeFromName(fileName) ?: FileType.TXT
else -> resolveFileTypeFromName(fileName)
}
} }
internal fun isCodeOrDataFileName(fileName: String): Boolean { internal fun isCodeOrDataFileName(fileName: String): Boolean {
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions return SharedFileCapabilities.isCodeOrDataFileName(fileName)
} }
internal fun isManualOnlyReaderFileName(fileName: String?): Boolean { internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false return SharedFileCapabilities.isManualOnlyReaderFileName(fileName)
return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
} }
internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean { internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
val normalized = mimeType?.lowercase() ?: return false return SharedFileCapabilities.isManualOnlyReaderMimeType(mimeType)
return normalized in manualOnlyReaderMimeTypes
} }
internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean { internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
if (isManualOnlyReaderFileName(name)) return false return SharedFileCapabilities.isLocalFolderSyncEligibleFile(name, mimeType)
if (resolveFileTypeFromName(name) != null) return true
return !isManualOnlyReaderMimeType(mimeType)
} }
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? { internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null return SharedFileCapabilities.fileExtensionSuffixForName(fileName)
val effectiveName = lowerName.withTransparentTextSuffix()
val effectiveSuffix = when {
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
effectiveName.endsWith(".markdown") -> ".markdown"
effectiveName.endsWith(".xhtml") -> ".xhtml"
effectiveName.extensionAfterLastDot() != null && resolveFileTypeFromName(effectiveName) != null -> ".${effectiveName.extensionAfterLastDot()}"
else -> null
} ?: return null
return if (effectiveName != lowerName && lowerName.endsWith(".txt")) {
"$effectiveSuffix.txt"
} else {
effectiveSuffix
}
}
private fun String.withTransparentTextSuffix(): String {
if (!endsWith(".txt")) return this
val innerName = removeSuffix(".txt")
if (innerName.isBlank() || !innerName.contains('.')) return this
return if (resolveFileTypeFromNameWithoutTransparentText(innerName) != null) innerName else this
}
private fun resolveFileTypeFromNameWithoutTransparentText(fileName: String): FileType? {
return when {
fileName.endsWith(".cbz") -> FileType.CBZ
fileName.endsWith(".cbr") -> FileType.CBR
fileName.endsWith(".cb7") -> FileType.CB7
fileName.endsWith(".pdf") -> FileType.PDF
fileName.endsWith(".epub") -> FileType.EPUB
fileName.endsWith(".mobi") || fileName.endsWith(".azw3") || fileName.endsWith(".prc") -> FileType.MOBI
fileName.endsWith(".fb2") || fileName.endsWith(".fb2.zip") -> FileType.FB2
fileName.endsWith(".md") || fileName.endsWith(".markdown") -> FileType.MD
fileName.endsWith(".html") || fileName.endsWith(".xhtml") || fileName.endsWith(".htm") -> FileType.HTML
fileName.endsWith(".docx") -> FileType.DOCX
fileName.endsWith(".odt") -> FileType.ODT
fileName.endsWith(".fodt") -> FileType.FODT
fileName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
else -> null
}
}
private fun String.extensionAfterLastDot(): String? {
val dotIndex = lastIndexOf('.')
return if (dotIndex in 0..<lastIndex) substring(dotIndex + 1) else null
} }

View file

@ -79,14 +79,14 @@ class FolderSyncWorker(
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {} try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
} }
} else { } else {
allowedFileTypes.addAll(FileType.entries) allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
} }
folders.add(Pair(uri, allowedFileTypes)) folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
} }
} catch (e: Exception) { Timber.e(e) } } catch (e: Exception) { Timber.e(e) }
} else { } else {
val single = prefs.getString("synced_folder_uri", null) val single = prefs.getString("synced_folder_uri", null)
if (single != null) folders.add(Pair(single, FileType.entries.toSet())) if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
} }
if (folders.isEmpty()) { if (folders.isEmpty()) {
@ -380,7 +380,7 @@ class FolderSyncWorker(
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
coverImagePath = null, coverImagePath = null,
title = remoteMeta?.title ?: name, title = name.substringBeforeLast('.', name),
author = remoteMeta?.author, author = remoteMeta?.author,
isAvailable = true, isAvailable = true,
isDeleted = false, isDeleted = false,
@ -395,7 +395,8 @@ class FolderSyncWorker(
customName = remoteMeta?.customName, customName = remoteMeta?.customName,
locatorBlockIndex = remoteMeta?.locatorBlockIndex, locatorBlockIndex = remoteMeta?.locatorBlockIndex,
locatorCharOffset = remoteMeta?.locatorCharOffset, locatorCharOffset = remoteMeta?.locatorCharOffset,
fileSize = size fileSize = size,
fileContentModifiedTimestamp = lastModified
) )
newOrUpdatedItems.add(newItem) newOrUpdatedItems.add(newItem)
newBooks++ newBooks++
@ -403,13 +404,28 @@ class FolderSyncWorker(
var needsUpdate = false var needsUpdate = false
var updatedItem = existingItem var updatedItem = existingItem
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) { val modifiedChanged = lastModified > 0L &&
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).") existingItem.fileContentModifiedTimestamp != lastModified
if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) {
Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.")
recentFilesRepository.clearLocalCachesForBook(stableId) recentFilesRepository.clearLocalCachesForBook(stableId)
updatedItem = updatedItem.copy( updatedItem = updatedItem.copy(
fileSize = size, fileSize = size,
fileContentModifiedTimestamp = lastModified,
lastModifiedTimestamp = lastModified, lastModifiedTimestamp = lastModified,
folderTextMetadataParsed = false coverImagePath = null,
title = name.substringBeforeLast('.', name),
author = null,
seriesName = null,
seriesIndex = null,
description = null,
originalTitle = null,
originalAuthor = null,
originalSeriesName = null,
originalSeriesIndex = null,
originalDescription = null,
folderTextMetadataParsed = false,
folderCoverMetadataParsed = false
) )
needsUpdate = true needsUpdate = true
} }
@ -500,7 +516,7 @@ class FolderSyncWorker(
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) { if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) { if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString") ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>() val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
.setInputData( .setInputData(
androidx.work.Data.Builder() androidx.work.Data.Builder()
@ -514,7 +530,7 @@ class FolderSyncWorker(
metaRequest metaRequest
) )
} else { } else {
ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString") ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
} }
} }
@ -602,15 +618,7 @@ class FolderSyncWorker(
} }
private fun getFileType(name: String, mimeType: String?): FileType? { private fun getFileType(name: String, mimeType: String?): FileType? {
return when (mimeType) { return resolveFileTypeFromMetadata(name, mimeType)
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/vnd.oasis.opendocument.text" -> FileType.ODT
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
"text/html", "application/xhtml+xml" -> FileType.HTML
else -> resolveFileTypeFromName(name)
}
} }
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String { private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {

View file

@ -71,6 +71,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.FavoriteBorder
@ -140,10 +141,8 @@ import androidx.navigation.NavHostController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@ -171,15 +170,16 @@ fun HomeScreen(
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val screenModel = remember(uiState) { uiState.toHomeScreenModel() } val recentFilesForHome = uiState.recentFiles
val recentFilesForHome = screenModel.recentFiles val openTabs = uiState.openTabs
val openTabs = screenModel.openTabs val selectedContextItems = uiState.contextualActionItems
val selectedContextItems = screenModel.selectedItems val isContextualModeActive = selectedContextItems.isNotEmpty()
val isContextualModeActive = screenModel.isContextualModeActive val isHomeEmpty = recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())
val isLibraryEmpty = uiState.rawLibraryFiles.isEmpty()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val deviceLimitState = screenModel.deviceLimitState val deviceLimitState = uiState.deviceLimitState
var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showClearCloudDataDialog by remember { mutableStateOf(false) } var showClearCloudDataDialog by remember { mutableStateOf(false) }
@ -224,15 +224,6 @@ fun HomeScreen(
} }
} }
LaunchedEffect(uiState.bannerMessage) {
uiState.bannerMessage?.let { msg ->
if (!msg.isPersistent) {
delay(3000L)
viewModel.bannerMessageShown()
}
}
}
LaunchedEffect(uiState.errorMessage) { LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { message -> uiState.errorMessage?.let { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
@ -319,6 +310,12 @@ fun HomeScreen(
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE) navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
} }
}, },
onSettingsClick = {
scope.launch {
drawerState.close()
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
}
},
navController = navController, navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled onFolderSyncToggle = viewModel::setFolderSyncEnabled
) )
@ -353,6 +350,9 @@ fun HomeScreen(
} }
}, },
onAppThemeClick = { showAppThemePanel = true }, onAppThemeClick = { showAppThemePanel = true },
onSettingsClick = {
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
},
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) }, onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
onLanguageClick = { showLanguageDialog = true }, onLanguageClick = { showLanguageDialog = true },
@ -394,8 +394,8 @@ fun HomeScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
if (screenModel.isEmpty) { if (isHomeEmpty) {
if (screenModel.isLibraryEmpty) { if (isLibraryEmpty) {
EmptyState( EmptyState(
title = stringResource(R.string.your_library_empty), title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.your_library_empty_desc), message = stringResource(R.string.your_library_empty_desc),
@ -502,8 +502,14 @@ fun HomeScreen(
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onUpdateName = { newName -> onSaveMetadata = { metadata ->
viewModel.updateCustomName(item.bookId, newName) viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
}, },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
@ -793,16 +799,8 @@ fun RecentFileCard(
onLongClick: () -> Unit, onLongClick: () -> Unit,
isDownloading: Boolean, isDownloading: Boolean,
) { ) {
val context = LocalContext.current
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt() val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " " val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
}
val imageModel = remember(item.coverImagePath) {
item.coverImagePath?.let { File(it) } ?: placeholder
}
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
modifier = modifier modifier = modifier
@ -827,14 +825,14 @@ fun RecentFileCard(
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(0.74f) .aspectRatio(0.74f)
) { ) {
AsyncImage( ThemedBookCover(
model = ImageRequest.Builder(context).data(imageModel).error(placeholder) item = item,
.fallback(placeholder).crossfade(true).build(),
contentDescription = item.displayName, contentDescription = item.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
if (!item.coverImagePath.isNullOrBlank()) {
Box( Box(
modifier = Modifier.fillMaxSize().background( modifier = Modifier.fillMaxSize().background(
androidx.compose.ui.graphics.Brush.verticalGradient( androidx.compose.ui.graphics.Brush.verticalGradient(
@ -845,6 +843,7 @@ fun RecentFileCard(
) )
) )
) )
}
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) { if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
FileStatusBadges( FileStatusBadges(
@ -1024,6 +1023,7 @@ fun DefaultTopAppBar(
onExternalFileBehaviorClick: () -> Unit, onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit, onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit, onAppThemeClick: () -> Unit,
onSettingsClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit, onTestPanelDetectionClick: () -> Unit,
onTestSpeechBubbleDetectionClick: () -> Unit, onTestSpeechBubbleDetectionClick: () -> Unit,
onLanguageClick: () -> Unit, onLanguageClick: () -> Unit,
@ -1048,6 +1048,9 @@ fun DefaultTopAppBar(
} }
} }
}, actions = { }, actions = {
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
Box { Box {
IconButton(onClick = onAppThemeClick) { IconButton(onClick = onAppThemeClick) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme)) Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme))
@ -1134,6 +1137,7 @@ fun DefaultTopAppBar(
showOptionsMenu = false showOptionsMenu = false
}) })
if (!BuildConfig.IS_OFFLINE) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") }, text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
onClick = { onClick = {
@ -1147,6 +1151,7 @@ fun DefaultTopAppBar(
} }
} }
) )
}
HorizontalDivider() HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
@ -1204,6 +1209,7 @@ private fun AppDrawerContent(
onSyncUpsellClick: () -> Unit, onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit, onFontsClick: () -> Unit,
onAiSettingsClick: () -> Unit, onAiSettingsClick: () -> Unit,
onSettingsClick: () -> Unit,
navController: NavHostController, navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit onFolderSyncToggle: (Boolean) -> Unit
) { ) {
@ -1366,6 +1372,14 @@ private fun AppDrawerContent(
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
} }
NavigationDrawerItem(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
selected = false,
onClick = onSettingsClick,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) }, icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_custom_fonts)) }, label = { Text(stringResource(R.string.drawer_custom_fonts)) },

View file

@ -1,40 +1,21 @@
package com.aryan.reader package com.aryan.reader
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
enum class AddBooksSource { typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource
UNSHELVED, typealias FileType = com.aryan.reader.shared.FileType
ALL_BOOKS typealias RenderMode = com.aryan.reader.shared.RenderMode
} typealias SortOrder = com.aryan.reader.shared.SortOrder
typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
enum class FileType { internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
} internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
internal val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
internal val EPUB_READER_FILE_TYPES = setOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
enum class RenderMode {
VERTICAL_SCROLL, PAGINATED
}
data class SyncedFolder(
val uriString: String,
val name: String,
val lastScanTime: Long,
val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
)
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER } enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
@ -54,33 +35,3 @@ data class Shelf(
val directBookCount: Int get() = directBooks.size val directBookCount: Int get() = directBooks.size
val childShelfCount: Int get() = childShelfIds.size val childShelfCount: Int get() = childShelfIds.size
} }
enum class SortOrder {
RECENT,
TITLE_ASC,
AUTHOR_ASC,
PERCENT_ASC,
PERCENT_DESC,
SIZE_ASC,
SIZE_DESC
}
enum class ReadStatusFilter {
ALL,
UNREAD,
IN_PROGRESS,
COMPLETED
}
data class LibraryFilters(
val fileTypes: Set<FileType> = emptySet(),
val sourceFolders: Set<String> = emptySet(),
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
val tagIds: Set<String> = emptySet()
) {
val isActive: Boolean
get() = fileTypes.isNotEmpty() ||
sourceFolders.isNotEmpty() ||
readStatus != ReadStatusFilter.ALL ||
tagIds.isNotEmpty()
}

View file

@ -79,6 +79,7 @@ import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
@ -133,6 +134,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
@ -148,7 +150,6 @@ import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup import org.jsoup.Jsoup
import timber.log.Timber import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -163,6 +164,7 @@ private fun getBookCountString(count: Int): String {
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
navController: NavHostController,
) { ) {
val compStart = remember { System.currentTimeMillis() } val compStart = remember { System.currentTimeMillis() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -170,14 +172,13 @@ fun LibraryScreen(
} }
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val screenModel = remember(uiState) { uiState.toLibraryScreenModel() } val selectedItems = uiState.contextualActionItems
val selectedItems = screenModel.selectedItems val isContextualModeActive = selectedItems.isNotEmpty()
val isContextualModeActive = screenModel.isContextualModeActive val selectedShelves = uiState.contextualActionShelfIds
val selectedShelves = screenModel.selectedShelves val isShelfContextualModeActive = selectedShelves.isNotEmpty()
val isShelfContextualModeActive = screenModel.isShelfContextualModeActive val sortOrder = uiState.sortOrder
val sortOrder = screenModel.sortOrder val shelves = uiState.shelves
val shelves = screenModel.shelves val rawLibraryFiles = uiState.rawLibraryFiles
val rawLibraryFiles = screenModel.rawLibraryFiles
val tabTitles = remember { val tabTitles = remember {
buildList { buildList {
add(context.getString(R.string.tab_all_books)) add(context.getString(R.string.tab_all_books))
@ -193,13 +194,13 @@ fun LibraryScreen(
pageCount = { tabTitles.size } pageCount = { tabTitles.size }
) )
val containsFolderItems = screenModel.containsFolderItemsInSelection val containsFolderItems = selectedItems.any { it.sourceFolderUri != null }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showFilterSheet by remember { mutableStateOf(false) } var showFilterSheet by remember { mutableStateOf(false) }
val isSearchActive = screenModel.isSearchActive val isSearchActive = uiState.isSearchActive
val searchQuery = screenModel.searchQuery val searchQuery = uiState.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult( val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree() contract = ActivityResultContracts.OpenDocumentTree()
@ -341,7 +342,8 @@ fun LibraryScreen(
catalogId = catalog?.id catalogId = catalog?.id
) )
}, },
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }
) )
@ -394,8 +396,14 @@ fun LibraryScreen(
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
}, },
onUpdateName = { newName -> onSaveMetadata = { metadata ->
viewModel.updateCustomName(item.bookId, newName) viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
}, },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
@ -516,7 +524,9 @@ fun ShelfScreen(
FileInfoDialog( FileInfoDialog(
item = item, item = item,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) }, onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
} }
@ -577,6 +587,7 @@ fun LibraryScreenContent(
onOpdsBookDownloaded: (Uri, String) -> Unit, onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit, onDeleteCatalogStreams: (String) -> Unit,
onSettingsClick: () -> Unit,
) { ) {
val isBookContextualModeActive = selectedItems.isNotEmpty() val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty()
@ -711,6 +722,9 @@ fun LibraryScreenContent(
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
} }
} }
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
} }
) )
TabRow(selectedTabIndex = pagerState.currentPage) { TabRow(selectedTabIndex = pagerState.currentPage) {
@ -1442,8 +1456,6 @@ private fun AddBooksModeScreen(
@Composable @Composable
private fun ShelfCover(shelf: Shelf) { private fun ShelfCover(shelf: Shelf) {
val context = LocalContext.current
val placeholder = R.drawable.epub_placeholder
val booksForCovers = shelf.books.take(4).reversed() val booksForCovers = shelf.books.take(4).reversed()
val coverWidth = 52.dp val coverWidth = 52.dp
val coverHeight = 75.dp val coverHeight = 75.dp
@ -1457,22 +1469,24 @@ private fun ShelfCover(shelf: Shelf) {
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
if (booksForCovers.size <= 1) { if (booksForCovers.size <= 1) {
val imageModel = remember(shelf.topBook?.coverImagePath) { val topBook = shelf.topBook
shelf.topBook?.coverImagePath?.let { File(it) } ?: placeholder if (topBook != null) {
} ThemedBookCover(
AsyncImage( item = topBook,
model = ImageRequest.Builder(context)
.data(imageModel)
.error(placeholder)
.fallback(placeholder)
.crossfade(true)
.build(),
contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name), contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(width = coverWidth, height = coverHeight) .size(width = coverWidth, height = coverHeight)
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
) )
} else {
EmptyShelfCover(
shelfName = shelf.name,
modifier = Modifier
.size(width = coverWidth, height = coverHeight)
.clip(MaterialTheme.shapes.small)
)
}
} else { } else {
Box( Box(
modifier = Modifier modifier = Modifier
@ -1480,9 +1494,6 @@ private fun ShelfCover(shelf: Shelf) {
.height(coverHeight) .height(coverHeight)
) { ) {
booksForCovers.forEachIndexed { index, book -> booksForCovers.forEachIndexed { index, book ->
val imageModel = remember(book.coverImagePath) {
book.coverImagePath?.let { File(it) } ?: placeholder
}
Surface( Surface(
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
shadowElevation = 4.dp, shadowElevation = 4.dp,
@ -1491,13 +1502,8 @@ private fun ShelfCover(shelf: Shelf) {
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.offset(x = -horizontalOffset * index) .offset(x = -horizontalOffset * index)
) { ) {
AsyncImage( ThemedBookCover(
model = ImageRequest.Builder(context) item = book,
.data(imageModel)
.error(placeholder)
.fallback(placeholder)
.crossfade(true)
.build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
@ -1508,6 +1514,36 @@ private fun ShelfCover(shelf: Shelf) {
} }
} }
@Composable
private fun EmptyShelfCover(
shelfName: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.surfaceContainerHighest
)
)
)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
Text(
text = shelfName.takeIf { it.isNotBlank() } ?: stringResource(R.string.tab_shelves),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(8.dp)
)
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun ShelfListItem( private fun ShelfListItem(
@ -1596,15 +1632,6 @@ private fun LibraryListItem(
onItemLongClick: () -> Unit, onItemLongClick: () -> Unit,
isDownloading: Boolean, isDownloading: Boolean,
) { ) {
val context = LocalContext.current
val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
}
val imageModel = remember(item.coverImagePath) {
item.coverImagePath?.let { File(it) } ?: placeholder
}
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors( colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@ -1641,13 +1668,8 @@ private fun LibraryListItem(
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium) .border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
) { ) {
AsyncImage( ThemedBookCover(
model = ImageRequest.Builder(context) item = item,
.data(imageModel)
.error(placeholder)
.fallback(placeholder)
.crossfade(true)
.build(),
contentDescription = item.displayName, contentDescription = item.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -2148,7 +2170,7 @@ private fun EditFolderFiltersDialog(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
FileType.entries.forEach { type -> ANDROID_SYNCABLE_FILE_TYPES.forEach { type ->
val isSelected = type in selectedTypes val isSelected = type in selectedTypes
FilterChip( FilterChip(
selected = isSelected, selected = isSelected,
@ -2226,7 +2248,7 @@ fun LibraryFilterSheet(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
FileType.entries.forEach { type -> ANDROID_READABLE_FILE_TYPES.forEach { type ->
FilterChip( FilterChip(
selected = type in currentFilters.fileTypes, selected = type in currentFilters.fileTypes,
onClick = { onClick = {

View file

@ -5,7 +5,10 @@ import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.SmartCollectionEngine import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.applyLibraryFilters as sharedApplyLibraryFilters
import com.aryan.reader.shared.filterBySearch as sharedFilterBySearch
import com.aryan.reader.shared.sortBooks as sharedSortBooks
fun interface FolderPathResolver { fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List<String> fun relativeFolderSegments(item: RecentFileItem): List<String>
@ -32,17 +35,28 @@ class LibraryStateProjector(
fun project(input: LibraryProjectionInput): ReaderScreenState { fun project(input: LibraryProjectionInput): ReaderScreenState {
val start = ReaderPerfLog.nowNanos() val start = ReaderPerfLog.nowNanos()
val internalState = input.state val internalState = input.state
val bridgeContext = AndroidSharedStateBridge.prepareLibraryProjection(input, folderPathResolver)
val cacheKey = ProjectionCacheKey( val cacheKey = ProjectionCacheKey(
recentFilesFromDb = input.recentFilesFromDb, recentFilesFromDb = input.recentFilesFromDb,
dbShelves = input.dbShelves, dbShelves = input.dbShelves,
shelfRefs = input.shelfRefs, shelfRefs = input.shelfRefs,
dbTags = input.dbTags, dbTags = input.dbTags,
tagRefs = input.tagRefs, tagRefs = input.tagRefs,
folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) }, folderKeys = bridgeContext.folderKeys,
sortOrder = internalState.sortOrder, sortOrder = internalState.sortOrder,
searchQuery = internalState.searchQuery, searchQuery = internalState.searchQuery,
libraryFilters = internalState.libraryFilters, libraryFilters = internalState.libraryFilters,
recentFilesLimit = internalState.recentFilesLimit recentFilesLimit = internalState.recentFilesLimit,
openTabIds = internalState.openTabIds,
activeTabBookId = internalState.activeTabBookId,
selectedBookIds = internalState.contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
selectedShelfIds = internalState.contextualActionShelfIds,
viewingShelfId = internalState.viewingShelfId,
isAddingBooksToShelf = internalState.isAddingBooksToShelf,
addBooksSource = internalState.addBooksSource,
booksSelectedForAdding = internalState.booksSelectedForAdding,
pinnedHomeBookIds = internalState.pinnedHomeBookIds,
pinnedLibraryBookIds = internalState.pinnedLibraryBookIds
) )
cachedProjection?.takeIf { it.key == cacheKey }?.let { cache -> cachedProjection?.takeIf { it.key == cacheKey }?.let { cache ->
@ -50,71 +64,26 @@ class LibraryStateProjector(
val elapsed = ReaderPerfLog.elapsedMs(start) val elapsed = ReaderPerfLog.elapsedMs(start)
if (elapsed >= 8L) { if (elapsed >= 8L) {
ReaderPerfLog.d( ReaderPerfLog.d(
"LibraryProject cache-hit took ${elapsed}ms books=${cache.allLibraryFiles.size} shelves=${cache.shelfProjection.shelves.size}" "LibraryProject cache-hit took ${elapsed}ms books=${cache.androidBooksById.size} shelves=${cache.projected.shelves.size}"
) )
} }
return result return result
} }
val tagsById = input.dbTags.associateBy { it.id } val projected = AndroidSharedStateBridge.projectLibrary(bridgeContext)
val bookTagsMap = input.tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
val allLibraryFiles = input.recentFilesFromDb
.filterNot { it.bookId.endsWith("_reflow") }
.map { item ->
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
}
val allLibraryFilesById = allLibraryFiles.associateBy { it.bookId }
val rawFilteredByQuery = filterBySearch(allLibraryFiles, internalState.searchQuery)
val libraryFiltered = applyLibraryFilters(rawFilteredByQuery, internalState.libraryFilters)
val sortedLibraryFiles = if (internalState.sortOrder == SortOrder.RECENT) {
libraryFiltered
} else {
sortFiles(libraryFiltered, internalState.sortOrder)
}
val recentLimit = if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
val visibleRecentFiles = if (internalState.sortOrder == SortOrder.RECENT) {
allLibraryFiles
.asSequence()
.filter { it.isRecent }
.take(recentLimit)
.toList()
} else {
sortFiles(
allLibraryFiles.filter { it.isRecent },
internalState.sortOrder
).take(recentLimit)
}
val shelfProjection = buildShelves(
allLibraryFiles = allLibraryFiles,
dbShelves = input.dbShelves,
shelfRefs = input.shelfRefs,
dbTags = input.dbTags,
sortOrder = internalState.sortOrder,
syncedFolders = internalState.syncedFolders
)
val cache = CachedProjection( val cache = CachedProjection(
key = cacheKey, key = cacheKey,
allLibraryFiles = allLibraryFiles, projected = projected,
allLibraryFilesById = allLibraryFilesById, androidBooksById = bridgeContext.androidBooksById,
sortedLibraryFiles = sortedLibraryFiles, tagEntitiesById = bridgeContext.tagEntitiesById
visibleRecentFiles = visibleRecentFiles,
shelfProjection = shelfProjection,
validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id },
dbTags = input.dbTags
) )
cachedProjection = cache cachedProjection = cache
val elapsed = ReaderPerfLog.elapsedMs(start) val elapsed = ReaderPerfLog.elapsedMs(start)
if (elapsed >= 16L || allLibraryFiles.size >= 500) { if (elapsed >= 16L || bridgeContext.androidBooksById.size >= 500) {
ReaderPerfLog.d( ReaderPerfLog.d(
"LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " + "LibraryProject shared recompute took ${elapsed}ms books=${bridgeContext.androidBooksById.size} " +
"visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " + "visible=${projected.libraryBooks.size} shelves=${projected.shelves.size} " +
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}" "tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
) )
} }
@ -126,310 +95,63 @@ class LibraryStateProjector(
internalState: ReaderScreenState, internalState: ReaderScreenState,
cache: CachedProjection cache: CachedProjection
): ReaderScreenState { ): ReaderScreenState {
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds } return AndroidSharedStateBridge.toAndroidState(
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds } base = internalState,
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) { sharedState = cache.projected,
val currentShelfBookIds = cache.shelfProjection.shelves androidBooksById = cache.androidBooksById,
.find { it.id == viewingShelfId } tagEntitiesById = cache.tagEntitiesById
?.books
?.mapTo(mutableSetOf()) { it.bookId }
?: emptySet()
when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> cache.shelfProjection.unshelvedBooks
AddBooksSource.ALL_BOOKS -> cache.allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
}
} else {
emptyList()
}
return internalState.copy(
recentFiles = cache.visibleRecentFiles,
allRecentFiles = cache.sortedLibraryFiles,
rawLibraryFiles = cache.allLibraryFiles,
viewingShelfId = viewingShelfId,
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
contextualActionShelfIds = selectedShelfIds,
contextualActionItems = internalState.contextualActionItems
.mapNotNull { ctx -> cache.allLibraryFilesById[ctx.bookId] }
.toSet(),
shelves = cache.shelfProjection.shelves,
openTabs = internalState.openTabIds.mapNotNull { tabId -> cache.allLibraryFilesById[tabId] },
booksAvailableForAdding = booksAvailableForAdding,
allTags = cache.dbTags
) )
} }
private fun buildShelves(
allLibraryFiles: List<RecentFileItem>,
dbShelves: List<ShelfEntity>,
shelfRefs: List<BookShelfCrossRef>,
dbTags: List<TagEntity>,
sortOrder: SortOrder,
syncedFolders: List<SyncedFolder>
): ShelfProjection {
val allShelves = mutableListOf<Shelf>()
val shelvedBookIds = mutableSetOf<String>()
val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId }
val taggedBookIdsByTagId = mutableMapOf<String, MutableList<String>>()
allLibraryFiles.forEach { item ->
item.tags.forEach { tag ->
taggedBookIdsByTagId.getOrPut(tag.id) { mutableListOf() }.add(item.bookId)
}
}
dbShelves.forEach { shelfEntity ->
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
if (rules != null) {
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it.toSharedBookItem(), rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}
} else {
val bookIdsInShelf = shelfRefsByShelfId[shelfEntity.id].orEmpty()
.sortedBy { it.addedAt }
.map { it.bookId }
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf, sortOrder)))
shelvedBookIds.addAll(bookIdsInShelf)
}
}
val tagShelves = dbTags.mapNotNull { tag ->
val taggedBooks = taggedBookIdsByTagId[tag.id].orEmpty().mapNotNull { baseFilesMap[it] }
if (taggedBooks.isEmpty()) {
null
} else {
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks, sortOrder))
}
}
allShelves.addAll(tagShelves)
val seriesShelves = allLibraryFiles
.filter { !it.seriesName.isNullOrBlank() }
.groupBy { it.seriesName!! }
.filter { it.value.size >= 2 }
.map { (series, books) ->
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
shelvedBookIds.addAll(books.map { it.bookId })
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
}
allShelves.addAll(seriesShelves)
val folderShelves = buildFolderShelves(
allLibraryFiles = allLibraryFiles,
syncedFolders = syncedFolders,
sortOrder = sortOrder
).also { shelves ->
shelves.forEach { shelf ->
shelvedBookIds.addAll(shelf.books.map { it.bookId })
}
}
allShelves.addAll(folderShelves)
val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds }
allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks, sortOrder)))
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
return ShelfProjection(shelves = allShelves, unshelvedBooks = unshelvedBooks)
}
private fun buildFolderShelves(
allLibraryFiles: List<RecentFileItem>,
syncedFolders: List<SyncedFolder>,
sortOrder: SortOrder
): List<Shelf> {
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
val folderSegmentsByBookId = allLibraryFiles
.asSequence()
.filter { it.sourceFolderUri != null }
.associate { it.bookId to folderPathResolver.relativeFolderSegments(it) }
return allLibraryFiles
.filter { it.sourceFolderUri != null }
.groupBy { it.sourceFolderUri!! }
.flatMap { (folderUri, books) ->
val rootName = folderNamesByUri[folderUri] ?: "Local Folder"
val rootShelfId = "folder_$folderUri"
val rootAccumulator = FolderShelfAccumulator(
id = rootShelfId,
name = rootName,
depth = 0,
parentShelfId = null,
sortPath = ""
)
val rootShelf = Shelf(
id = rootShelfId,
name = rootName,
type = ShelfType.FOLDER,
books = sortFiles(books, sortOrder),
directBooks = emptyList(),
childShelfIds = emptyList(),
depth = 0,
sortKey = "folder:${rootName.lowercase()}:"
)
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
val nestedShelvesById = mutableMapOf<String, FolderShelfAccumulator>()
books.forEach { book ->
rootAccumulator.books.add(book)
val segments = folderSegmentsByBookId[book.bookId].orEmpty()
if (segments.isEmpty()) {
rootAccumulator.directBooks.add(book)
}
var currentPath = ""
var parentShelfId = rootShelfId
segments.forEachIndexed { index, segment ->
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
val shelfId = "folder_$folderUri::$currentPath"
val accumulator = nestedShelves.getOrPut(currentPath) {
val newShelf = FolderShelfAccumulator(
id = shelfId,
name = segment,
depth = index + 1,
parentShelfId = parentShelfId,
sortPath = currentPath.lowercase()
)
if (parentShelfId == rootShelfId) {
rootAccumulator.childShelfIds.add(shelfId)
} else {
nestedShelvesById[parentShelfId]?.childShelfIds?.add(shelfId)
}
nestedShelvesById[shelfId] = newShelf
newShelf
}
accumulator.books.add(book)
if (index == segments.lastIndex) {
accumulator.directBooks.add(book)
}
parentShelfId = shelfId
}
}
val sortedNestedShelves = nestedShelves
.values
.sortedBy { it.sortPath }
.map { shelf ->
Shelf(
id = shelf.id,
name = shelf.name,
type = ShelfType.FOLDER,
books = sortFiles(shelf.books, sortOrder),
directBooks = sortFiles(shelf.directBooks, sortOrder),
parentShelfId = shelf.parentShelfId,
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
depth = shelf.depth,
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
)
}
listOf(
rootShelf.copy(
directBooks = sortFiles(rootAccumulator.directBooks, sortOrder),
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
)
) + sortedNestedShelves
}
}
private data class FolderShelfAccumulator(
val id: String,
val name: String,
val depth: Int,
val parentShelfId: String?,
val sortPath: String,
val books: MutableList<RecentFileItem> = mutableListOf(),
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
val childShelfIds: MutableList<String> = mutableListOf()
)
private data class ShelfProjection(
val shelves: List<Shelf>,
val unshelvedBooks: List<RecentFileItem>
)
private data class ProjectionCacheKey( private data class ProjectionCacheKey(
val recentFilesFromDb: List<RecentFileItem>, val recentFilesFromDb: List<RecentFileItem>,
val dbShelves: List<ShelfEntity>, val dbShelves: List<ShelfEntity>,
val shelfRefs: List<BookShelfCrossRef>, val shelfRefs: List<BookShelfCrossRef>,
val dbTags: List<TagEntity>, val dbTags: List<TagEntity>,
val tagRefs: List<BookTagCrossRef>, val tagRefs: List<BookTagCrossRef>,
val folderKeys: List<SyncedFolderProjectionKey>, val folderKeys: List<AndroidSharedFolderProjectionKey>,
val sortOrder: SortOrder, val sortOrder: SortOrder,
val searchQuery: String, val searchQuery: String,
val libraryFilters: LibraryFilters, val libraryFilters: LibraryFilters,
val recentFilesLimit: Int val recentFilesLimit: Int,
) val openTabIds: List<String>,
val activeTabBookId: String?,
private data class SyncedFolderProjectionKey( val selectedBookIds: Set<String>,
val uriString: String, val selectedShelfIds: Set<String>,
val name: String val viewingShelfId: String?,
val isAddingBooksToShelf: Boolean,
val addBooksSource: AddBooksSource,
val booksSelectedForAdding: Set<String>,
val pinnedHomeBookIds: Set<String>,
val pinnedLibraryBookIds: Set<String>
) )
private data class CachedProjection( private data class CachedProjection(
val key: ProjectionCacheKey, val key: ProjectionCacheKey,
val allLibraryFiles: List<RecentFileItem>, val projected: SharedReaderScreenState,
val allLibraryFilesById: Map<String, RecentFileItem>, val androidBooksById: Map<String, RecentFileItem>,
val sortedLibraryFiles: List<RecentFileItem>, val tagEntitiesById: Map<String, TagEntity>
val visibleRecentFiles: List<RecentFileItem>,
val shelfProjection: ShelfProjection,
val validShelfIds: Set<String>,
val dbTags: List<TagEntity>
) )
} }
fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> { fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> {
val query = searchQuery.trim() return files.mapSharedResults(sharedFilterBySearch(files.map { it.toSharedProjectionBookItem() }, searchQuery))
return if (query.isBlank()) {
files
} else {
files.filter { item ->
item.displayName.contains(query, ignoreCase = true) ||
item.title?.contains(query, ignoreCase = true) == true ||
item.author?.contains(query, ignoreCase = true) == true ||
item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
}
}
} }
fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> { fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> {
return files.filter { item -> return files.mapSharedResults(
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true sharedApplyLibraryFilters(
val matchFolder = if (filters.sourceFolders.isNotEmpty()) { books = files.map { it.toSharedProjectionBookItem() },
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && filters = filters.toSharedLibraryFilters()
item.sourceFolderUri == null && )
item.uriString?.startsWith("opds-pse") != true )
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
matchesInApp || matchesSynced
} else {
true
}
val progress = item.progressPercentage ?: 0f
val matchStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true
ReadStatusFilter.UNREAD -> progress == 0f
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
ReadStatusFilter.COMPLETED -> progress >= 100f
}
val matchTags = if (filters.tagIds.isNotEmpty()) {
item.tags.any { it.id in filters.tagIds }
} else {
true
}
matchType && matchFolder && matchStatus && matchTags
}
} }
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> { fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
return when (sortOrder) { return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder()))
SortOrder.RECENT -> files.sortedByDescending { it.timestamp } }
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f } val byId = associateBy { it.bookId }
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f } return sharedBooks.mapNotNull { byId[it.id] }
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
}
} }

View file

@ -121,9 +121,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {

View file

@ -110,7 +110,10 @@ fun MainScreen(
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
navController = navController navController = navController
) )
1 -> LibraryScreen(viewModel = viewModel) 1 -> LibraryScreen(
viewModel = viewModel,
navController = navController
)
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,10 @@ import android.provider.OpenableColumns
import android.util.Xml import android.util.Xml
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.WorkManager
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import io.legere.pdfiumandroid.PdfiumCore import io.legere.pdfiumandroid.PdfiumCore
@ -28,7 +31,17 @@ class MetadataExtractionWorker(
const val WORK_NAME = "MetadataExtractionWorker" const val WORK_NAME = "MetadataExtractionWorker"
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri" const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
private const val METADATA_DB_BATCH_SIZE = 100 private const val METADATA_DB_BATCH_SIZE = 100
private const val METADATA_WORKER_BOOK_BATCH_SIZE = 300
private const val METADATA_PROGRESS_LOG_EVERY = 250 private const val METADATA_PROGRESS_LOG_EVERY = 250
private val TEXT_METADATA_TYPES = setOf(
FileType.PDF,
FileType.EPUB,
FileType.MOBI,
FileType.FB2,
FileType.ODT,
FileType.FODT,
FileType.DOCX
)
} }
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
@ -45,20 +58,25 @@ class MetadataExtractionWorker(
} }
try { try {
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri) val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
sourceFolderUri = sourceFolderUri,
limit = METADATA_WORKER_BOOK_BATCH_SIZE
)
if (filesToProcess.isEmpty()) { if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}") ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
return@withContext Result.success() return@withContext Result.success()
} }
ReaderPerfLog.i( ReaderPerfLog.i(
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}" "MetadataWorker start mode=metadata books=${filesToProcess.size} " +
"batchLimit=$METADATA_WORKER_BOOK_BATCH_SIZE folder=${sourceFolderUri ?: "ALL"}"
) )
val pendingUpdates = mutableListOf<RecentFileItem>() val pendingUpdates = mutableListOf<RecentFileItem>()
var processed = 0 var processed = 0
var updated = 0 var updated = 0
var coversUpdated = 0
var failed = 0 var failed = 0
suspend fun flushUpdates() { suspend fun flushUpdates() {
@ -76,36 +94,80 @@ class MetadataExtractionWorker(
if (item.sourceFolderUri == null) return@forEach if (item.sourceFolderUri == null) return@forEach
var needsTextMetadata = item.type in TEXT_METADATA_TYPES && !item.folderTextMetadataParsed
var needsEmbeddedCover = false
try { try {
val uri = item.uriString?.toUri() ?: return@forEach val uri = item.uriString?.toUri() ?: return@forEach
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri) val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
val existingCoverIsAvailable = item.coverImagePath?.let { File(it).isFile } == true
needsEmbeddedCover = EmbeddedEbookMetadataExtractor.canExtractEmbeddedCover(item.type) &&
!item.folderCoverMetadataParsed &&
!existingCoverIsAvailable
val metadata = when (item.type) { val metadata = when (item.type) {
FileType.EPUB -> parseEpubTextMetadata(uri) FileType.EPUB,
FileType.MOBI,
FileType.FB2 -> {
if (needsTextMetadata || needsEmbeddedCover) {
EmbeddedEbookMetadataExtractor.extract(
type = item.type,
displayName = item.displayName,
openStream = { appContext.contentResolver.openInputStream(uri) },
extractCover = needsEmbeddedCover
).toTextMetadata()
} else {
TextMetadata()
}
}
FileType.PDF -> parsePdfTextMetadata(uri) FileType.PDF -> parsePdfTextMetadata(uri)
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml") FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
FileType.FODT -> parseFlatXmlTextMetadata(uri) FileType.FODT -> parseFlatXmlTextMetadata(uri)
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml") FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
FileType.PPTX -> parseZipTextMetadata(uri, "docProps/core.xml")
else -> TextMetadata() else -> TextMetadata()
} }
val title = sanitizeTitle(metadata.title) val title = sanitizeTitle(metadata.title)
val author = sanitizeAuthor(metadata.author) val author = sanitizeAuthor(metadata.author)
val description = metadata.description?.trim()?.takeIf { it.isNotBlank() }
val seriesName = metadata.seriesName?.trim()?.takeIf { it.isNotBlank() }
val seriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 }
val sizeChanged = fileSize > 0L && fileSize != item.fileSize val sizeChanged = fileSize > 0L && fileSize != item.fileSize
val titleChanged = title != null && title != item.title val titleChanged = title != null && title != item.title
val authorChanged = author != null && author != item.author val authorChanged = author != null && author != item.author
val descriptionChanged = description != null && description != item.description
val seriesChanged = seriesName != null && seriesName != item.seriesName
val seriesIndexChanged = seriesIndex != null && seriesIndex != item.seriesIndex
val coverPath = if (needsEmbeddedCover) {
metadata.cover?.let { cover ->
recentFilesRepository.saveEmbeddedCoverToCache(cover.bytes, uri, cover.extension)
}
} else {
null
}
val coverChanged = coverPath != null && coverPath != item.coverImagePath
val coverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
val textMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata
if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) { if (needsTextMetadata || needsEmbeddedCover || sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
pendingUpdates.add( pendingUpdates.add(
item.copy( item.copy(
coverImagePath = coverPath ?: item.coverImagePath,
title = title ?: item.title ?: item.displayName, title = title ?: item.title ?: item.displayName,
author = author ?: item.author, author = author ?: item.author,
description = description ?: item.description,
seriesName = seriesName ?: item.seriesName,
seriesIndex = seriesIndex ?: item.seriesIndex,
fileSize = if (fileSize > 0L) fileSize else item.fileSize, fileSize = if (fileSize > 0L) fileSize else item.fileSize,
folderTextMetadataParsed = true folderTextMetadataParsed = textMetadataParsed,
folderCoverMetadataParsed = coverMetadataParsed
) )
) )
if (sizeChanged || titleChanged || authorChanged) { if (sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
updated++ updated++
} }
if (coverChanged) coversUpdated++
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) { if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates() flushUpdates()
} }
@ -114,29 +176,64 @@ class MetadataExtractionWorker(
processed++ processed++
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) { if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
ReaderPerfLog.d( ReaderPerfLog.d(
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed" "MetadataWorker progress mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed"
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
failed++ failed++
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}") Timber.tag("MetadataWorker").e(e, "Failed metadata extraction for ${item.displayName}")
if (needsTextMetadata || needsEmbeddedCover) {
pendingUpdates.add(
item.copy(
folderTextMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata,
folderCoverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
)
)
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
}
} }
} }
flushUpdates() flushUpdates()
val nextBatchEnqueued = !isStopped &&
filesToProcess.size >= METADATA_WORKER_BOOK_BATCH_SIZE &&
recentFilesRepository.hasFolderBooksNeedingTextMetadata(sourceFolderUri)
if (nextBatchEnqueued) {
enqueueNextBatch(sourceFolderUri)
}
ReaderPerfLog.i( ReaderPerfLog.i(
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " + "MetadataWorker finished mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed " +
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}" "nextBatch=$nextBatchEnqueued elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
) )
return@withContext Result.success() return@withContext Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed") Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
return@withContext Result.failure() return@withContext Result.failure()
} }
} }
private fun enqueueNextBatch(sourceFolderUri: String?) {
val data = androidx.work.Data.Builder().apply {
if (!sourceFolderUri.isNullOrBlank()) {
putString(KEY_SOURCE_FOLDER_URI, sourceFolderUri)
}
}.build()
val request = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
.setInputData(data)
.build()
WorkManager.getInstance(appContext).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE,
request
)
ReaderPerfLog.d("MetadataWorker enqueued next metadata batch folder=${sourceFolderUri ?: "ALL"}")
}
private fun queryFileSize(uri: android.net.Uri): Long { private fun queryFileSize(uri: android.net.Uri): Long {
return try { return try {
if (uri.scheme == "file") { if (uri.scheme == "file") {
@ -157,30 +254,6 @@ class MetadataExtractionWorker(
} }
} }
private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
val opfEntries = linkedMapOf<String, String>()
var containerXml: String? = null
appContext.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.isDirectory) continue
val name = entry.name
when {
name == "META-INF/container.xml" -> containerXml = zip.readTextEntry()
name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry()
}
zip.closeEntry()
}
}
}
val opfPath = containerXml?.let { parseEpubRootfilePath(it) }
val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull()
return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata()
}
private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata { private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata {
appContext.contentResolver.openInputStream(uri)?.use { input -> appContext.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input.buffered()).use { zip -> ZipInputStream(input.buffered()).use { zip ->
@ -222,21 +295,6 @@ class MetadataExtractionWorker(
} }
} }
private fun parseEpubRootfilePath(containerXml: String): String? {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
parser.setInput(containerXml.reader())
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) {
return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() }
}
event = parser.next()
}
return null
}
private fun parseXmlTextMetadata(xml: String): TextMetadata { private fun parseXmlTextMetadata(xml: String): TextMetadata {
val parser = Xml.newPullParser() val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
@ -286,8 +344,23 @@ class MetadataExtractionWorker(
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
} }
private fun EmbeddedEbookMetadata.toTextMetadata(): TextMetadata {
return TextMetadata(
title = title,
author = author,
description = description,
seriesName = seriesName,
seriesIndex = seriesIndex,
cover = cover
)
}
private data class TextMetadata( private data class TextMetadata(
val title: String? = null, val title: String? = null,
val author: String? = null val author: String? = null,
val description: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val cover: EmbeddedEbookCover? = null
) )
} }

View file

@ -1,54 +0,0 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
data class HomeScreenModel(
val recentFiles: List<RecentFileItem>,
val openTabs: List<RecentFileItem>,
val selectedItems: Set<RecentFileItem>,
val isContextualModeActive: Boolean,
val deviceLimitState: DeviceLimitReachedState,
val isEmpty: Boolean,
val isLibraryEmpty: Boolean
)
fun ReaderScreenState.toHomeScreenModel(): HomeScreenModel {
val homeRecentFiles = recentFiles
return HomeScreenModel(
recentFiles = homeRecentFiles,
openTabs = openTabs,
selectedItems = contextualActionItems,
isContextualModeActive = contextualActionItems.isNotEmpty(),
deviceLimitState = deviceLimitState,
isEmpty = homeRecentFiles.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()),
isLibraryEmpty = recentFiles.isEmpty()
)
}
data class LibraryScreenModel(
val selectedItems: Set<RecentFileItem>,
val isContextualModeActive: Boolean,
val selectedShelves: Set<String>,
val isShelfContextualModeActive: Boolean,
val sortOrder: SortOrder,
val shelves: List<Shelf>,
val rawLibraryFiles: List<RecentFileItem>,
val containsFolderItemsInSelection: Boolean,
val isSearchActive: Boolean,
val searchQuery: String
)
fun ReaderScreenState.toLibraryScreenModel(): LibraryScreenModel {
return LibraryScreenModel(
selectedItems = contextualActionItems,
isContextualModeActive = contextualActionItems.isNotEmpty(),
selectedShelves = contextualActionShelfIds,
isShelfContextualModeActive = contextualActionShelfIds.isNotEmpty(),
sortOrder = sortOrder,
shelves = shelves,
rawLibraryFiles = rawLibraryFiles,
containsFolderItemsInSelection = contextualActionItems.any { it.sourceFolderUri != null },
isSearchActive = isSearchActive,
searchQuery = searchQuery
)
}

View file

@ -274,7 +274,7 @@ private fun ProTierCard(
) { ) {
val productDetails = proUpgradeState.productDetails val productDetails = proUpgradeState.productDetails
val billingClientReady = proUpgradeState.billingClientReady val billingClientReady = proUpgradeState.billingClientReady
val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasValidPurchase val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasAccountConflict
var originalFormattedPrice by remember { mutableStateOf("$9.99") } var originalFormattedPrice by remember { mutableStateOf("$9.99") }
@ -467,23 +467,6 @@ private fun ProTierCard(
Text(stringResource(R.string.verifying_purchase)) Text(stringResource(R.string.verifying_purchase))
} }
} }
localPurchaseExistsForOtherAccount -> {
OutlinedButton(
onClick = onShowExistingPurchaseDialog,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = stringResource(R.string.info),
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
AutoSizeText(stringResource(R.string.existing_purchase_found))
}
}
productDetails != null -> { productDetails != null -> {
Button( Button(
onClick = { onClick = {
@ -538,6 +521,17 @@ private fun ProTierCard(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
localPurchaseExistsForOtherAccount -> {
TextButton(onClick = onShowExistingPurchaseDialog) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = stringResource(R.string.info),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
AutoSizeText(stringResource(R.string.existing_purchase_found))
}
}
else -> { else -> {
LegalText(prefixText = stringResource(R.string.legal_by_purchasing)) LegalText(prefixText = stringResource(R.string.legal_by_purchasing))
} }

View file

@ -0,0 +1,22 @@
package com.aryan.reader
import java.security.MessageDigest
import java.util.Base64
object PurchaseAccountObfuscator {
fun obfuscatedAccountId(uid: String): String {
return "firebase_${sha256Base64Url(uid)}"
}
fun purchaseTokenHash(purchaseToken: String): String {
return "sha256_${sha256Base64Url(purchaseToken)}"
}
private fun sha256Base64Url(value: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(digest)
}
}

View file

@ -0,0 +1,28 @@
package com.aryan.reader
import android.content.Context
import androidx.core.content.edit
private const val READER_PAGINATION_PREFS_NAME = "reader_prefs"
private const val PDF_RIGHT_TO_LEFT_PAGINATION_KEY = "pdf_right_to_left_pagination_enabled"
private const val EPUB_RIGHT_TO_LEFT_PAGINATION_KEY = "epub_right_to_left_pagination_enabled"
fun savePdfRightToLeftPagination(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_RIGHT_TO_LEFT_PAGINATION_KEY, enabled) }
}
fun loadPdfRightToLeftPagination(context: Context): Boolean {
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_RIGHT_TO_LEFT_PAGINATION_KEY, false)
}
fun saveEpubRightToLeftPagination(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(EPUB_RIGHT_TO_LEFT_PAGINATION_KEY, enabled) }
}
fun loadEpubRightToLeftPagination(context: Context): Boolean {
val prefs = context.getSharedPreferences(READER_PAGINATION_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(EPUB_RIGHT_TO_LEFT_PAGINATION_KEY, false)
}

View file

@ -0,0 +1,183 @@
package com.aryan.reader
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
private const val READER_SCREEN_ORIENTATION_PREFS_NAME = "epub_reader_settings"
private const val READER_SCREEN_ORIENTATION_KEY = "reader_screen_orientation_mode"
enum class ReaderScreenOrientationMode(
val id: Int,
val title: String
) {
FOLLOW_SYSTEM(0, "Follow system"),
PORTRAIT(1, "Portrait"),
LANDSCAPE(2, "Landscape")
}
fun saveReaderScreenOrientationMode(context: Context, mode: ReaderScreenOrientationMode) {
val prefs = context.getSharedPreferences(READER_SCREEN_ORIENTATION_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putInt(READER_SCREEN_ORIENTATION_KEY, mode.id) }
}
fun loadReaderScreenOrientationMode(context: Context): ReaderScreenOrientationMode {
val prefs = context.getSharedPreferences(READER_SCREEN_ORIENTATION_PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getInt(READER_SCREEN_ORIENTATION_KEY, ReaderScreenOrientationMode.FOLLOW_SYSTEM.id)
return ReaderScreenOrientationMode.entries.find { it.id == id } ?: ReaderScreenOrientationMode.FOLLOW_SYSTEM
}
fun ReaderScreenOrientationMode.toRequestedOrientation(): Int {
return when (this) {
ReaderScreenOrientationMode.FOLLOW_SYSTEM -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
ReaderScreenOrientationMode.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
ReaderScreenOrientationMode.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
}
}
@Composable
fun ReaderScreenOrientationEffect(mode: ReaderScreenOrientationMode) {
val context = LocalContext.current
val activity: Activity? = remember(context) { context.findReaderOrientationActivity() }
DisposableEffect(activity, mode) {
if (activity != null) {
val originalOrientation = activity.requestedOrientation
activity.requestedOrientation = mode.toRequestedOrientation()
onDispose {
activity.requestedOrientation = originalOrientation
}
} else {
onDispose {}
}
}
}
@Composable
fun ReaderScreenOrientationPicker(
selectedMode: ReaderScreenOrientationMode,
onModeSelected: (ReaderScreenOrientationMode) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ReaderScreenOrientationMode.entries.forEach { mode ->
val selected = mode == selectedMode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onModeSelected(mode) }
.padding(vertical = 10.dp, horizontal = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = mode.title,
style = MaterialTheme.typography.labelSmall,
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderScreenOrientationSheet(
selectedMode: ReaderScreenOrientationMode,
onModeSelected: (ReaderScreenOrientationMode) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.padding(bottom = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.visual_options_screen_orientation),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.visual_options_screen_orientation_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
ReaderScreenOrientationPicker(
selectedMode = selectedMode,
onModeSelected = onModeSelected
)
}
}
}
private tailrec fun Context.findReaderOrientationActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findReaderOrientationActivity()
else -> null
}
}

View file

@ -0,0 +1,583 @@
package com.aryan.reader
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import android.content.Context
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings
import com.aryan.reader.epubreader.PageInfoMode as AndroidPageInfoMode
import com.aryan.reader.epubreader.PageInfoPosition as AndroidPageInfoPosition
import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont
import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
import com.aryan.reader.epubreader.SystemUiMode as AndroidSystemUiMode
import com.aryan.reader.epubreader.loadFormatSettings
import com.aryan.reader.epubreader.loadPageInfoMode
import com.aryan.reader.epubreader.loadPageInfoPosition
import com.aryan.reader.epubreader.loadPullToTurn
import com.aryan.reader.epubreader.loadPullToTurnMultiplier
import com.aryan.reader.epubreader.loadSystemUiMode
import com.aryan.reader.epubreader.savePageInfoMode
import com.aryan.reader.epubreader.savePageInfoPosition
import com.aryan.reader.epubreader.savePullToTurn
import com.aryan.reader.epubreader.savePullToTurnMultiplier
import com.aryan.reader.epubreader.saveReaderSettings
import com.aryan.reader.epubreader.saveSystemUiMode
import com.aryan.reader.pdf.savePdfSystemUiMode
import com.aryan.reader.pdf.savePdfThemeId
import com.aryan.reader.pdf.savePdfVerticalPageGapVisible
import com.aryan.reader.pdf.savePdfPageNumberOverlayVisible
import com.aryan.reader.pdf.loadPdfSystemUiMode
import com.aryan.reader.pdf.loadPdfThemeId
import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible
import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible
import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.PageInfoMode as SharedPageInfoMode
import com.aryan.reader.shared.PageInfoPosition as SharedPageInfoPosition
import com.aryan.reader.shared.SharedSettingsAction
import com.aryan.reader.shared.SharedSettingsDestination
import com.aryan.reader.shared.SystemUiMode as SharedSystemUiMode
import com.aryan.reader.shared.parentDestination
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedReaderTextAlign
import com.aryan.reader.shared.readerThemeById
import com.aryan.reader.shared.sharedSettingsHubModel
import com.aryan.reader.shared.toReaderSettings
import com.aryan.reader.shared.ui.SharedSettingsHub
import com.aryan.reader.tts.loadTtsMode
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.roundToInt
private const val ANDROID_SETTINGS_GLOBAL_BOOK_ID = "__global_reader_defaults__"
@androidx.annotation.OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: MainViewModel,
navController: NavHostController,
onBackClick: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
val ttsState by viewModel.ttsController.ttsState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) }
var showAppThemePanel by remember { mutableStateOf(false) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
var showClearAllDataDialog by remember { mutableStateOf(false) }
var showLanguageDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) }
var showSignOutConfirmDialog by remember { mutableStateOf(false) }
var showUpgradeDialog by remember { mutableStateOf(false) }
var showRecentLimitDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var hideReaderAi by remember { mutableStateOf(loadHideReaderAiFeatures(context)) }
var epubReaderDefaults by remember(context, uiState.renderMode) {
mutableStateOf(loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode))
}
var pdfReaderDefaults by remember(context) {
mutableStateOf(loadAndroidPdfReaderDefaultSettings(context))
}
var ttsReplacementPreferences by remember(context) {
mutableStateOf(loadTtsReplacementPreferences(context))
}
var ttsMode by remember(context) { mutableStateOf(loadTtsMode(context)) }
LaunchedEffect(uiState.renderMode) {
epubReaderDefaults = loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode)
}
val sharedFonts = remember(customFonts) {
customFonts.toSharedCustomFontItems()
}
val settingsModel = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = uiState,
hideReaderAi = hideReaderAi
)
)
val settingsPage = settingsModel.page(settingsDestination)
fun navigateBackFromSettings() {
if (query.isNotBlank()) {
query = ""
return
}
val parent = settingsDestination.parentDestination()
if (parent != null) {
settingsDestination = parent
} else {
onBackClick()
}
}
BackHandler(enabled = query.isNotBlank() || settingsDestination != SharedSettingsDestination.ROOT) {
navigateBackFromSettings()
}
Scaffold(
modifier = modifier,
topBar = {
CustomTopAppBar(
title = { Text(settingsPage.title) },
navigationIcon = {
IconButton(onClick = ::navigateBackFromSettings) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
contentWindowInsets = WindowInsets.navigationBars
) { padding ->
SharedSettingsHub(
model = settingsModel,
query = query,
onQueryChange = { query = it },
readerDefaultSettings = epubReaderDefaults,
onReaderDefaultSettingsChange = { settings ->
epubReaderDefaults = settings
saveAndroidEpubReaderDefaultSettings(context, settings)
viewModel.setRenderMode(settings.toAndroidRenderMode())
},
pdfReaderDefaultSettings = pdfReaderDefaults,
onPdfReaderDefaultSettingsChange = { settings ->
pdfReaderDefaults = settings
saveAndroidPdfReaderDefaultSettings(context, settings)
},
ttsReplacementPreferences = ttsReplacementPreferences,
onTtsReplacementPreferencesChange = { preferences ->
ttsReplacementPreferences = preferences
saveTtsReplacementPreferences(context, preferences)
},
customFonts = sharedFonts,
showTopBar = false,
destination = settingsDestination,
onDestinationChange = { settingsDestination = it },
contentPadding = padding,
modifier = Modifier.fillMaxSize(),
onAction = { action ->
when (action) {
SharedSettingsAction.APP_THEME -> showAppThemePanel = true
SharedSettingsAction.LANGUAGE -> showLanguageDialog = true
SharedSettingsAction.TABS_TOGGLE -> viewModel.setTabsEnabled(!uiState.isTabsEnabled)
SharedSettingsAction.RECENT_LIMIT -> showRecentLimitDialog = true
SharedSettingsAction.STRICT_FILE_FILTER -> {
if (uiState.useStrictFileFilter) {
viewModel.setStrictFileFilter(false)
} else {
showStrictFilterDialog = true
}
}
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> {
val next = !uiState.isScreenCaptureProtectionEnabled
viewModel.setScreenCaptureProtectionEnabled(next)
val messageRes = if (next) {
R.string.banner_screen_capture_protection_on
} else {
R.string.banner_screen_capture_protection_off
}
viewModel.showBanner(context.getString(messageRes))
}
SharedSettingsAction.CUSTOM_FONTS -> navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
SharedSettingsAction.SIGN_IN -> {
scope.launch {
context.findActivity()?.let { activity -> viewModel.signIn(activity) }
}
}
SharedSettingsAction.SIGN_OUT -> showSignOutConfirmDialog = true
SharedSettingsAction.CLOUD_SYNC -> {
if (uiState.isProUser) {
viewModel.setSyncEnabled(!uiState.isSyncEnabled)
} else {
showUpgradeDialog = true
}
}
SharedSettingsAction.FOLDER_SYNC -> viewModel.setFolderSyncEnabled(!uiState.isFolderSyncEnabled)
SharedSettingsAction.DEVICE_MANAGEMENT -> viewModel.showDeviceManagementForDebug()
SharedSettingsAction.AI_SETTINGS -> navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
SharedSettingsAction.HIDE_READER_AI -> {
val nextHidden = !hideReaderAi
saveHideReaderAiFeatures(context, nextHidden)
hideReaderAi = nextHidden
}
SharedSettingsAction.TTS_SETTINGS -> showTtsSettingsSheet = true
SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true
SharedSettingsAction.CLEAR_REFLOW_CACHE -> showClearReflowCacheDialog = true
SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA -> showClearAllDataDialog = true
SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context)
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context)
SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context)
SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner("Debug actions remain in their existing menus.")
SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE)
SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE)
SharedSettingsAction.ABOUT -> showAboutDialog = true
SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner("PDF-specific OCR, annotation, and tool settings remain in the PDF reader.")
SharedSettingsAction.TEXT_READER_DEFAULTS,
SharedSettingsAction.READER_TOOLBAR,
SharedSettingsAction.TTS_REPLACEMENTS,
SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit
}
}
)
}
if (showRecentLimitDialog) {
RecentLimitDialog(
currentLimit = uiState.recentFilesLimit,
onSelect = { limit ->
viewModel.setRecentFilesLimit(limit)
showRecentLimitDialog = false
},
onDismiss = { showRecentLimitDialog = false }
)
}
if (showBehaviorDialog) {
ExternalFileBehaviorDialog(
currentBehavior = uiState.externalFileBehavior,
onDismiss = { showBehaviorDialog = false },
onSelect = { viewModel.setExternalFileBehavior(it) }
)
}
if (showStrictFilterDialog) {
StrictFilterConfirmationDialog(
onConfirm = {
viewModel.setStrictFileFilter(true)
showStrictFilterDialog = false
},
onDismiss = { showStrictFilterDialog = false }
)
}
if (showClearBookCacheDialog) {
DangerousFolderActionDialog(
title = context.getString(R.string.dialog_clear_book_cache),
message = context.getString(R.string.dialog_clear_book_cache_desc),
onConfirm = {
viewModel.clearBookCache()
showClearBookCacheDialog = false
},
onDismiss = { showClearBookCacheDialog = false }
)
}
if (showClearReflowCacheDialog) {
DangerousFolderActionDialog(
title = context.getString(R.string.dialog_clear_reflow_cache),
message = context.getString(R.string.dialog_clear_reflow_cache_desc),
onConfirm = {
viewModel.clearReflowCache()
showClearReflowCacheDialog = false
},
onDismiss = { showClearReflowCacheDialog = false }
)
}
if (showClearAllDataDialog) {
ClearAllDataConfirmationDialog(
onConfirm = {
viewModel.deleteAllCloudAndLocalData()
showClearAllDataDialog = false
},
onDismiss = { showClearAllDataDialog = false }
)
}
if (showLanguageDialog) {
LanguageSelectionDialog(onDismiss = { showLanguageDialog = false })
}
if (showAppThemePanel) {
AppThemeBottomSheet(
uiState = uiState,
onThemeModeChanged = viewModel::setAppThemeMode,
onContrastOptionChanged = viewModel::setAppContrastOption,
onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight,
onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark,
onSeedColorChanged = viewModel::setAppSeedColor,
onCustomThemeAdded = viewModel::addCustomAppTheme,
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
onDismiss = { showAppThemePanel = false }
)
}
if (showAboutDialog) {
AboutDialog(onDismiss = { showAboutDialog = false })
}
if (showSignOutConfirmDialog) {
SignOutConfirmationDialog(
onConfirm = {
viewModel.signOut()
showSignOutConfirmDialog = false
},
onDismiss = { showSignOutConfirmDialog = false }
)
}
if (showUpgradeDialog) {
UpgradeDialog(
onConfirm = {
showUpgradeDialog = false
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
},
onDismiss = { showUpgradeDialog = false }
)
}
if (showTtsSettingsSheet) {
TtsSettingsSheet(
isVisible = true,
onDismiss = { showTtsSettingsSheet = false },
currentMode = ttsMode,
onModeChange = { mode ->
ttsMode = mode
viewModel.ttsController.changeTtsMode(mode.name)
},
currentSpeakerId = ttsState.speakerId,
onSpeakerChange = viewModel.ttsController::changeSpeaker,
isTtsActive = ttsState.isPlaying,
getAuthToken = { viewModel.getAuthToken() },
bookTitle = "Reader defaults"
)
}
if (uiState.deviceLimitState.isLimitReached) {
DeviceManagementScreen(
devices = uiState.deviceLimitState.registeredDevices,
onRemoveDevice = { deviceId -> viewModel.replaceDevice(deviceId) },
isReplacing = uiState.isReplacingDevice
)
}
}
@Composable
private fun RecentLimitDialog(
currentLimit: Int,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Recent files limit") },
text = {
androidx.compose.foundation.layout.Column {
listOf(0, 10, 20, 50, 100).forEach { limit ->
TextButton(onClick = { onSelect(limit) }) {
val label = if (limit == 0) "No limit" else "$limit files"
Text(if (currentLimit == limit) "$label selected" else label)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
private fun loadAndroidEpubReaderDefaultSettings(
context: Context,
renderMode: RenderMode
): ReaderSettings {
val format = loadFormatSettings(context, ANDROID_SETTINGS_GLOBAL_BOOK_ID, isLocal = false)
val horizontalMargin = (48f * format.horizontalMargin).roundToInt().coerceIn(0, 160)
val verticalMargin = (48f * format.verticalMargin).roundToInt().coerceIn(0, 160)
val base = ReaderSettings(
fontSize = (18f * format.fontSize).roundToInt().coerceIn(12, 42),
lineSpacing = (1.45f * format.lineHeight).coerceIn(1.0f, 2.8f),
margin = max(horizontalMargin, verticalMargin),
readingMode = renderMode.toSharedReaderReadingMode(),
textAlign = format.textAlign.toSharedReaderTextAlign(),
fontFamily = format.toSharedFontFamilyName(),
paragraphSpacing = format.paragraphGap.coerceIn(0.5f, 2.5f),
imageScale = format.imageSize.coerceIn(0.5f, 2.0f),
horizontalMargin = horizontalMargin,
verticalMargin = verticalMargin,
themeId = loadReaderThemeId(context),
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
customFontPath = format.customPath?.takeIf { it.isNotBlank() },
systemUiMode = loadSystemUiMode(context).toSharedSystemUiMode(),
pageInfoMode = loadPageInfoMode(context).toSharedPageInfoMode(),
pageInfoPosition = loadPageInfoPosition(context).toSharedPageInfoPosition(),
seamlessChapterNavigation = loadPullToTurn(context),
chapterTurnDragMultiplier = loadPullToTurnMultiplier(context)
)
return readerThemeById(base.themeId)?.toReaderSettings(base) ?: base
}
private fun loadAndroidPdfReaderDefaultSettings(
context: Context
): ReaderSettings {
val base = ReaderSettings(
themeId = loadPdfThemeId(context),
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
systemUiMode = loadPdfSystemUiMode(context).toSharedSystemUiMode(),
pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context),
pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context)
)
return BuiltInPdfReaderThemes.firstOrNull { it.id == base.themeId }?.toReaderSettings(base) ?: base
}
private fun saveAndroidEpubReaderDefaultSettings(
context: Context,
settings: ReaderSettings
) {
saveReaderSettings(
context = context,
fontSize = (settings.fontSize / 18f).coerceIn(0.65f, 2.4f),
lineHeight = (settings.lineSpacing / 1.45f).coerceIn(0.7f, 2.0f),
paragraphGap = settings.paragraphSpacing.coerceIn(0.5f, 2.5f),
imageSize = settings.imageScale.coerceIn(0.5f, 2.0f),
horizontalMargin = (settings.resolvedHorizontalMargin / 48f).coerceIn(0f, 3.4f),
verticalMargin = (settings.resolvedVerticalMargin / 48f).coerceIn(0f, 3.4f),
fontFamily = settings.toAndroidReaderFont(),
customFontPath = settings.customFontPath,
textAlign = settings.textAlign.toAndroidTextAlign()
)
saveSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode())
savePageInfoMode(context, settings.pageInfoMode.toAndroidPageInfoMode())
savePageInfoPosition(context, settings.pageInfoPosition.toAndroidPageInfoPosition())
savePullToTurn(context, settings.seamlessChapterNavigation)
savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier)
saveReaderThemeId(context, settings.themeId ?: "system")
saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f))
}
private fun saveAndroidPdfReaderDefaultSettings(
context: Context,
settings: ReaderSettings
) {
savePdfSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode())
savePdfThemeId(context, settings.themeId ?: "no_theme")
savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible)
savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible)
saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f))
}
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
.map { font ->
CustomFontItem(
id = font.id,
displayName = font.displayName,
fileName = font.fileName,
fileExtension = font.fileExtension,
path = font.path,
timestamp = font.timestamp,
isDeleted = font.isDeleted
)
}
}
private fun AndroidFormatSettings.toSharedFontFamilyName(): String {
return customPath?.substringAfterLast('/')?.substringAfterLast('\\')?.takeIf { it.isNotBlank() }
?: when (font) {
AndroidReaderFont.ORIGINAL -> "Default"
AndroidReaderFont.MERRIWEATHER,
AndroidReaderFont.LORA -> "Serif"
AndroidReaderFont.LATO,
AndroidReaderFont.LEXEND -> "Sans"
AndroidReaderFont.ROBOTO_MONO -> "Mono"
}
}
private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
return when (this) {
AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
AndroidReaderTextAlign.DEFAULT,
AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START
}
}
private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont {
return when (fontFamily) {
"Serif" -> AndroidReaderFont.LORA
"Sans" -> AndroidReaderFont.LATO
"Mono" -> AndroidReaderFont.ROBOTO_MONO
else -> AndroidReaderFont.ORIGINAL
}
}
private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign {
return when (this) {
SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY
SharedReaderTextAlign.CENTER,
SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT
}
}
private fun RenderMode.toSharedReaderReadingMode(): ReaderReadingMode {
return when (this) {
RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED
RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
}
}
private fun ReaderSettings.toAndroidRenderMode(): RenderMode {
return when (readingMode) {
ReaderReadingMode.PAGINATED -> RenderMode.PAGINATED
ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL
}
}
private fun AndroidSystemUiMode.toSharedSystemUiMode(): SharedSystemUiMode {
return SharedSystemUiMode.valueOf(name)
}
private fun SharedSystemUiMode.toAndroidSystemUiMode(): AndroidSystemUiMode {
return AndroidSystemUiMode.valueOf(name)
}
private fun AndroidPageInfoMode.toSharedPageInfoMode(): SharedPageInfoMode {
return SharedPageInfoMode.valueOf(name)
}
private fun SharedPageInfoMode.toAndroidPageInfoMode(): AndroidPageInfoMode {
return AndroidPageInfoMode.valueOf(name)
}
private fun AndroidPageInfoPosition.toSharedPageInfoPosition(): SharedPageInfoPosition {
return SharedPageInfoPosition.valueOf(name)
}
private fun SharedPageInfoPosition.toAndroidPageInfoPosition(): AndroidPageInfoPosition {
return AndroidPageInfoPosition.valueOf(name)
}

View file

@ -23,6 +23,9 @@ package com.aryan.reader
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -57,6 +60,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
@ -69,17 +74,24 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.FileOpen import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy import androidx.compose.material.icons.outlined.Policy
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@ -88,6 +100,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ProvideTextStyle
@ -101,7 +114,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@ -117,12 +132,19 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.ui.SharedMarkdownText
import timber.log.Timber import timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -359,141 +381,41 @@ fun DeleteConfirmationDialog(
} }
@Composable @Composable
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit, onOpenTags: () -> Unit) { fun FileInfoDialog(
LocalContext.current item: RecentFileItem,
onDismiss: () -> Unit,
onSaveMetadata: (BookMetadataEdit) -> Unit,
onSaveDisplayName: (String?) -> Unit,
onRestoreMetadata: () -> Unit,
onOpenTags: () -> Unit
) {
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val originalName = item.title ?: item.displayName var isEditing by remember(item.bookId) { mutableStateOf(false) }
var editingName by remember { mutableStateOf(item.customName ?: originalName) } var titleInput by remember(item.bookId, item.title) { mutableStateOf(item.title.orEmpty()) }
val hasCustomName = item.customName != null var authorInput by remember(item.bookId, item.author) { mutableStateOf(item.author.orEmpty()) }
var seriesInput by remember(item.bookId, item.seriesName) { mutableStateOf(item.seriesName.orEmpty()) }
var seriesIndexInput by remember(item.bookId, item.seriesIndex) {
mutableStateOf(item.seriesIndex?.formatMetadataNumber().orEmpty())
}
var descriptionInput by remember(item.bookId, item.description) { mutableStateOf(item.description.orEmpty()) }
var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName) {
mutableStateOf(item.customName ?: item.cardTitle())
}
var showRestoreConfirmation by remember(item.bookId) { mutableStateOf(false) }
val formattedDate = remember(item.timestamp) { val formattedDate = remember(item.timestamp) {
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp)) SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
} }
val lastModifiedDate = remember(item.lastModifiedTimestamp) {
val context = LocalContext.current item.lastModifiedTimestamp
.takeIf { it > 0L }
?.let { SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(it)) }
}
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) { val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) {
if (isOpdsStream) { item.resolveDisplayPath(context, isOpdsStream)
"Source: OPDS Stream"
} else if (item.sourceFolderUri != null && item.uriString != null) {
try {
val uri = item.uriString.toUri()
val docId = if (android.provider.DocumentsContract.isDocumentUri(context, uri)) {
android.provider.DocumentsContract.getDocumentId(uri)
} else if (android.provider.DocumentsContract.isTreeUri(uri)) {
android.provider.DocumentsContract.getTreeDocumentId(uri)
} else {
Uri.decode(uri.toString())
} }
val split = docId.split(":")
val storageName = if (split[0].equals("primary", ignoreCase = true)) "Internal storage" else split[0]
var relativePath = if (split.size > 1) {
Uri.decode(split[1]).removeSuffix("/")
} else ""
if (!relativePath.endsWith(item.displayName)) {
relativePath = if (relativePath.isEmpty()) item.displayName else "$relativePath/${item.displayName}"
}
val leadingSlash = if (relativePath.isNotEmpty() && !relativePath.startsWith("/")) "/" else ""
"/$storageName$leadingSlash$relativePath"
} catch (_: Exception) {
val decoded = Uri.decode(item.uriString)
if (decoded.contains("primary:")) {
"/Internal storage/${decoded.substringAfter("primary:").substringBeforeLast("/")}/${item.displayName}"
} else {
item.displayName
}
}
} else {
"In-App Storage"
}
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
stringResource(R.string.file_information),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
androidx.compose.material3.OutlinedTextField(
value = editingName,
onValueChange = { editingName = it },
label = { Text(stringResource(R.string.book_name)) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp, max = 130.dp),
maxLines = 4,
textStyle = MaterialTheme.typography.bodyLarge,
trailingIcon = {
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(editingName))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_name), modifier = Modifier.size(20.dp))
}
}
)
if (hasCustomName) {
Text(
text = stringResource(R.string.original_name, originalName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
TextButton(
onClick = {
editingName = originalName
onUpdateName(null)
},
modifier = Modifier.align(Alignment.End),
contentPadding = PaddingValues(0.dp)
) {
Text(stringResource(R.string.revert_to_original))
}
} else if (originalName != item.displayName) {
Text(
text = stringResource(R.string.file_name, item.displayName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
InfoRowDetailed(stringResource(R.string.author), it)
}
item.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
val seriesText = if (item.seriesIndex != null && item.seriesIndex > 0) {
"$series #${item.seriesIndex.toInt()}"
} else {
series
}
InfoRowDetailed("Series", seriesText)
}
InfoRowDetailed(stringResource(R.string.format), item.type.name)
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
InfoRowDetailed(stringResource(R.string.added), formattedDate)
val pathTextFinal = if (isOpdsStream) { val pathTextFinal = if (isOpdsStream) {
stringResource(R.string.source_opds) stringResource(R.string.source_opds)
} else if (pathText == "In-App Storage") { } else if (pathText == "In-App Storage") {
@ -501,52 +423,451 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
} else { } else {
pathText.replace("Internal storage", stringResource(R.string.internal_storage)) pathText.replace("Internal storage", stringResource(R.string.internal_storage))
} }
val hasOriginalMetadata = item.hasOriginalMetadata()
val hasMetadataChanges = item.hasMetadataChanges()
val canEditEmbeddedMetadata = item.type == FileType.EPUB && !isOpdsStream && item.uriString != null
val canRenameDisplayName = !canEditEmbeddedMetadata
InfoRowDetailed( Dialog(
label = stringResource(R.string.location), onDismissRequest = {
value = pathTextFinal, if (isEditing) {
maxLines = 4, isEditing = false
isScrollable = true, } else {
onCopy = { onDismiss()
clipboardManager.setText(AnnotatedString(pathTextFinal)) }
},
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.imePadding()
) {
FileInfoTopBar(
title = if (isEditing) {
if (canEditEmbeddedMetadata) "Edit EPUB metadata" else "Rename in app"
} else {
stringResource(R.string.file_information)
},
subtitle = item.cardTitle(),
onClose = {
if (isEditing) {
isEditing = false
} else {
onDismiss()
}
}
)
HorizontalDivider()
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (isEditing) {
if (canEditEmbeddedMetadata) {
BookMetadataEditContent(
titleInput = titleInput,
onTitleChange = { titleInput = it },
authorInput = authorInput,
onAuthorChange = { authorInput = it },
seriesInput = seriesInput,
onSeriesChange = { seriesInput = it },
seriesIndexInput = seriesIndexInput,
onSeriesIndexChange = { seriesIndexInput = it },
descriptionInput = descriptionInput,
onDescriptionChange = { descriptionInput = it }
)
} else if (canRenameDisplayName) {
BookDisplayNameEditContent(
displayNameInput = displayNameInput,
onDisplayNameChange = { displayNameInput = it },
originalFileName = item.displayName
)
}
} else {
BookMetadataInfoContent(
item = item,
formattedDate = formattedDate,
lastModifiedDate = lastModifiedDate,
pathText = pathTextFinal,
hasMetadataChanges = hasMetadataChanges,
onCopy = { value -> clipboardManager.setText(AnnotatedString(value)) },
onOpenTags = onOpenTags
)
}
}
HorizontalDivider()
FileInfoBottomBar(
isEditing = isEditing,
canRestore = canEditEmbeddedMetadata && hasOriginalMetadata && (hasMetadataChanges || isEditing),
editLabel = if (canEditEmbeddedMetadata) "Edit metadata" else "Rename",
onCancel = {
if (isEditing) {
isEditing = false
} else {
onDismiss()
}
},
onRestore = {
showRestoreConfirmation = true
},
onSave = {
if (canEditEmbeddedMetadata) {
onSaveMetadata(
BookMetadataEdit(
title = titleInput.toMetadataValue() ?: item.displayName.substringBeforeLast('.', item.displayName),
author = authorInput.toMetadataValue(),
seriesName = seriesInput.toMetadataValue(),
seriesIndex = seriesIndexInput.toSeriesIndexOrNull(),
description = descriptionInput.toMetadataValue()
)
)
} else if (canRenameDisplayName) {
onSaveDisplayName(displayNameInput.toMetadataValue())
}
onDismiss()
},
onEdit = { isEditing = true }
)
}
}
}
if (showRestoreConfirmation) {
AlertDialog(
onDismissRequest = { showRestoreConfirmation = false },
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
title = { Text("Restore original metadata?") },
text = {
Text(
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
)
},
confirmButton = {
Button(
onClick = {
showRestoreConfirmation = false
onRestoreMetadata()
onDismiss()
}
) {
Text("Restore")
}
},
dismissButton = {
TextButton(onClick = { showRestoreConfirmation = false }) {
Text(stringResource(R.string.action_cancel))
}
} }
) )
} }
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) @Composable
private fun FileInfoTopBar(
title: String,
subtitle: String,
onClose: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { @Composable
Text(stringResource(R.string.section_tags), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) private fun BookMetadataInfoContent(
item: RecentFileItem,
formattedDate: String,
lastModifiedDate: String?,
pathText: String,
hasMetadataChanges: Boolean,
onCopy: (String) -> Unit,
onOpenTags: () -> Unit
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
item.cardTitle(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
item.author
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
val provenance = when {
item.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited"
item.type == FileType.EPUB -> "Metadata from EPUB file"
!item.customName.isNullOrBlank() -> "Display name changed in app"
else -> "Metadata from file"
}
Text(
provenance,
style = MaterialTheme.typography.labelMedium,
color = if (hasMetadataChanges) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
FileInfoSection(title = "Metadata") {
InfoRowDetailed("Title", item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3)
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
InfoRowDetailed(stringResource(R.string.author), it, maxLines = 2)
}
item.seriesLabel()?.let {
InfoRowDetailed("Series", it, maxLines = 2)
}
InfoRowDetailed(stringResource(R.string.format), item.type.name)
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
InfoRowDetailed("Reading", item.readingProgressText(), maxLines = 2)
}
FileInfoSection(title = "File") {
InfoRowDetailed("File name", item.displayName, maxLines = 2)
InfoRowDetailed(stringResource(R.string.added), formattedDate)
lastModifiedDate?.let { InfoRowDetailed("Modified", it) }
InfoRowDetailed(
label = stringResource(R.string.location),
value = pathText,
maxLines = 4,
onCopy = { onCopy(pathText) }
)
}
item.description?.takeIf { it.isNotBlank() }?.let { summary ->
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Summary", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
ExpandableSummaryText(summary, collapsedMaxLines = 4)
}
}
}
FileInfoSection(title = stringResource(R.string.section_tags)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Library tags", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) } TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) }
} }
if (item.tags.isNotEmpty()) { if (item.tags.isNotEmpty()) {
BookTagChipsRow(tags = item.tags, compact = false) BookTagChipsRow(tags = item.tags, compact = false)
} else { } else {
Text(stringResource(R.string.msg_no_tags_assigned), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
stringResource(R.string.msg_no_tags_assigned),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
}
}
@Composable
private fun BookMetadataEditContent(
titleInput: String,
onTitleChange: (String) -> Unit,
authorInput: String,
onAuthorChange: (String) -> Unit,
seriesInput: String,
onSeriesChange: (String) -> Unit,
seriesIndexInput: String,
onSeriesIndexChange: (String) -> Unit,
descriptionInput: String,
onDescriptionChange: (String) -> Unit
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Editable metadata", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = titleInput,
onValueChange = onTitleChange,
label = { Text("Title") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
OutlinedTextField(
value = authorInput,
onValueChange = onAuthorChange,
label = { Text(stringResource(R.string.author)) },
modifier = Modifier.fillMaxWidth(),
maxLines = 2
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = seriesInput,
onValueChange = onSeriesChange,
label = { Text("Series") },
modifier = Modifier.weight(1f),
maxLines = 2
)
OutlinedTextField(
value = seriesIndexInput,
onValueChange = onSeriesIndexChange,
label = { Text("#") },
modifier = Modifier.width(96.dp),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
)
}
OutlinedTextField(
value = descriptionInput,
onValueChange = onDescriptionChange,
label = { Text("Summary") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp),
minLines = 4,
maxLines = 10
)
}
}
}
@Composable
private fun BookDisplayNameEditContent(
displayNameInput: String,
onDisplayNameChange: (String) -> Unit,
originalFileName: String
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Display name", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = displayNameInput,
onValueChange = onDisplayNameChange,
label = { Text("Name shown in Reader") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
Text(
"Original file: $originalFileName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun FileInfoBottomBar(
isEditing: Boolean,
canRestore: Boolean,
editLabel: String,
onCancel: () -> Unit,
onRestore: () -> Unit,
onSave: () -> Unit,
onEdit: () -> Unit
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 8.dp), .horizontalScroll(rememberScrollState())
horizontalArrangement = Arrangement.End .padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } if (canRestore) {
OutlinedButton(
onClick = onRestore,
modifier = Modifier.padding(end = 8.dp)
) {
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = { Text("Restore")
val finalName = editingName.trim() }
if (finalName != (item.customName ?: originalName)) { }
if (finalName == originalName || finalName.isEmpty()) { TextButton(onClick = onCancel) {
onUpdateName(null) Text(if (isEditing) stringResource(R.string.action_cancel) else stringResource(R.string.action_close))
}
Spacer(modifier = Modifier.width(8.dp))
if (isEditing) {
Button(onClick = onSave) {
Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.action_save))
}
} else { } else {
onUpdateName(finalName) Button(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(editLabel)
} }
} }
onDismiss()
}) { Text(stringResource(R.string.action_save)) }
}
} }
}
@Composable
private fun FileInfoSection(
title: String,
content: @Composable () -> Unit
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
content()
} }
} }
} }
@ -556,7 +877,6 @@ private fun InfoRowDetailed(
label: String, label: String,
value: String, value: String,
maxLines: Int = 1, maxLines: Int = 1,
isScrollable: Boolean = false,
onCopy: (() -> Unit)? = null onCopy: (() -> Unit)? = null
) { ) {
Row( Row(
@ -569,32 +889,17 @@ private fun InfoRowDetailed(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
.width(85.dp) .width(104.dp)
.padding(top = 2.dp) .padding(top = 2.dp)
) )
Column(modifier = Modifier.weight(1f)) {
val scrollModifier = if (isScrollable) { ExpandableValueText(value, collapsedMaxLines = maxLines)
Modifier }
.heightIn(max = 66.dp)
.verticalScroll(rememberScrollState())
} else Modifier
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (isScrollable) Int.MAX_VALUE else maxLines,
overflow = if (isScrollable) TextOverflow.Clip else TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(top = 2.dp)
.then(scrollModifier)
)
if (onCopy != null) { if (onCopy != null) {
IconButton( IconButton(
onClick = onCopy, onClick = onCopy,
modifier = Modifier modifier = Modifier
.size(24.dp) .size(28.dp)
.padding(start = 4.dp) .padding(start = 4.dp)
) { ) {
Icon( Icon(
@ -608,6 +913,211 @@ private fun InfoRowDetailed(
} }
} }
@Composable
private fun ExpandableValueText(
value: String,
collapsedMaxLines: Int
) {
var expanded by remember(value) { mutableStateOf(false) }
val canExpand = collapsedMaxLines < Int.MAX_VALUE && (value.length > 120 || value.contains('\n'))
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines,
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
if (canExpand) {
TextButton(
onClick = { expanded = !expanded },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(if (expanded) "Less" else "...more")
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
@Composable
private fun ExpandableSummaryText(
value: String,
collapsedMaxLines: Int
) {
var expanded by remember(value) { mutableStateOf(false) }
val canExpand = value.length > 220 || value.count { it == '\n' } >= collapsedMaxLines || value.looksLikeHtml()
val contentModifier = if (expanded) {
Modifier.fillMaxWidth()
} else {
Modifier
.fillMaxWidth()
.heightIn(max = (collapsedMaxLines * 26).dp)
.clipToBounds()
}
if (value.looksLikeHtml()) {
HtmlSummaryText(
html = value,
expanded = expanded,
collapsedMaxLines = collapsedMaxLines,
modifier = Modifier.fillMaxWidth()
)
} else {
Box(modifier = contentModifier) {
SharedMarkdownText(
markdown = value,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium
)
}
}
if (canExpand) {
TextButton(
onClick = { expanded = !expanded },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(if (expanded) "Less" else "...more")
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
@Composable
private fun HtmlSummaryText(
html: String,
expanded: Boolean,
collapsedMaxLines: Int,
modifier: Modifier = Modifier
) {
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
val linkColor = MaterialTheme.colorScheme.primary.toArgb()
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context).apply {
includeFontPadding = false
movementMethod = LinkMovementMethod.getInstance()
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
textView.setTextColor(textColor)
textView.setLinkTextColor(linkColor)
textView.maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines
textView.ellipsize = if (expanded) null else TextUtils.TruncateAt.END
}
)
}
private fun RecentFileItem.resolveDisplayPath(context: Context, isOpdsStream: Boolean): String {
return if (isOpdsStream) {
"Source: OPDS Stream"
} else if (sourceFolderUri != null && uriString != null) {
try {
val uri = uriString.toUri()
val docId = if (android.provider.DocumentsContract.isDocumentUri(context, uri)) {
android.provider.DocumentsContract.getDocumentId(uri)
} else if (android.provider.DocumentsContract.isTreeUri(uri)) {
android.provider.DocumentsContract.getTreeDocumentId(uri)
} else {
Uri.decode(uri.toString())
}
val split = docId.split(":")
val storageName = if (split[0].equals("primary", ignoreCase = true)) "Internal storage" else split[0]
var relativePath = if (split.size > 1) Uri.decode(split[1]).removeSuffix("/") else ""
if (!relativePath.endsWith(displayName)) {
relativePath = if (relativePath.isEmpty()) displayName else "$relativePath/$displayName"
}
val leadingSlash = if (relativePath.isNotEmpty() && !relativePath.startsWith("/")) "/" else ""
"/$storageName$leadingSlash$relativePath"
} catch (_: Exception) {
val decoded = Uri.decode(uriString)
if (decoded.contains("primary:")) {
"/Internal storage/${decoded.substringAfter("primary:").substringBeforeLast("/")}/$displayName"
} else {
displayName
}
}
} else {
"In-App Storage"
}
}
private fun String.looksLikeHtml(): Boolean {
return contains(Regex("<\\s*/?\\s*(p|br|div|span|strong|em|ul|ol|li|h[1-6]|blockquote|a|b|i)\\b", RegexOption.IGNORE_CASE)) ||
contains(Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);"))
}
private fun RecentFileItem.hasOriginalMetadata(): Boolean {
return listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription).any { !it.isNullOrBlank() } ||
originalSeriesIndex != null
}
private fun RecentFileItem.hasMetadataChanges(): Boolean {
return metadataValueChanged(title, originalTitle) ||
metadataValueChanged(author, originalAuthor) ||
metadataValueChanged(seriesName, originalSeriesName) ||
seriesIndex != originalSeriesIndex ||
metadataValueChanged(description, originalDescription) ||
!customName.isNullOrBlank()
}
private fun metadataValueChanged(current: String?, original: String?): Boolean {
return current.orEmpty().trim() != original.orEmpty().trim()
}
private fun RecentFileItem.seriesLabel(): String? {
val series = seriesName?.trim()?.takeIf { it.isNotBlank() } ?: return null
return seriesIndex?.takeIf { it > 0.0 }?.let { "$series #${it.formatMetadataNumber()}" } ?: series
}
private fun RecentFileItem.readingProgressText(): String {
val progress = progressPercentage?.coerceIn(0f, 100f)
val progressText = progress?.let { String.format(Locale.US, "%.1f%%", it) } ?: "Not started"
val locatorText = when {
lastPage != null -> "Last page ${lastPage + 1}"
lastChapterIndex != null -> "Chapter ${lastChapterIndex + 1}"
else -> null
}
return listOfNotNull(progressText, locatorText).joinToString(" - ")
}
private fun String.toMetadataValue(): String? {
return trim().takeIf { it.isNotEmpty() }
}
private fun String.toSeriesIndexOrNull(): Double? {
return trim()
.replace(',', '.')
.takeIf { it.isNotEmpty() }
?.toDoubleOrNull()
?.takeIf { it > 0.0 }
}
private fun Double.formatMetadataNumber(): String {
return if (this % 1.0 == 0.0) {
toInt().toString()
} else {
String.format(Locale.US, "%.2f", this).trimEnd('0').trimEnd('.')
}
}
@Composable @Composable
fun CustomTopBanner(bannerMessage: BannerMessage?) { fun CustomTopBanner(bannerMessage: BannerMessage?) {
AnimatedVisibility( AnimatedVisibility(
@ -931,7 +1441,7 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null
) { ) {
Text( Text(
text = type.name.uppercase(), text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)

View file

@ -17,9 +17,10 @@ import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
import com.aryan.reader.shared.RenderMode as SharedRenderMode import com.aryan.reader.shared.RenderMode as SharedRenderMode
import com.aryan.reader.shared.SharedLibraryProjectionInput
import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfRecord import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.ShelfType as SharedShelfType
import com.aryan.reader.shared.SortOrder as SharedSortOrder import com.aryan.reader.shared.SortOrder as SharedSortOrder
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag import com.aryan.reader.shared.Tag as SharedTag
@ -34,18 +35,88 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
title = title, title = title,
author = author, author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
progressPercentage = progressPercentage, progressPercentage = progressPercentage,
isRecent = isRecent, isRecent = isRecent,
fileSize = fileSize, fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
sourceFolder = sourceFolderUri, sourceFolder = sourceFolderUri,
folderTextMetadataParsed = folderTextMetadataParsed, folderTextMetadataParsed = folderTextMetadataParsed,
seriesName = seriesName, seriesName = seriesName,
seriesIndex = seriesIndex, seriesIndex = seriesIndex,
lastPageIndex = lastPage,
tags = tags.map { it.toSharedTag() }, tags = tags.map { it.toSharedTag() },
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
) )
} }
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
return toSharedBookItem().copy(displayName = displayName)
}
fun SharedBookItem.toRecentFileItem(
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): RecentFileItem {
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
return androidBooksById[id]?.copy(tags = resolvedTags)
?.copy(
uriString = path,
type = type.toAndroidFileType(),
displayName = androidBooksById[id]?.displayName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
lastPage = lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
sourceFolderUri = sourceFolder,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed
)
?: RecentFileItem(
bookId = id,
uriString = path,
type = type.toAndroidFileType(),
displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
lastPage = lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
sourceFolderUri = sourceFolder,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed,
tags = resolvedTags
)
}
fun TagEntity.toSharedTag(): SharedTag { fun TagEntity.toSharedTag(): SharedTag {
return SharedTag( return SharedTag(
id = id, id = id,
@ -153,92 +224,147 @@ fun ReaderScreenState.toSharedReaderScreenState(
) )
} }
fun ReaderScreenState.toSharedLibraryProjectionInput( fun List<RecentFileItem>.withResolvedTags(
recentFilesFromDb: List<RecentFileItem>,
dbShelves: List<ShelfEntity>,
shelfRefs: List<BookShelfCrossRef>,
dbTags: List<TagEntity>, dbTags: List<TagEntity>,
tagRefs: List<BookTagCrossRef> tagRefs: List<BookTagCrossRef>
): SharedLibraryProjectionInput { ): List<RecentFileItem> {
val tagsById = dbTags.associateBy { it.id } val tagsById = dbTags.associateBy { it.id }
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] } entry.value.mapNotNull { tagsById[it.tagId] }
} }
val taggedBooks = recentFilesFromDb.map { item -> return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
fun SharedReaderScreenState.toAndroidReaderScreenState(
base: ReaderScreenState,
androidBooksById: Map<String, RecentFileItem>,
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): ReaderScreenState {
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
fun SharedBookItem.toAndroidBook(): RecentFileItem {
return toRecentFileItem(androidBooksById, tagEntitiesById)
} }
return SharedLibraryProjectionInput( fun bookById(bookId: String): RecentFileItem? {
state = toSharedReaderScreenState( return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
rawBooks = taggedBooks, }
dbTags = dbTags return base.copy(
), recentFiles = recentBooks.map { it.toAndroidBook() },
booksFromStore = taggedBooks allRecentFiles = libraryBooks.map { it.toAndroidBook() },
.filterNot { it.bookId.endsWith("_reflow") } rawLibraryFiles = rawLibraryBooks.map { it.toAndroidBook() },
.map { it.toSharedBookItem() }, viewingShelfId = viewingShelfId,
shelfRecords = dbShelves.map { it.toSharedShelfRecord() }, isAddingBooksToShelf = isAddingBooksToShelf,
shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() }, contextualActionShelfIds = selectedShelfIds,
tags = dbTags.map { it.toSharedTag() } contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
openTabs = openTabs.map { it.toAndroidBook() },
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
booksAvailableForAdding = booksAvailableForAdding.map { it.toAndroidBook() },
allTags = allTags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
)
}
fun SharedShelf.toAndroidShelf(
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): Shelf {
return Shelf(
id = id,
name = name,
type = type.toAndroidShelfType(),
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
parentShelfId = parentShelfId,
childShelfIds = childShelfIds,
depth = depth,
sortKey = sortKey
) )
} }
fun FileType.toSharedFileType(): SharedFileType { fun FileType.toSharedFileType(): SharedFileType {
return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN) return this
} }
private fun RenderMode.toSharedRenderMode(): SharedRenderMode { fun SharedFileType.toAndroidFileType(): FileType {
return SharedRenderMode.valueOf(name) return this
} }
private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource { fun RenderMode.toSharedRenderMode(): SharedRenderMode {
return SharedAddBooksSource.valueOf(name) return this
} }
private fun SortOrder.toSharedSortOrder(): SharedSortOrder { fun SharedRenderMode.toAndroidRenderMode(): RenderMode {
return SharedSortOrder.valueOf(name) return this
} }
private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter { fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
return SharedReadStatusFilter.valueOf(name) return this
} }
private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters { fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource {
return SharedLibraryFilters( return this
fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() },
sourceFolders = sourceFolders,
readStatus = readStatus.toSharedReadStatusFilter(),
tagIds = tagIds
)
} }
private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder { fun SortOrder.toSharedSortOrder(): SharedSortOrder {
return SharedSyncedFolder( return this
uriString = uriString,
name = name,
lastScanTime = lastScanTime,
allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }
)
} }
private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage { fun SharedSortOrder.toAndroidSortOrder(): SortOrder {
return SharedBannerMessage( return this
message = message,
isError = isError,
isPersistent = isPersistent
)
} }
private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode { fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
return SharedAppThemeMode.valueOf(name) return this
} }
private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption { fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter {
return SharedAppContrastOption.valueOf(name) return this
} }
private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme { fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
return SharedCustomAppTheme( return this
id = id, }
name = name,
seedColor = seedColor fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters {
) return this
}
fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
return this
}
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder {
return this
}
private fun SharedShelfType.toAndroidShelfType(): ShelfType {
return ShelfType.valueOf(name)
}
fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
return this
}
fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
return this
}
fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode {
return this
}
fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
return this
}
fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption {
return this
}
fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
return this
}
fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme {
return this
} }

View file

@ -0,0 +1,191 @@
package com.aryan.reader
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
import java.io.File
import kotlin.math.absoluteValue
@Composable
fun ThemedBookCover(
item: RecentFileItem,
modifier: Modifier = Modifier,
contentDescription: String? = item.displayName,
contentScale: ContentScale = ContentScale.Crop
) {
val context = LocalContext.current
val coverFile = remember(item.coverImagePath) {
item.coverImagePath
?.takeIf { it.isNotBlank() }
?.let(::File)
?.takeIf { it.isFile }
}
Box(modifier = modifier) {
GeneratedBookCover(item = item, modifier = Modifier.fillMaxSize())
if (coverFile != null) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(coverFile)
.crossfade(true)
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
modifier = Modifier.fillMaxSize()
)
}
}
}
@Composable
private fun GeneratedBookCover(
item: RecentFileItem,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val seed = remember(item.bookId, item.displayName) {
val hash = (item.bookId.ifBlank { item.displayName }).hashCode()
if (hash == Int.MIN_VALUE) 0 else hash.absoluteValue
}
val baseOptions = listOf(
colorScheme.primaryContainer,
colorScheme.secondaryContainer,
colorScheme.tertiaryContainer,
lerp(colorScheme.primary, colorScheme.surface, 0.30f),
lerp(colorScheme.secondary, colorScheme.surface, 0.26f)
)
val accentOptions = listOf(
colorScheme.primary,
colorScheme.secondary,
colorScheme.tertiary,
colorScheme.inversePrimary
)
val base = baseOptions[seed % baseOptions.size]
val accent = accentOptions[(seed / 7) % accentOptions.size]
val title = item.coverTitle()
val author = item.coverAuthor()
BoxWithConstraints(
modifier = modifier
.background(
Brush.linearGradient(
colors = listOf(
lerp(base, colorScheme.surface, 0.06f),
lerp(base, accent, 0.16f),
lerp(colorScheme.surfaceContainerHighest, base, 0.34f)
)
)
)
.border(0.5.dp, colorScheme.outlineVariant.copy(alpha = 0.35f))
) {
val compact = maxWidth < 80.dp
Box(
modifier = Modifier
.fillMaxHeight()
.width(if (compact) 7.dp else 10.dp)
.background(accent.copy(alpha = 0.42f))
.align(Alignment.CenterStart)
)
Box(
modifier = Modifier
.fillMaxWidth(0.72f)
.height(if (compact) 5.dp else 7.dp)
.align(Alignment.TopEnd)
.offset(y = if (compact) 8.dp else 12.dp)
.background(colorScheme.surface.copy(alpha = 0.30f))
)
Box(
modifier = Modifier
.fillMaxWidth(0.48f)
.height(if (compact) 4.dp else 6.dp)
.align(Alignment.BottomStart)
.offset(x = if (compact) 12.dp else 18.dp, y = if (compact) (-10).dp else (-16).dp)
.background(accent.copy(alpha = 0.26f))
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(
start = if (compact) 12.dp else 18.dp,
top = if (compact) 10.dp else 18.dp,
end = if (compact) 8.dp else 14.dp,
bottom = if (compact) 10.dp else 16.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
color = colorScheme.onSurface,
fontSize = if (compact) 12.sp else 17.sp,
lineHeight = if (compact) 14.sp else 20.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
if (author != null && !compact) {
Spacer(modifier = Modifier.height(10.dp))
Box(
modifier = Modifier
.width(36.dp)
.height(1.dp)
.background(accent.copy(alpha = 0.55f))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = author,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
lineHeight = 14.sp,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
private fun RecentFileItem.coverTitle(): String {
return customName
?.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
?: displayName.substringBeforeLast('.', missingDelimiterValue = displayName)
}
private fun RecentFileItem.coverAuthor(): String? {
return author
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
}

View file

@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class, TagEntity::class,
BookTagCrossRef::class BookTagCrossRef::class
], ],
version = 19, version = 22,
exportSchema = false exportSchema = false
) )
@TypeConverters(FileTypeConverter::class) @TypeConverters(FileTypeConverter::class)
@ -257,6 +257,37 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_19_20 = object : Migration(19, 20) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN folderCoverMetadataParsed INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATION_20_21 = object : Migration(20, 21) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalTitle TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalAuthor TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesName TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalSeriesIndex REAL DEFAULT NULL")
db.execSQL("ALTER TABLE recent_files ADD COLUMN originalDescription TEXT DEFAULT NULL")
db.execSQL("""
UPDATE recent_files
SET
originalTitle = title,
originalAuthor = author,
originalSeriesName = seriesName,
originalSeriesIndex = seriesIndex,
originalDescription = description
""")
}
}
val MIGRATION_21_22 = object : Migration(21, 22) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN fileContentModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
}
}
fun getDatabase(context: Context): AppDatabase { fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
@ -269,7 +300,8 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19 MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
MIGRATION_20_21, MIGRATION_21_22
) )
.fallbackToDestructiveMigration(false) .fallbackToDestructiveMigration(false)
.build() .build()

View file

@ -0,0 +1,9 @@
package com.aryan.reader.data
data class BookMetadataEdit(
val title: String?,
val author: String?,
val seriesName: String?,
val seriesIndex: Double?,
val description: String?
)

View file

@ -20,13 +20,19 @@ data class FolderBookMetadata(
val locatorBlockIndex: Int?, val locatorBlockIndex: Int?,
val locatorCharOffset: Int?, val locatorCharOffset: Int?,
val customName: String?, val customName: String?,
val highlightsJson: String? val highlightsJson: String?,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
val originalTitle: String? = null,
val originalAuthor: String? = null,
val originalSeriesName: String? = null,
val originalSeriesIndex: Double? = null,
val originalDescription: String? = null
) { ) {
fun toJsonString(): String { fun toJsonString(): String {
val json = JSONObject() val json = JSONObject()
json.put("bookId", bookId) json.put("bookId", bookId)
json.put("title", title)
json.put("author", author)
json.put("displayName", displayName) json.put("displayName", displayName)
json.put("type", type) json.put("type", type)
json.put("lastChapterIndex", lastChapterIndex ?: -1) json.put("lastChapterIndex", lastChapterIndex ?: -1)
@ -58,8 +64,8 @@ data class FolderBookMetadata(
return FolderBookMetadata( return FolderBookMetadata(
bookId = json.getString("bookId"), bookId = json.getString("bookId"),
title = json.optStringNull("title"), title = null,
author = json.optStringNull("author"), author = null,
displayName = json.optString("displayName", "Unknown"), displayName = json.optString("displayName", "Unknown"),
type = json.optString("type", "PDF"), type = json.optString("type", "PDF"),
lastChapterIndex = json.optIntNull("lastChapterIndex"), lastChapterIndex = json.optIntNull("lastChapterIndex"),
@ -72,7 +78,15 @@ data class FolderBookMetadata(
locatorBlockIndex = json.optIntNull("locatorBlockIndex"), locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
locatorCharOffset = json.optIntNull("locatorCharOffset"), locatorCharOffset = json.optIntNull("locatorCharOffset"),
customName = json.optStringNull("customName"), customName = json.optStringNull("customName"),
highlightsJson = json.optStringNull("highlightsJson") highlightsJson = json.optStringNull("highlightsJson"),
seriesName = null,
seriesIndex = null,
description = null,
originalTitle = null,
originalAuthor = null,
originalSeriesName = null,
originalSeriesIndex = null,
originalDescription = null
) )
} }
} }
@ -86,8 +100,8 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
displayName = this.displayName, displayName = this.displayName,
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
coverImagePath = coverPath, coverImagePath = coverPath,
title = this.title, title = this.displayName.substringBeforeLast('.', this.displayName),
author = this.author, author = null,
lastChapterIndex = this.lastChapterIndex, lastChapterIndex = this.lastChapterIndex,
lastPage = this.lastPage, lastPage = this.lastPage,
lastPositionCfi = this.lastPositionCfi, lastPositionCfi = this.lastPositionCfi,
@ -101,6 +115,14 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
bookmarksJson = this.bookmarksJson, bookmarksJson = this.bookmarksJson,
sourceFolderUri = sourceFolderUri, sourceFolderUri = sourceFolderUri,
customName = this.customName, customName = this.customName,
highlightsJson = this.highlightsJson highlightsJson = this.highlightsJson,
seriesName = null,
seriesIndex = null,
description = null,
originalTitle = null,
originalAuthor = null,
originalSeriesName = null,
originalSeriesIndex = null,
originalDescription = null
) )
} }

View file

@ -7,6 +7,12 @@ import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.aryan.reader.ReaderPerfLog import com.aryan.reader.ReaderPerfLog
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
import com.aryan.reader.shared.localFolderSyncMetadataFileName
import com.aryan.reader.shared.localFolderSyncMetadataTempFileName
import com.aryan.reader.shared.localFolderSyncSidecarStem
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
@ -22,6 +28,12 @@ object LocalSyncUtils {
val uri: Uri val uri: Uri
) )
private data class ParsedAnnotationSidecar(
val bookId: String,
val timestamp: Long,
val data: String
)
private fun syncSubfolderDocId(rootDocId: String): String { private fun syncSubfolderDocId(rootDocId: String): String {
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) { return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
rootDocId rootDocId
@ -182,15 +194,14 @@ object LocalSyncUtils {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
val syncFileName = ".${metadata.bookId}.json" val syncFileName = localFolderSyncMetadataFileName(metadata.bookId)
val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId) val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId)
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.") Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
return@withContext return@withContext
} }
val tempFileName = ".${metadata.bookId}.tmp" val tempFileName = uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId))
syncDir.findFile(tempFileName)?.delete()
val tempFile = syncDir.createFile("application/json", tempFileName) val tempFile = syncDir.createFile("application/json", tempFileName)
if (tempFile == null) { if (tempFile == null) {
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}") Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
@ -258,10 +269,8 @@ object LocalSyncUtils {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json" val targetName = localFolderSyncAnnotationFileName(bookId)
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp" val tempName = uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId))
syncDir.findFile(tempName)?.delete()
val tempFile = syncDir.createFile("application/json", tempName)
if (currentBest != null) { if (currentBest != null) {
val (remoteTs, _) = currentBest val (remoteTs, _) = currentBest
@ -273,10 +282,12 @@ object LocalSyncUtils {
val wrapper = JSONObject() val wrapper = JSONObject()
wrapper.put("version", 1) wrapper.put("version", 1)
wrapper.put("bookId", bookId)
wrapper.put("timestamp", timestamp) wrapper.put("timestamp", timestamp)
wrapper.put("data", JSONObject(jsonPayload)) wrapper.put("data", JSONObject(jsonPayload))
val contentBytes = wrapper.toString().toByteArray() val contentBytes = wrapper.toString().toByteArray()
val tempFile = syncDir.createFile("application/json", tempName)
if (tempFile == null) { if (tempFile == null) {
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.") Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
return@withContext return@withContext
@ -325,17 +336,21 @@ object LocalSyncUtils {
val results = mutableMapOf<String, Pair<Long, String>>() val results = mutableMapOf<String, Pair<Long, String>>()
try { try {
val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri) val parsedSidecars = querySyncSubfolderFiles(context, sourceFolderUri)
.filter { file -> .asSequence()
val name = file.name .filter { isAnnotationSidecarCandidateName(it.name) }
extractAnnotationBookId(name) != null && .mapNotNull { file ->
!name.contains(".syncthing.") parseAnnotationSidecar(
context = context,
file = file,
fallbackBookId = extractLegacyAnnotationBookId(file.name)
)
} }
.groupBy { file -> extractAnnotationBookId(file.name).orEmpty() } .groupBy { it.bookId }
for ((bookId, files) in groupedFiles) { for ((bookId, sidecars) in parsedSidecars) {
val best = resolveAnnotationConflictsReadOnly(context, bookId, files) val best = sidecars.maxByOrNull { it.timestamp }
if (best != null) results[bookId] = best if (best != null) results[bookId] = best.timestamp to best.data
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars") Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
@ -344,44 +359,6 @@ object LocalSyncUtils {
return@withContext results return@withContext results
} }
private fun resolveAnnotationConflictsReadOnly(
context: Context,
bookId: String,
files: List<SyncFileEntry>
): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
var bestTs = -1L
var bestData: String? = null
for (file in files) {
val name = file.name
if (!((name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
name.endsWith(".json") &&
!name.endsWith(".tmp") &&
!name.contains(".syncthing."))
) {
continue
}
try {
val content = context.contentResolver.openInputStream(file.uri)?.use {
it.bufferedReader().readText()
} ?: continue
val json = JSONObject(content)
val ts = json.optLong("timestamp", 0L)
val data = json.optJSONObject("data")?.toString()
if (data != null && ts > bestTs) {
bestTs = ts
bestData = data
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: $name")
}
}
return bestData?.let { bestTs to it }
}
suspend fun getAnnotationSidecar( suspend fun getAnnotationSidecar(
context: Context, context: Context,
sourceFolderUri: Uri, sourceFolderUri: Uri,
@ -405,17 +382,16 @@ object LocalSyncUtils {
bookId: String, bookId: String,
knownFiles: List<DocumentFile>? = null knownFiles: List<DocumentFile>? = null
): Pair<Long, String>? { ): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
val allFiles = knownFiles ?: syncDir.listFiles().asList() val allFiles = knownFiles ?: syncDir.listFiles().asList()
val candidates = allFiles.filter { file -> val candidates = allFiles.mapNotNull { file ->
val name = file.name ?: "" val name = file.name ?: return@mapNotNull null
(name.startsWith(basePattern) || name.startsWith(legacyPattern)) && if (!isAnnotationSidecarCandidateName(name)) return@mapNotNull null
name.endsWith(".json") && parseAnnotationSidecar(
!name.endsWith(".tmp") && context = context,
!name.contains(".syncthing.") file = SyncFileEntry(name = name, uri = file.uri),
fallbackBookId = extractLegacyAnnotationBookId(name)
)?.takeIf { it.bookId == bookId }?.let { file to it }
} }
if (candidates.isEmpty()) return null if (candidates.isEmpty()) return null
@ -425,32 +401,17 @@ object LocalSyncUtils {
var bestFile: DocumentFile? = null var bestFile: DocumentFile? = null
val filesToDelete = mutableListOf<DocumentFile>() val filesToDelete = mutableListOf<DocumentFile>()
for (file in candidates) { for ((file, sidecar) in candidates) {
try { if (sidecar.timestamp > bestTs) {
val content = context.contentResolver.openInputStream(file.uri)?.use { if (bestFile != null) {
it.bufferedReader().readText() filesToDelete.add(bestFile)
} ?: continue }
bestTs = sidecar.timestamp
val json = JSONObject(content) bestData = sidecar.data
val ts = json.optLong("timestamp", 0L)
val data = json.optJSONObject("data")?.toString()
if (data != null) {
if (ts > bestTs) {
if (bestFile != null) filesToDelete.add(bestFile)
bestTs = ts
bestData = data
bestFile = file bestFile = file
} else { } else {
filesToDelete.add(file) filesToDelete.add(file)
} }
} else {
filesToDelete.add(file)
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error parsing candidate file: ${file.name}")
}
} }
if (filesToDelete.isNotEmpty()) { if (filesToDelete.isNotEmpty()) {
@ -464,7 +425,7 @@ object LocalSyncUtils {
} }
if (bestFile != null) { if (bestFile != null) {
val correctName = "${basePattern}.json" val correctName = localFolderSyncAnnotationFileName(bookId)
if (bestFile.name != correctName) { if (bestFile.name != correctName) {
Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName") Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName")
val existingTarget = syncDir.findFile(correctName) val existingTarget = syncDir.findFile(correctName)
@ -548,7 +509,7 @@ object LocalSyncUtils {
} }
} }
val correctName = ".${bookId}.json" val correctName = localFolderSyncMetadataFileName(bookId)
if (bestFile.name != correctName) { if (bestFile.name != correctName) {
Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName") Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
bestFile.renameTo(correctName) bestFile.renameTo(correctName)
@ -563,21 +524,24 @@ object LocalSyncUtils {
syncDir: DocumentFile, syncDir: DocumentFile,
bookId: String bookId: String
): FolderBookMetadata? { ): FolderBookMetadata? {
val hashedStem = localFolderSyncSidecarStem(bookId)
val candidates = syncDir.listFiles().filter { file -> val candidates = syncDir.listFiles().filter { file ->
val name = file.name ?: "" val name = file.name ?: ""
val normalizedName = if (name.startsWith(".")) name.substring(1) else name val normalizedName = name.normalizedSidecarName()
normalizedName == "$bookId.json" || isMetadataSidecarCandidateName(name) &&
normalizedName.startsWith("$bookId.sync-conflict") || (
normalizedName.startsWith("$bookId.json.sync-conflict") normalizedName.matchesJsonSidecarStem(hashedStem) ||
normalizedName.matchesJsonSidecarStem(bookId)
)
} }
if (candidates.isEmpty()) return null if (candidates.isEmpty()) return null
return resolveAndCleanConflicts(context, candidates, bookId) return resolveAndCleanConflicts(context, candidates, bookId)
} }
private fun extractAnnotationBookId(name: String?): String? { private fun extractLegacyAnnotationBookId(name: String?): String? {
if (name.isNullOrBlank()) return null if (name.isNullOrBlank()) return null
var temp = name var temp = name
if (!temp.contains(ANNOTATION_SUFFIX) || !temp.endsWith(".json") || temp.endsWith(".tmp")) return null if (!isAnnotationSidecarCandidateName(temp)) return null
if (temp.contains(".sync-conflict")) { if (temp.contains(".sync-conflict")) {
temp = temp.substringBefore(".sync-conflict") temp = temp.substringBefore(".sync-conflict")
} }
@ -588,9 +552,63 @@ object LocalSyncUtils {
if (temp.startsWith(".")) { if (temp.startsWith(".")) {
temp = temp.substring(1) temp = temp.substring(1)
} }
if (temp.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
return temp.ifBlank { null } return temp.ifBlank { null }
} }
private fun isMetadataSidecarCandidateName(name: String): Boolean {
if (name.contains(ANNOTATION_SUFFIX)) return false
if (name.contains(".tmp") || name.contains(".syncthing.")) return false
return name.endsWith(".json") || name.contains(".sync-conflict")
}
private fun isAnnotationSidecarCandidateName(name: String): Boolean {
if (!name.contains(ANNOTATION_SUFFIX)) return false
if (name.contains(".tmp") || name.contains(".syncthing.")) return false
return name.endsWith(".json") || name.contains(".sync-conflict")
}
private fun String.normalizedSidecarName(): String {
return removePrefix(".")
}
private fun String.matchesJsonSidecarStem(stem: String): Boolean {
return this == "$stem.json" ||
startsWith("$stem.sync-conflict") ||
startsWith("$stem.json.sync-conflict")
}
private fun uniqueFolderSyncTempName(baseName: String): String {
val stem = baseName.removeSuffix(".tmp")
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
return "$stem.$nonce.tmp"
}
private fun parseAnnotationSidecar(
context: Context,
file: SyncFileEntry,
fallbackBookId: String?
): ParsedAnnotationSidecar? {
return try {
val content = context.contentResolver.openInputStream(file.uri)?.use {
it.bufferedReader().readText()
} ?: return null
val json = JSONObject(content)
val bookId = json.optString("bookId").takeIf { it.isNotBlank() }
?: fallbackBookId
?: return null
val data = json.optJSONObject("data")?.toString() ?: return null
ParsedAnnotationSidecar(
bookId = bookId,
timestamp = json.optLong("timestamp", 0L),
data = data
)
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: ${file.name}")
null
}
}
suspend fun deleteBookSidecars( suspend fun deleteBookSidecars(
context: Context, context: Context,
sourceFolderUri: Uri, sourceFolderUri: Uri,
@ -599,13 +617,15 @@ object LocalSyncUtils {
try { try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext
val hashedStem = localFolderSyncSidecarStem(bookId)
val hashedAnnotationStem = "$hashedStem$ANNOTATION_SUFFIX"
val targets = syncDir.listFiles().filter { file -> val targets = syncDir.listFiles().filter { file ->
val name = file.name ?: return@filter false val name = file.name ?: return@filter false
val normalized = if (name.startsWith(".")) name.substring(1) else name val normalized = name.normalizedSidecarName()
normalized == "$bookId.json" || normalized.matchesJsonSidecarStem(hashedStem) ||
normalized.startsWith("$bookId.sync-conflict") || normalized.matchesJsonSidecarStem(bookId) ||
normalized.startsWith("$bookId.json.sync-conflict") || normalized.matchesJsonSidecarStem(hashedAnnotationStem) ||
normalized.startsWith("$bookId${ANNOTATION_SUFFIX}") normalized.matchesJsonSidecarStem("$bookId$ANNOTATION_SUFFIX")
} }
targets.forEach { targets.forEach {
try { try {
@ -626,34 +646,32 @@ object LocalSyncUtils {
try { try {
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri) val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
val groupedFiles = allFiles val groupedMetadata = allFiles
.filter { .asSequence()
val name = it.name .filter { isMetadataSidecarCandidateName(it.name) }
(name.endsWith(".json") || name.contains(".sync-conflict")) && .mapNotNull { file ->
!name.contains(ANNOTATION_SUFFIX) && try {
!name.endsWith(".tmp") && val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
!name.contains(".syncthing.") input.bufferedReader().use { it.readText() }
} }
.groupBy { file -> jsonString?.let(FolderBookMetadata::fromJsonString)
var name = file.name } catch (e: Exception) {
if (name.startsWith(".")) name = name.substring(1) Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
if (name.contains(".sync-conflict")) { null
name.substringBefore(".sync-conflict")
} else {
name.substringBefore(".json")
} }
} }
.groupBy { it.bookId }
groupedFiles.forEach { (bookId, files) -> groupedMetadata.forEach { (bookId, metadataRecords) ->
val winner = resolveMetadataConflictsReadOnly(context, files, bookId) val winner = metadataRecords.maxByOrNull { it.lastModifiedTimestamp }
if (winner != null) { if (winner != null) {
finalResults[bookId] = winner finalResults[bookId] = winner
} }
} }
Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.") Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedMetadata.size} book records from sync data.")
ReaderPerfLog.d( ReaderPerfLog.d(
"LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}" "LocalSync metadata read files=${allFiles.size} groups=${groupedMetadata.size} records=${finalResults.size}"
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -663,29 +681,4 @@ object LocalSyncUtils {
return@withContext finalResults return@withContext finalResults
} }
private fun resolveMetadataConflictsReadOnly(
context: Context,
files: List<SyncFileEntry>,
bookId: String
): FolderBookMetadata? {
var bestMeta: FolderBookMetadata? = null
for (file in files) {
try {
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
input.bufferedReader().use { it.readText() }
}
if (jsonString != null) {
val meta = FolderBookMetadata.fromJsonString(jsonString)
if (meta.bookId == bookId &&
(bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp)
) {
bestMeta = meta
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
}
}
return bestMeta
}
} }

View file

@ -28,7 +28,8 @@ data class PurchaseEntity(
val purchaseToken: String, val purchaseToken: String,
val purchaseTime: Long, val purchaseTime: Long,
val isAcknowledged: Boolean, val isAcknowledged: Boolean,
val isAutoRenewing: Boolean val isAutoRenewing: Boolean,
val obfuscatedAccountId: String? = null
) )
/** /**

View file

@ -33,7 +33,7 @@ interface RecentFileDao {
@Upsert @Upsert
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>) suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileSummary>> fun getRecentFiles(): Flow<List<RecentFileSummary>>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
@ -45,7 +45,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileSummary> fun getRecentFilesList(limit: Int): List<RecentFileSummary>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
@ -94,26 +94,36 @@ interface RecentFileDao {
SELECT * FROM recent_files SELECT * FROM recent_files
WHERE sourceFolderUri IS NOT NULL WHERE sourceFolderUri IS NOT NULL
AND isDeleted = 0 AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') AND (
AND folderTextMetadataParsed = 0 (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
)
ORDER BY timestamp DESC
LIMIT :limit
""") """)
suspend fun getFolderBooksNeedingTextMetadata(): List<RecentFileEntity> suspend fun getFolderBooksNeedingTextMetadata(limit: Int): List<RecentFileEntity>
@Query(""" @Query("""
SELECT * FROM recent_files SELECT * FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0 AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') AND (
AND folderTextMetadataParsed = 0 (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
)
ORDER BY timestamp DESC
LIMIT :limit
""") """)
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List<RecentFileEntity> suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String, limit: Int): List<RecentFileEntity>
@Query(""" @Query("""
SELECT COUNT(*) FROM recent_files SELECT COUNT(*) FROM recent_files
WHERE sourceFolderUri IS NOT NULL WHERE sourceFolderUri IS NOT NULL
AND isDeleted = 0 AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') AND (
AND folderTextMetadataParsed = 0 (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
)
""") """)
suspend fun countFolderBooksNeedingTextMetadata(): Int suspend fun countFolderBooksNeedingTextMetadata(): Int
@ -121,8 +131,10 @@ interface RecentFileDao {
SELECT COUNT(*) FROM recent_files SELECT COUNT(*) FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0 AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') AND (
AND folderTextMetadataParsed = 0 (type IN ('PDF', 'EPUB', 'MOBI', 'FB2', 'ODT', 'FODT', 'DOCX') AND folderTextMetadataParsed = 0)
OR (type IN ('EPUB', 'MOBI', 'FB2') AND folderCoverMetadataParsed = 0 AND (coverImagePath IS NULL OR coverImagePath = ''))
)
""") """)
suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int
@ -130,10 +142,60 @@ interface RecentFileDao {
UPDATE recent_files UPDATE recent_files
SET SET
coverImagePath = COALESCE(:coverImagePath, coverImagePath), coverImagePath = COALESCE(:coverImagePath, coverImagePath),
title = COALESCE(:title, title), title = CASE
author = COALESCE(:author, author), WHEN :title IS NOT NULL AND (originalTitle IS NULL OR title IS NULL OR title = originalTitle OR title = displayName)
THEN :title
ELSE title
END,
author = CASE
WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR author IS NULL OR author = originalAuthor)
THEN :author
ELSE author
END,
seriesName = CASE
WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR seriesName IS NULL OR seriesName = originalSeriesName)
THEN :seriesName
ELSE seriesName
END,
seriesIndex = CASE
WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR seriesIndex IS NULL OR seriesIndex = originalSeriesIndex)
THEN :seriesIndex
ELSE seriesIndex
END,
description = CASE
WHEN :description IS NOT NULL AND (originalDescription IS NULL OR description IS NULL OR description = originalDescription)
THEN :description
ELSE description
END,
originalTitle = CASE
WHEN :title IS NOT NULL AND (originalTitle IS NULL OR originalTitle = title OR originalTitle = displayName)
THEN :title
ELSE originalTitle
END,
originalAuthor = CASE
WHEN :author IS NOT NULL AND (originalAuthor IS NULL OR originalAuthor = author)
THEN :author
ELSE originalAuthor
END,
originalSeriesName = CASE
WHEN :seriesName IS NOT NULL AND (originalSeriesName IS NULL OR originalSeriesName = seriesName)
THEN :seriesName
ELSE originalSeriesName
END,
originalSeriesIndex = CASE
WHEN :seriesIndex IS NOT NULL AND (originalSeriesIndex IS NULL OR originalSeriesIndex = seriesIndex)
THEN :seriesIndex
ELSE originalSeriesIndex
END,
originalDescription = CASE
WHEN :description IS NOT NULL AND (originalDescription IS NULL OR originalDescription = description)
THEN :description
ELSE originalDescription
END,
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END, fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
folderTextMetadataParsed = 1 fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
folderTextMetadataParsed = CASE WHEN :textMetadataParsed = 1 THEN 1 ELSE folderTextMetadataParsed END,
folderCoverMetadataParsed = CASE WHEN :coverMetadataParsed = 1 THEN 1 ELSE folderCoverMetadataParsed END
WHERE bookId = :bookId WHERE bookId = :bookId
""") """)
suspend fun updateExtractedMetadata( suspend fun updateExtractedMetadata(
@ -141,7 +203,13 @@ interface RecentFileDao {
coverImagePath: String?, coverImagePath: String?,
title: String?, title: String?,
author: String?, author: String?,
fileSize: Long seriesName: String?,
seriesIndex: Double?,
description: String?,
fileSize: Long,
fileContentModifiedTimestamp: Long,
textMetadataParsed: Boolean,
coverMetadataParsed: Boolean
) )
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL") @Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
@ -149,4 +217,58 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId") @Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long) suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long)
@Query("""
UPDATE recent_files
SET
title = :title,
author = :author,
seriesName = :seriesName,
seriesIndex = :seriesIndex,
description = :description,
customName = NULL,
originalTitle = COALESCE(originalTitle, title),
originalAuthor = COALESCE(originalAuthor, author),
originalSeriesName = COALESCE(originalSeriesName, seriesName),
originalSeriesIndex = COALESCE(originalSeriesIndex, seriesIndex),
originalDescription = COALESCE(originalDescription, description),
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
folderTextMetadataParsed = 1,
lastModifiedTimestamp = :timestamp
WHERE bookId = :bookId
""")
suspend fun updateUserEditableMetadata(
bookId: String,
title: String?,
author: String?,
seriesName: String?,
seriesIndex: Double?,
description: String?,
fileSize: Long,
fileContentModifiedTimestamp: Long,
timestamp: Long
)
@Query("""
UPDATE recent_files
SET
title = COALESCE(originalTitle, displayName),
author = originalAuthor,
seriesName = originalSeriesName,
seriesIndex = originalSeriesIndex,
description = originalDescription,
customName = NULL,
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
fileContentModifiedTimestamp = CASE WHEN :fileContentModifiedTimestamp > 0 THEN :fileContentModifiedTimestamp ELSE fileContentModifiedTimestamp END,
folderTextMetadataParsed = 1,
lastModifiedTimestamp = :timestamp
WHERE bookId = :bookId
""")
suspend fun restoreOriginalMetadata(
bookId: String,
fileSize: Long,
fileContentModifiedTimestamp: Long,
timestamp: Long
)
} }

View file

@ -52,10 +52,17 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val customName: String?,
@ColumnInfo(defaultValue = "NULL") val highlights: String?, @ColumnInfo(defaultValue = "NULL") val highlights: String?,
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long, @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
@ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L,
@ColumnInfo(defaultValue = "NULL") val seriesName: String?, @ColumnInfo(defaultValue = "NULL") val seriesName: String?,
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?, @ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
@ColumnInfo(defaultValue = "NULL") val description: String?, @ColumnInfo(defaultValue = "NULL") val description: String?,
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean @ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean,
@ColumnInfo(defaultValue = "0") val folderCoverMetadataParsed: Boolean = false,
@ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
) )
data class RecentFileSummary( data class RecentFileSummary(
@ -81,7 +88,13 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val customName: String?,
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long, @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
@ColumnInfo(defaultValue = "0") val fileContentModifiedTimestamp: Long = 0L,
@ColumnInfo(defaultValue = "NULL") val seriesName: String?, @ColumnInfo(defaultValue = "NULL") val seriesName: String?,
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?, @ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
@ColumnInfo(defaultValue = "NULL") val description: String? @ColumnInfo(defaultValue = "NULL") val description: String?,
@ColumnInfo(defaultValue = "NULL") val originalTitle: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
) )

View file

@ -46,10 +46,17 @@ data class RecentFileItem(
val customName: String? = null, val customName: String? = null,
val highlightsJson: String? = null, val highlightsJson: String? = null,
val fileSize: Long = 0L, val fileSize: Long = 0L,
val fileContentModifiedTimestamp: Long = 0L,
val seriesName: String? = null, val seriesName: String? = null,
val seriesIndex: Double? = null, val seriesIndex: Double? = null,
val description: String? = null, val description: String? = null,
val originalTitle: String? = null,
val originalAuthor: String? = null,
val originalSeriesName: String? = null,
val originalSeriesIndex: Double? = null,
val originalDescription: String? = null,
val folderTextMetadataParsed: Boolean = false, val folderTextMetadataParsed: Boolean = false,
val folderCoverMetadataParsed: Boolean = false,
val tags: List<TagEntity> = emptyList() val tags: List<TagEntity> = emptyList()
) )
@ -79,10 +86,17 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
customName = this.customName, customName = this.customName,
highlightsJson = this.highlights, highlightsJson = this.highlights,
fileSize = this.fileSize, fileSize = this.fileSize,
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName, seriesName = this.seriesName,
seriesIndex = this.seriesIndex, seriesIndex = this.seriesIndex,
description = this.description, description = this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed originalTitle = this.originalTitle,
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription,
folderTextMetadataParsed = this.folderTextMetadataParsed,
folderCoverMetadataParsed = this.folderCoverMetadataParsed
) )
} }
@ -112,10 +126,17 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
customName = this.customName, customName = this.customName,
highlights = this.highlightsJson, highlights = this.highlightsJson,
fileSize = this.fileSize, fileSize = this.fileSize,
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName, seriesName = this.seriesName,
seriesIndex = this.seriesIndex, seriesIndex = this.seriesIndex,
description = this.description, description = this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed originalTitle = this.originalTitle ?: this.title,
originalAuthor = this.originalAuthor ?: this.author,
originalSeriesName = this.originalSeriesName ?: this.seriesName,
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
originalDescription = this.originalDescription ?: this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed,
folderCoverMetadataParsed = this.folderCoverMetadataParsed
) )
} }
@ -138,7 +159,16 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
bookmarksJson = this.bookmarksJson, bookmarksJson = this.bookmarksJson,
hasAnnotations = false, hasAnnotations = false,
customName = this.customName, customName = this.customName,
highlightsJson = this.highlightsJson highlightsJson = this.highlightsJson,
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
description = this.description,
originalTitle = this.originalTitle ?: this.title,
originalAuthor = this.originalAuthor ?: this.author,
originalSeriesName = this.originalSeriesName ?: this.seriesName,
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
originalDescription = this.originalDescription ?: this.description
) )
} }
@ -164,7 +194,16 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
isDeleted = this.isDeleted, isDeleted = this.isDeleted,
bookmarksJson = this.bookmarksJson, bookmarksJson = this.bookmarksJson,
customName = this.customName, customName = this.customName,
highlightsJson = this.highlightsJson highlightsJson = this.highlightsJson,
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
description = this.description,
originalTitle = this.originalTitle,
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription
) )
} }
@ -194,8 +233,14 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
customName = this.customName, customName = this.customName,
highlightsJson = null, highlightsJson = null,
fileSize = this.fileSize, fileSize = this.fileSize,
fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName, seriesName = this.seriesName,
seriesIndex = this.seriesIndex, seriesIndex = this.seriesIndex,
description = this.description description = this.description,
originalTitle = this.originalTitle,
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription
) )
} }

View file

@ -22,9 +22,12 @@ package com.aryan.reader.data
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.FileType
import com.aryan.reader.ReaderPerfLog import com.aryan.reader.ReaderPerfLog
import com.aryan.reader.scaledToCanvasLimit
import timber.log.Timber import timber.log.Timber
import com.aryan.reader.BookImporter import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
@ -40,7 +43,6 @@ import java.io.FileOutputStream
import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONArray import org.json.JSONArray
@ -48,6 +50,9 @@ import java.util.UUID
import androidx.core.content.edit import androidx.core.content.edit
private const val COVER_CACHE_DIR = "cover_cache" private const val COVER_CACHE_DIR = "cover_cache"
private const val DIRECT_EMBEDDED_COVER_MAX_BYTES = 8L * 1024L * 1024L
private const val EMBEDDED_COVER_MAX_DIMENSION = 1200
private val EMBEDDED_COVER_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
class RecentFilesRepository(private val context: Context) { class RecentFilesRepository(private val context: Context) {
@ -169,12 +174,43 @@ class RecentFilesRepository(private val context: Context) {
} }
val entityToInsert = if (existingItem != null) { val entityToInsert = if (existingItem != null) {
val folderFileChanged = item.sourceFolderUri != null &&
existingItem.sourceFolderUri == item.sourceFolderUri &&
((item.fileSize > 0L && item.fileSize != existingItem.fileSize) ||
(item.fileContentModifiedTimestamp > 0L &&
item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp))
val embeddedMetadataFileChanged =
(item.fileSize > 0L && existingItem.fileSize > 0L && item.fileSize != existingItem.fileSize) ||
(item.fileContentModifiedTimestamp > 0L &&
existingItem.fileContentModifiedTimestamp > 0L &&
item.fileContentModifiedTimestamp != existingItem.fileContentModifiedTimestamp)
val keepExistingEmbeddedMetadata = item.type == FileType.EPUB &&
existingItem.type == FileType.EPUB &&
!embeddedMetadataFileChanged &&
existingItem.hasEmbeddedMetadataChanges()
item.toRecentFileEntity().copy( item.toRecentFileEntity().copy(
uriString = existingItem.uriString ?: item.uriString, uriString = existingItem.uriString ?: item.uriString,
isAvailable = existingItem.isAvailable || item.isAvailable, isAvailable = existingItem.isAvailable || item.isAvailable,
coverImagePath = item.coverImagePath ?: existingItem.coverImagePath, coverImagePath = if (folderFileChanged) {
title = item.title ?: existingItem.title, item.coverImagePath
author = item.author ?: existingItem.author, } else {
item.coverImagePath ?: existingItem.coverImagePath
},
title = if (folderFileChanged) {
item.title ?: item.displayName.substringBeforeLast('.', item.displayName)
} else if (keepExistingEmbeddedMetadata) {
existingItem.title
} else {
item.title ?: existingItem.title
},
author = if (folderFileChanged) {
item.author
} else if (keepExistingEmbeddedMetadata) {
existingItem.author
} else {
item.author ?: existingItem.author
},
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex, lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
lastPage = item.lastPage ?: existingItem.lastPage, lastPage = item.lastPage ?: existingItem.lastPage,
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi, lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
@ -187,10 +223,43 @@ class RecentFilesRepository(private val context: Context) {
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri, sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
highlights = item.highlightsJson ?: existingItem.highlights, highlights = item.highlightsJson ?: existingItem.highlights,
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize, fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
seriesName = item.seriesName ?: existingItem.seriesName, fileContentModifiedTimestamp = if (item.fileContentModifiedTimestamp > 0) item.fileContentModifiedTimestamp else existingItem.fileContentModifiedTimestamp,
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex, seriesName = if (folderFileChanged) {
description = item.description ?: existingItem.description, item.seriesName
folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed } else if (keepExistingEmbeddedMetadata) {
existingItem.seriesName
} else {
item.seriesName ?: existingItem.seriesName
},
seriesIndex = if (folderFileChanged) {
item.seriesIndex
} else if (keepExistingEmbeddedMetadata) {
existingItem.seriesIndex
} else {
item.seriesIndex ?: existingItem.seriesIndex
},
description = if (folderFileChanged) {
item.description
} else if (keepExistingEmbeddedMetadata) {
existingItem.description
} else {
item.description ?: existingItem.description
},
originalTitle = if (folderFileChanged) item.originalTitle ?: item.title else existingItem.originalTitle ?: item.originalTitle ?: item.title,
originalAuthor = if (folderFileChanged) item.originalAuthor ?: item.author else existingItem.originalAuthor ?: item.originalAuthor ?: item.author,
originalSeriesName = if (folderFileChanged) item.originalSeriesName ?: item.seriesName else existingItem.originalSeriesName ?: item.originalSeriesName ?: item.seriesName,
originalSeriesIndex = if (folderFileChanged) item.originalSeriesIndex ?: item.seriesIndex else existingItem.originalSeriesIndex ?: item.originalSeriesIndex ?: item.seriesIndex,
originalDescription = if (folderFileChanged) item.originalDescription ?: item.description else existingItem.originalDescription ?: item.originalDescription ?: item.description,
folderTextMetadataParsed = if (folderFileChanged) {
item.folderTextMetadataParsed
} else {
item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
},
folderCoverMetadataParsed = if (folderFileChanged) {
item.folderCoverMetadataParsed
} else {
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
}
) )
} else { } else {
item.toRecentFileEntity() item.toRecentFileEntity()
@ -201,13 +270,60 @@ class RecentFilesRepository(private val context: Context) {
Timber.d("Added/Updated recent file in DB: ${item.displayName}") Timber.d("Added/Updated recent file in DB: ${item.displayName}")
} }
private fun RecentFileEntity.hasEmbeddedMetadataChanges(): Boolean {
val hasOriginalMetadata = listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription)
.any { !it.isNullOrBlank() } || originalSeriesIndex != null
return (hasOriginalMetadata && (
metadataValueChanged(title, originalTitle) ||
metadataValueChanged(author, originalAuthor) ||
metadataValueChanged(seriesName, originalSeriesName) ||
seriesIndex != originalSeriesIndex ||
metadataValueChanged(description, originalDescription)
))
}
private fun metadataValueChanged(current: String?, original: String?): Boolean {
return current.orEmpty().trim() != original.orEmpty().trim()
}
suspend fun updateUserEditableMetadata(
bookId: String,
metadata: BookMetadataEdit,
fileSize: Long = 0L,
fileContentModifiedTimestamp: Long = 0L
) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateUserEditableMetadata(
bookId = bookId,
title = metadata.title,
author = metadata.author,
seriesName = metadata.seriesName,
seriesIndex = metadata.seriesIndex,
description = metadata.description,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
timestamp = currentTime
)
Timber.d("Updated user-editable metadata for $bookId")
}
suspend fun restoreOriginalMetadata(
bookId: String,
fileSize: Long = 0L,
fileContentModifiedTimestamp: Long = 0L
) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.restoreOriginalMetadata(bookId, fileSize, fileContentModifiedTimestamp, currentTime)
Timber.d("Restored original metadata for $bookId")
}
suspend fun updateHighlights(bookId: String, highlightsJson: String) = withContext(Dispatchers.IO) { suspend fun updateHighlights(bookId: String, highlightsJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
recentFileDao.updateHighlights(bookId, highlightsJson, currentTime) recentFileDao.updateHighlights(bookId, highlightsJson, currentTime)
Timber.d("Updated highlights for $bookId") Timber.d("Updated highlights for $bookId")
} }
suspend fun syncLocalMetadataToFolder(bookId: String) = withContext(Dispatchers.IO) { suspend fun syncLocalMetadataToFolder(bookId: String, force: Boolean = false) = withContext(Dispatchers.IO) {
val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext
val folderUriString = entity.sourceFolderUri val folderUriString = entity.sourceFolderUri
@ -217,7 +333,7 @@ class RecentFilesRepository(private val context: Context) {
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]" val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
val isDirty = entity.isRecent || hasProgress || hasBookmarks || hasHighlights val isDirty = entity.isRecent || hasProgress || hasBookmarks || hasHighlights
if (!isDirty) { if (!force && !isDirty) {
Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.") Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.")
return@withContext return@withContext
} }
@ -240,7 +356,15 @@ class RecentFilesRepository(private val context: Context) {
locatorBlockIndex = entity.locatorBlockIndex, locatorBlockIndex = entity.locatorBlockIndex,
locatorCharOffset = entity.locatorCharOffset, locatorCharOffset = entity.locatorCharOffset,
customName = entity.customName, customName = entity.customName,
highlightsJson = entity.highlights highlightsJson = entity.highlights,
seriesName = entity.seriesName,
seriesIndex = entity.seriesIndex,
description = entity.description,
originalTitle = entity.originalTitle,
originalAuthor = entity.originalAuthor,
originalSeriesName = entity.originalSeriesName,
originalSeriesIndex = entity.originalSeriesIndex,
originalDescription = entity.originalDescription
) )
LocalSyncUtils.saveMetadataToFolder( LocalSyncUtils.saveMetadataToFolder(
@ -275,7 +399,7 @@ class RecentFilesRepository(private val context: Context) {
val hasHighlights = highlightFile.exists() val hasHighlights = highlightFile.exists()
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights") Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.folder.export candidates book=$bookId hasRichText=$hasRichText " + "android.folder.export candidates book=$bookId hasRichText=$hasRichText " +
"richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString" "richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
) )
@ -291,7 +415,7 @@ class RecentFilesRepository(private val context: Context) {
try { try {
val content = file.readText().trim() val content = file.readText().trim()
if (key == "text") { if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}" "android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
) )
} }
@ -302,7 +426,7 @@ class RecentFilesRepository(private val context: Context) {
} }
} catch (e: Exception) { } catch (e: Exception) {
if (key == "text") { if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) Timber
.e(e, "android.folder.export.richTextParseFailed book=$bookId") .e(e, "android.folder.export.richTextParseFailed book=$bookId")
} }
Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file") Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file")
@ -328,7 +452,7 @@ class RecentFilesRepository(private val context: Context) {
val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString()) val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
if (hasRichText) { if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}" "android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
) )
} }
@ -348,7 +472,7 @@ class RecentFilesRepository(private val context: Context) {
val bundle = JSONObject( val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString) SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
) )
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " + "android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" "hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
) )
@ -359,7 +483,7 @@ class RecentFilesRepository(private val context: Context) {
val contentStr = bundle.get(key).toString() val contentStr = bundle.get(key).toString()
file.writeText(contentStr) file.writeText(contentStr)
if (key == "text") { if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}" "android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
) )
} }
@ -431,11 +555,15 @@ class RecentFilesRepository(private val context: Context) {
} }
} }
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List<RecentFileItem> = withContext(Dispatchers.IO) { suspend fun getFolderBooksNeedingTextMetadata(
sourceFolderUri: String? = null,
limit: Int = Int.MAX_VALUE
): List<RecentFileItem> = withContext(Dispatchers.IO) {
val queryLimit = limit.coerceAtLeast(1)
val entities = if (sourceFolderUri.isNullOrBlank()) { val entities = if (sourceFolderUri.isNullOrBlank()) {
recentFileDao.getFolderBooksNeedingTextMetadata() recentFileDao.getFolderBooksNeedingTextMetadata(queryLimit)
} else { } else {
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri) recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri, queryLimit)
} }
return@withContext entities.map { it.toRecentFileItem() } return@withContext entities.map { it.toRecentFileItem() }
} }
@ -459,7 +587,13 @@ class RecentFilesRepository(private val context: Context) {
coverImagePath = item.coverImagePath, coverImagePath = item.coverImagePath,
title = item.title, title = item.title,
author = item.author, author = item.author,
fileSize = item.fileSize seriesName = item.seriesName,
seriesIndex = item.seriesIndex,
description = item.description,
fileSize = item.fileSize,
fileContentModifiedTimestamp = item.fileContentModifiedTimestamp,
textMetadataParsed = item.folderTextMetadataParsed,
coverMetadataParsed = item.folderCoverMetadataParsed
) )
} }
} }
@ -562,9 +696,17 @@ class RecentFilesRepository(private val context: Context) {
val filename = "cover_${uri.toString().hashCode()}.png" val filename = "cover_${uri.toString().hashCode()}.png"
val file = File(cacheDir, filename) val file = File(cacheDir, filename)
var fos: FileOutputStream? = null var fos: FileOutputStream? = null
var scaledCopy: Bitmap? = null
try { try {
deleteCoverCacheVariants(uri)
val bitmapToSave = bitmap.scaledToCanvasLimit(
maxBytes = 8L * 1024L * 1024L,
maxDimension = EMBEDDED_COVER_MAX_DIMENSION
).also {
if (it !== bitmap) scaledCopy = it
}
fos = FileOutputStream(file) fos = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos) bitmapToSave.compress(Bitmap.CompressFormat.PNG, 90, fos)
Timber.d("Saved cover image to: ${file.absolutePath}") Timber.d("Saved cover image to: ${file.absolutePath}")
return@withContext file.absolutePath return@withContext file.absolutePath
} catch (e: Exception) { } catch (e: Exception) {
@ -573,9 +715,70 @@ class RecentFilesRepository(private val context: Context) {
return@withContext null return@withContext null
} finally { } finally {
fos?.close() fos?.close()
scaledCopy?.recycle()
} }
} }
suspend fun saveEmbeddedCoverToCache(bytes: ByteArray, uri: Uri, extension: String): String? = withContext(Dispatchers.IO) {
if (bytes.isEmpty()) return@withContext null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
val safeExtension = extension.lowercase().takeIf { it in EMBEDDED_COVER_EXTENSIONS } ?: "png"
if (
bytes.size.toLong() <= DIRECT_EMBEDDED_COVER_MAX_BYTES &&
bounds.outWidth <= EMBEDDED_COVER_MAX_DIMENSION &&
bounds.outHeight <= EMBEDDED_COVER_MAX_DIMENSION
) {
return@withContext saveEmbeddedCoverBytesToCache(bytes, uri, safeExtension)
}
var sampleSize = 1
while (
(bounds.outWidth / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION ||
(bounds.outHeight / sampleSize) > EMBEDDED_COVER_MAX_DIMENSION
) {
sampleSize *= 2
}
val decoded = BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply { inSampleSize = sampleSize }
) ?: return@withContext null
try {
return@withContext saveCoverToCache(decoded, uri)
} finally {
decoded.recycle()
}
}
private fun saveEmbeddedCoverBytesToCache(bytes: ByteArray, uri: Uri, extension: String): String? {
val cacheDir = getCoverCacheDirInternal()
val filename = "cover_${uri.toString().hashCode()}.$extension"
val file = File(cacheDir, filename)
return try {
deleteCoverCacheVariants(uri)
FileOutputStream(file).use { output -> output.write(bytes) }
Timber.d("Saved embedded cover image to: ${file.absolutePath}")
file.absolutePath
} catch (e: Exception) {
Timber.e(e, "Failed to save embedded cover image to cache for $uri")
file.delete()
null
}
}
private fun deleteCoverCacheVariants(uri: Uri) {
val prefix = "cover_${uri.toString().hashCode()}."
getCoverCacheDirInternal().listFiles()
?.filter { it.isFile && it.name.startsWith(prefix) }
?.forEach { runCatching { it.delete() } }
}
private fun deleteCachedCover(filePath: String): Boolean { private fun deleteCachedCover(filePath: String): Boolean {
val file = File(filePath) val file = File(filePath)
val deleted = file.delete() val deleted = file.delete()
@ -622,10 +825,11 @@ class RecentFilesRepository(private val context: Context) {
suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) { suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) {
try { try {
recentFileDao.getFileByBookId(bookId)?.coverImagePath?.let { deleteCachedCover(it) }
pdfRichTextRepository.getFileForSync(bookId).delete() pdfRichTextRepository.getFileForSync(bookId).delete()
pageLayoutRepository.getLayoutFile(bookId).delete() pageLayoutRepository.getLayoutFile(bookId).delete()
ImportedFileCache.clearBookCache(context, bookId) ImportedFileCache.clearBookCache(context, bookId)
Timber.d("Cleared layout and text caches for modified book: $bookId") Timber.d("Cleared local caches for modified book: $bookId")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error clearing caches for $bookId") Timber.e(e, "Error clearing caches for $bookId")
} }

View file

@ -35,9 +35,11 @@ import org.w3c.dom.Node
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.ByteArrayOutputStream
import java.net.URLDecoder import java.net.URLDecoder
import java.nio.file.Paths import java.nio.file.Paths
import java.util.UUID import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -78,7 +80,8 @@ class EpubParser(private val context: Context) {
val originalBookNameHint: String, val originalBookNameHint: String,
val parserVersion: Int, val parserVersion: Int,
val parseContent: Boolean, val parseContent: Boolean,
val shouldUseToc: Boolean val shouldUseToc: Boolean,
val sourceFingerprint: String? = null
) )
// EpubFile can still represent in-memory file data during initial parsing before extraction // EpubFile can still represent in-memory file data during initial parsing before extraction
@ -109,6 +112,9 @@ class EpubParser(private val context: Context) {
private const val BOOK_METADATA_FILE = "book_metadata.json" private const val BOOK_METADATA_FILE = "book_metadata.json"
private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json" private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
private const val EPUB_EXTRACTION_CACHE_VERSION = 1 private const val EPUB_EXTRACTION_CACHE_VERSION = 1
private const val MAX_METADATA_ENTRY_BYTES = 4 * 1024 * 1024
private const val EPUB_COVER_MAX_DIMENSION = 1024
private val EPUB_IMAGE_EXTENSIONS = setOf(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
} }
internal val String.decodedURL: String internal val String.decodedURL: String
@ -185,7 +191,8 @@ class EpubParser(private val context: Context) {
shouldUseToc: Boolean = true, shouldUseToc: Boolean = true,
originalBookNameHint: String = "streamed_book", originalBookNameHint: String = "streamed_book",
parseContent: Boolean = true, parseContent: Boolean = true,
extractionDirOverride: File? = null extractionDirOverride: File? = null,
sourceFingerprint: String? = null
): EpubBook { ): EpubBook {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId") Timber.d("Parsing EPUB input stream for bookId: $bookId")
@ -201,7 +208,8 @@ class EpubParser(private val context: Context) {
extractionDir = activeDir, extractionDir = activeDir,
bookId = bookId, bookId = bookId,
originalBookNameHint = originalBookNameHint, originalBookNameHint = originalBookNameHint,
shouldUseToc = shouldUseToc shouldUseToc = shouldUseToc,
sourceFingerprint = sourceFingerprint
)?.let { cachedBook -> )?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId") Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
return@withContext cachedBook return@withContext cachedBook
@ -215,7 +223,12 @@ class EpubParser(private val context: Context) {
tempFile.outputStream().use { output -> tempFile.outputStream().use { output ->
inputStream.copyTo(output) inputStream.copyTo(output)
} }
filesMap = extractEpubContents(ZipFile(tempFile), extractionDir, parseContent) filesMap = extractEpubContents(
zipFile = ZipFile(tempFile),
extractionDir = extractionDir,
parseContent = parseContent,
extractImagesForMetadata = shouldDeleteExtractionDir
)
} finally { } finally {
tempFile.delete() tempFile.delete()
} }
@ -229,6 +242,7 @@ class EpubParser(private val context: Context) {
bookId = bookId, bookId = bookId,
originalBookNameHint = originalBookNameHint, originalBookNameHint = originalBookNameHint,
shouldUseToc = shouldUseToc, shouldUseToc = shouldUseToc,
sourceFingerprint = sourceFingerprint,
book = book book = book
) )
} }
@ -243,7 +257,8 @@ class EpubParser(private val context: Context) {
extractionDir: File, extractionDir: File,
bookId: String, bookId: String,
originalBookNameHint: String, originalBookNameHint: String,
shouldUseToc: Boolean shouldUseToc: Boolean,
sourceFingerprint: String?
): EpubBook? { ): EpubBook? {
val metadataFile = File(extractionDir, BOOK_METADATA_FILE) val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE) val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
@ -255,7 +270,8 @@ class EpubParser(private val context: Context) {
manifest.originalBookNameHint == originalBookNameHint && manifest.originalBookNameHint == originalBookNameHint &&
manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION && manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
manifest.parseContent && manifest.parseContent &&
manifest.shouldUseToc == shouldUseToc manifest.shouldUseToc == shouldUseToc &&
manifest.sourceFingerprint == sourceFingerprint
if (!isCompatible) { if (!isCompatible) {
Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId") Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
@ -277,6 +293,7 @@ class EpubParser(private val context: Context) {
bookId: String, bookId: String,
originalBookNameHint: String, originalBookNameHint: String,
shouldUseToc: Boolean, shouldUseToc: Boolean,
sourceFingerprint: String?,
book: EpubBook book: EpubBook
) { ) {
try { try {
@ -288,7 +305,8 @@ class EpubParser(private val context: Context) {
originalBookNameHint = originalBookNameHint, originalBookNameHint = originalBookNameHint,
parserVersion = EPUB_EXTRACTION_CACHE_VERSION, parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
parseContent = true, parseContent = true,
shouldUseToc = shouldUseToc shouldUseToc = shouldUseToc,
sourceFingerprint = sourceFingerprint
) )
) )
) )
@ -297,22 +315,40 @@ class EpubParser(private val context: Context) {
} }
} }
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> { internal fun extractEpubContents(
zipFile: ZipFile,
extractionDir: File,
parseContent: Boolean,
extractImagesForMetadata: Boolean
): Map<String, EpubFile> {
val filesMap = mutableMapOf<String, EpubFile>() val filesMap = mutableMapOf<String, EpubFile>()
zipFile.use { zf -> zipFile.use { zf ->
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry -> zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
val isEssential = isEssentialFile(entry.name, parseContent) val isEssential = isEssentialFile(entry.name, parseContent)
val isImage = entry.name.matches(Regex(".*\\.(png|jpg|jpeg|gif|webp|svg)$", RegexOption.IGNORE_CASE)) val isImage = isEpubImageFile(entry.name)
if (!parseContent) { if (!parseContent) {
if (isEssential || isImage) { when {
val data = zf.getInputStream(entry).readBytes() isEssential -> {
val data = zf.readSmallEntryBytes(entry) ?: return@forEach
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data) filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
} }
isImage && extractImagesForMetadata -> {
val outputFile = safeExtractionFile(extractionDir, entry.name)
?: return@forEach
outputFile.parentFile?.mkdirs()
zf.getInputStream(entry).use { input ->
FileOutputStream(outputFile).use { output ->
input.copyTo(output)
}
}
filesMap[entry.name] = EpubFile(absPath = entry.name, data = ByteArray(0))
}
}
return@forEach return@forEach
} }
val outputFile = File(extractionDir, entry.name) val outputFile = safeExtractionFile(extractionDir, entry.name) ?: return@forEach
outputFile.parentFile?.mkdirs() outputFile.parentFile?.mkdirs()
zf.getInputStream(entry).use { input -> zf.getInputStream(entry).use { input ->
FileOutputStream(outputFile).use { output -> FileOutputStream(outputFile).use { output ->
@ -329,6 +365,59 @@ class EpubParser(private val context: Context) {
return filesMap return filesMap
} }
private fun isEpubImageFile(fileName: String): Boolean {
val lowerName = fileName.lowercase()
return EPUB_IMAGE_EXTENSIONS.any { lowerName.endsWith(it) }
}
private fun safeExtractionFile(extractionDir: File, entryName: String): File? {
val outputFile = File(extractionDir, entryName)
val root = extractionDir.canonicalFile
val target = outputFile.canonicalFile
val rootPath = root.path
val targetPath = target.path
val isInsideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator)
if (!isInsideRoot) {
Timber.w("Skipping unsafe EPUB entry outside extraction root: $entryName")
return null
}
return outputFile
}
private fun ZipFile.readSmallEntryBytes(entry: ZipEntry): ByteArray? {
if (entry.size > MAX_METADATA_ENTRY_BYTES.toLong()) {
Timber.w("Skipping oversized EPUB metadata entry: ${entry.name} (${entry.size} bytes)")
return null
}
val initialSize = entry.size
.takeIf { it in 0..MAX_METADATA_ENTRY_BYTES.toLong() }
?.toInt()
?: DEFAULT_BUFFER_SIZE
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var totalBytes = 0
return getInputStream(entry).use { input ->
ByteArrayOutputStream(initialSize).use { output ->
while (true) {
val read = input.read(buffer)
if (read == -1) break
totalBytes += read
if (totalBytes > MAX_METADATA_ENTRY_BYTES) {
Timber.w("Skipping oversized EPUB metadata entry while reading: ${entry.name}")
return null
}
output.write(buffer, 0, read)
}
output.toByteArray()
}
}
}
private suspend fun parseAndCreateEbook( private suspend fun parseAndCreateEbook(
filesContentMap: Map<String, EpubFile>, filesContentMap: Map<String, EpubFile>,
document: EpubDocument, document: EpubDocument,
@ -712,8 +801,6 @@ class EpubParser(private val context: Context) {
filesContentMap: Map<String, EpubFile>, filesContentMap: Map<String, EpubFile>,
@Suppress("UNUSED_PARAMETER") extractionRoot: File @Suppress("UNUSED_PARAMETER") extractionRoot: File
): List<EpubImage> { ): List<EpubImage> {
val imageExtensions = setOf(".png", ".gif", ".jpg", ".jpeg", ".webp", ".svg")
val listedImages = manifestItems.values val listedImages = manifestItems.values
.filter { it.mediaType.startsWith("image/") } .filter { it.mediaType.startsWith("image/") }
.map { manifestItem -> .map { manifestItem ->
@ -725,7 +812,7 @@ class EpubParser(private val context: Context) {
val unlistedImages = filesContentMap.keys val unlistedImages = filesContentMap.keys
.filter { path -> .filter { path ->
val lowerPath = path.lowercase() val lowerPath = path.lowercase()
imageExtensions.any { lowerPath.endsWith(it) } && !listedPaths.contains(path) EPUB_IMAGE_EXTENSIONS.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
} }
.map { path -> .map { path ->
EpubImage(absPath = path) EpubImage(absPath = path)
@ -743,11 +830,9 @@ class EpubParser(private val context: Context) {
): Bitmap? { ): Bitmap? {
val coverManifestItem = manifestItems[metadataCoverId] val coverManifestItem = manifestItems[metadataCoverId]
if (coverManifestItem != null) { if (coverManifestItem != null) {
val coverImageBytes = filesContentMap[coverManifestItem.absPath]?.data?.takeIf { it.isNotEmpty() } val coverImage = decodeEpubImage(coverManifestItem.absPath, filesContentMap, extractionRoot)
?: File(extractionRoot, coverManifestItem.absPath).takeIf { it.exists() }?.readBytes() if (coverImage != null) {
return coverImage
if (coverImageBytes != null) {
return BitmapFactory.decodeByteArray(coverImageBytes, 0, coverImageBytes.size)
} else { } else {
Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}") Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}")
} }
@ -768,21 +853,15 @@ class EpubParser(private val context: Context) {
) )
for (path in possiblePaths) { for (path in possiblePaths) {
if (filesContentMap.containsKey(path)) { if (filesContentMap.containsKey(path)) {
val bytes = filesContentMap[path]?.data?.takeIf { it.isNotEmpty() } decodeEpubImage(path, filesContentMap, extractionRoot)?.let {
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
bytes?.let {
Timber.d("Found fallback cover image at $path") Timber.d("Found fallback cover image at $path")
return BitmapFactory.decodeByteArray(it, 0, it.size) return it
} }
} }
manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem -> manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem ->
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() } decodeEpubImage(manifestItem.absPath, filesContentMap, extractionRoot)?.let {
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
bytes?.let {
Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}") Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}")
return BitmapFactory.decodeByteArray(it, 0, it.size) return it
} }
} }
} }
@ -791,6 +870,53 @@ class EpubParser(private val context: Context) {
return null return null
} }
private fun decodeEpubImage(
path: String,
filesContentMap: Map<String, EpubFile>,
extractionRoot: File
): Bitmap? {
filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }?.let { bytes ->
return decodeSampledByteArray(bytes)
}
val imageFile = File(extractionRoot, path).takeIf { it.exists() && it.isFile } ?: return null
return decodeSampledFile(imageFile)
}
private fun decodeSampledByteArray(bytes: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val options = BitmapFactory.Options().apply {
inSampleSize = calculateBitmapSampleSize(bounds)
}
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}
private fun decodeSampledFile(file: File): Bitmap? {
val bounds = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, bounds)
val options = BitmapFactory.Options().apply {
inSampleSize = calculateBitmapSampleSize(bounds)
}
return BitmapFactory.decodeFile(file.absolutePath, options)
}
private fun calculateBitmapSampleSize(options: BitmapFactory.Options): Int {
val width = options.outWidth
val height = options.outHeight
if (width <= 0 || height <= 0) return 1
var sampleSize = 1
while ((width / sampleSize) > EPUB_COVER_MAX_DIMENSION || (height / sampleSize) > EPUB_COVER_MAX_DIMENSION) {
sampleSize *= 2
}
return sampleSize
}
private fun isEssentialFile(fileName: String, parseContent: Boolean): Boolean { private fun isEssentialFile(fileName: String, parseContent: Boolean): Boolean {
val lowerName = fileName.lowercase() val lowerName = fileName.lowercase()

View file

@ -29,6 +29,7 @@ import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
@ -90,6 +91,34 @@ import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV" private const val TAG_LINK_NAV = "LINK_NAV"
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
"PageInfoReporter",
"ProgressReporter",
"ContentBridge",
"HighlightBridge",
"AutoScrollBridge",
"CfiBridge",
"SnippetBridge",
"TtsBridge",
"AiBridge",
"FootnoteBridge",
"LinkNavBridge"
)
private fun WebView.releaseReaderResources() {
try {
stopLoading()
READER_WEB_VIEW_JS_INTERFACES.forEach { removeJavascriptInterface(it) }
webChromeClient = null
webViewClient = WebViewClient()
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
clearHistory()
removeAllViews()
destroy()
} catch (e: Exception) {
Timber.w(e, "Failed to fully release EPUB WebView resources")
}
}
private fun getFontCssInjection(): String { private fun getFontCssInjection(): String {
return """ return """
@ -314,12 +343,14 @@ class FootnoteJsBridge(
@Suppress("unused") @Suppress("unused")
class LinkNavJsBridge( class LinkNavJsBridge(
private val currentChapterTitle: String private val currentChapterTitle: String,
private val onInternalLinkClick: (String) -> Unit
) { ) {
@JavascriptInterface @JavascriptInterface
fun onLinkClicked(href: String, epubType: String, linkText: String) { fun onLinkClicked(href: String, epubType: String, linkText: String) {
Timber.tag(TAG_LINK_NAV) Timber.tag(TAG_LINK_NAV)
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'") .d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
onInternalLinkClick(href)
} }
} }
@ -384,6 +415,7 @@ fun ChapterWebView(
activeHighlightPalette: List<HighlightColor>, activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit, onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit, onInternalLinkClick: (String) -> Unit,
onWebViewDisposed: (WebView) -> Unit = {},
activeTextureId: String? = null, activeTextureId: String? = null,
activeTextureAlpha: Float = 0.55f activeTextureAlpha: Float = 0.55f
) { ) {
@ -554,7 +586,7 @@ fun ChapterWebView(
}, "AutoScrollBridge" }, "AutoScrollBridge"
) )
webChromeClient = object : android.webkit.WebChromeClient() { webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
consoleMessage?.let { consoleMessage?.let {
val message = it.message() val message = it.message()
@ -668,7 +700,9 @@ fun ChapterWebView(
) )
addJavascriptInterface( addJavascriptInterface(
LinkNavJsBridge(chapterTitle), "LinkNavBridge" LinkNavJsBridge(chapterTitle) { href ->
this.post { onInternalLinkClick(href) }
}, "LinkNavBridge"
) )
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
@ -744,6 +778,37 @@ fun ChapterWebView(
null null
) )
view?.evaluateJavascript(
"""
javascript:(function() {
if (window.__readerInternalLinkBridgeInstalled) return;
window.__readerInternalLinkBridgeInstalled = true;
document.addEventListener('click', function(event) {
var target = event.target;
var anchor = target && target.closest ? target.closest('a[href]') : null;
if (!anchor && target && target.parentElement && target.parentElement.closest) {
anchor = target.parentElement.closest('a[href]');
}
if (!anchor) return;
var rawHref = anchor.getAttribute('href') || '';
if (!rawHref) return;
if (/^(https?:|mailto:|tel:|javascript:)/i.test(rawHref)) return;
if (/^\/\//.test(rawHref)) return;
event.preventDefault();
var resolvedHref = anchor.href || rawHref;
if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) {
window.LinkNavBridge.onLinkClicked(
resolvedHref,
anchor.getAttribute('epub:type') || '',
anchor.textContent || ''
);
}
}, true);
})();
""".trimIndent(),
null
)
view?.evaluateJavascript( view?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.restoreHighlights('${ "javascript:window.HighlightBridgeHelper.restoreHighlights('${
escapeJsString( escapeJsString(
@ -907,10 +972,19 @@ fun ChapterWebView(
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null) loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
} }
webView webView
}, update = { webView -> },
Timber.d( modifier = Modifier.fillMaxSize(),
"WebView update. Setting Font: ${currentFontFamily.fontFamilyName}" onRelease = { releasedWebView ->
) if (localWebViewRef === releasedWebView) {
localWebViewRef = null
}
customMenuState?.finishActionModeCallback?.invoke()
customMenuState = null
onWebViewDisposed(releasedWebView)
releasedWebView.releaseReaderResources()
},
update = { webView ->
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
localWebViewRef = webView localWebViewRef = webView
onWebViewInstanceCreated(webView) onWebViewInstanceCreated(webView)
val fontCss = getFontCssInjection().replace("\n", " ") val fontCss = getFontCssInjection().replace("\n", " ")
@ -946,7 +1020,7 @@ fun ChapterWebView(
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", "javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
null null
) )
}, modifier = Modifier.fillMaxSize() }
) )
} }

View file

@ -72,6 +72,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
@ -88,6 +89,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.ScreenRotation
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
@ -104,6 +106,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@ -166,8 +169,9 @@ enum class ReaderTool(val title: String, val category: String) {
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"), PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"), VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"), AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"), TTS_SETTINGS("TTS Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu") TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
} }
@ -258,7 +262,8 @@ private val epubToolbarTools = setOf(
ReaderTool.FORMAT, ReaderTool.FORMAT,
ReaderTool.SEARCH, ReaderTool.SEARCH,
ReaderTool.AI_FEATURES, ReaderTool.AI_FEATURES,
ReaderTool.TTS_CONTROLS ReaderTool.TTS_CONTROLS,
ReaderTool.SCREEN_ORIENTATION
) )
@Composable @Composable
@ -272,6 +277,7 @@ fun EpubReaderTopBar(
tapToNavigateEnabled: Boolean, tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean, volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean, isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
isKeepScreenOn: Boolean, isKeepScreenOn: Boolean,
onToggleKeepScreenOn: (Boolean) -> Unit, onToggleKeepScreenOn: (Boolean) -> Unit,
@ -281,12 +287,14 @@ fun EpubReaderTopBar(
onToggleTapToNavigate: (Boolean) -> Unit, onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit, onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit, onTogglePageTurnAnimation: (Boolean) -> Unit,
onSetRightToLeftPagination: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit, onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit, onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit, onOpenTtsReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit, onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit, onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit, onOpenVisualOptions: () -> Unit,
onOpenScreenOrientation: () -> Unit,
onOpenSlider: () -> Unit, onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit, onToggleFormat: () -> Unit,
@ -414,17 +422,32 @@ fun EpubReaderTopBar(
tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
) )
} }
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onOpenScreenOrientation
) {
Icon(
Icons.Default.ScreenRotation,
contentDescription = stringResource(R.string.menu_screen_orientation),
tint = MaterialTheme.colorScheme.onSurface
)
}
else -> Unit else -> Unit
} }
} }
Box { Box {
var showMoreMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) }
var showHiddenToolsExpanded by remember { mutableStateOf(false) } var showHiddenToolsExpanded by remember { mutableStateOf(false) }
var showReadingModeExpanded by remember { mutableStateOf(false) }
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_more_options), text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc), description = stringResource(R.string.tooltip_more_options_desc),
onClick = { onClick = {
showHiddenToolsExpanded = false showHiddenToolsExpanded = false
showReadingModeExpanded = false
showTtsSettingsExpanded = false
showMoreMenu = true showMoreMenu = true
} }
) { ) {
@ -435,6 +458,8 @@ fun EpubReaderTopBar(
expanded = showMoreMenu, expanded = showMoreMenu,
onDismissRequest = { onDismissRequest = {
showHiddenToolsExpanded = false showHiddenToolsExpanded = false
showReadingModeExpanded = false
showTtsSettingsExpanded = false
showMoreMenu = false showMoreMenu = false
} }
) { ) {
@ -480,7 +505,8 @@ fun EpubReaderTopBar(
onToggleFormat = onToggleFormat, onToggleFormat = onToggleFormat,
onToggleSearch = onToggleSearch, onToggleSearch = onToggleSearch,
onOpenAiHub = onOpenAiHub, onOpenAiHub = onOpenAiHub,
onToggleTts = onToggleTts onToggleTts = onToggleTts,
onOpenScreenOrientation = onOpenScreenOrientation
) )
} }
} }
@ -527,6 +553,18 @@ fun EpubReaderTopBar(
} }
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) { if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
)
}
)
if (showReadingModeExpanded) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsActive, enabled = !isTtsActive,
@ -544,15 +582,35 @@ fun EpubReaderTopBar(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsActive, enabled = !isTtsActive,
onClick = { onClick = {
onSetRightToLeftPagination(false)
showMoreMenu = false showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED) onChangeRenderMode(RenderMode.PAGINATED)
}, },
trailingIcon = { trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED) Icon( if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
Icon(
Icons.Default.Check, Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected) contentDescription = stringResource(R.string.content_desc_selected)
) )
}
}) })
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
enabled = !isTtsActive,
onClick = {
onSetRightToLeftPagination(true)
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) {
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
}
})
}
HorizontalDivider() HorizontalDivider()
} }
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) { if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
@ -664,7 +722,29 @@ fun EpubReaderTopBar(
}) })
HorizontalDivider() HorizontalDivider()
} }
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) { val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)
val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
if (showTtsVoiceSettings || showTtsReplacements) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings)) },
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
)
}
)
if (showTtsSettingsExpanded) {
if (showTtsVoiceSettings) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsActive, enabled = !isTtsActive,
@ -680,9 +760,8 @@ fun EpubReaderTopBar(
) )
} }
) )
HorizontalDivider()
} }
if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) { if (showTtsReplacements) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = { onClick = {
@ -704,6 +783,91 @@ fun EpubReaderTopBar(
} }
} }
} }
}
}
}
@Composable
fun EpubJumpHistoryBar(
modifier: Modifier = Modifier,
showStandardBars: Boolean,
searchStateActive: Boolean,
backLabel: String?,
forwardLabel: String?,
onBack: () -> Unit,
onForward: () -> Unit,
onClear: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars && !searchStateActive && (backLabel != null || forwardLabel != null),
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = onBack,
enabled = backLabel != null,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_desc_jump_back),
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = backLabel.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(
onClick = onClear,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.action_clear),
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.action_clear), maxLines = 1)
}
TextButton(
onClick = onForward,
enabled = forwardLabel != null,
modifier = Modifier.weight(1f)
) {
Text(
text = forwardLabel.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(4.dp))
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = stringResource(R.string.content_desc_jump_forward),
modifier = Modifier.size(16.dp)
)
}
}
}
}
} }
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@ -723,6 +887,7 @@ fun EpubReaderBottomBar(
onOpenDictionarySettings: () -> Unit, onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit, onOpenThemeSettings: () -> Unit,
onToggleTts: () -> Unit, onToggleTts: () -> Unit,
onOpenScreenOrientation: () -> Unit,
hiddenTools: Set<String>, hiddenTools: Set<String>,
toolOrder: List<ReaderTool>, toolOrder: List<ReaderTool>,
bottomTools: Set<String>, bottomTools: Set<String>,
@ -835,6 +1000,16 @@ fun EpubReaderBottomBar(
tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
) )
} }
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onOpenScreenOrientation
) {
Icon(
imageVector = Icons.Default.ScreenRotation,
contentDescription = stringResource(R.string.menu_screen_orientation)
)
}
else -> Unit else -> Unit
} }
} }
@ -1849,6 +2024,7 @@ private fun ToolPreviewIcon(tool: ReaderTool) {
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp)) ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp)) ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp)) ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
} }
} }
@ -1866,7 +2042,8 @@ private fun HiddenEpubToolMenuItem(
onToggleFormat: () -> Unit, onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit, onToggleSearch: () -> Unit,
onOpenAiHub: () -> Unit, onOpenAiHub: () -> Unit,
onToggleTts: () -> Unit onToggleTts: () -> Unit,
onOpenScreenOrientation: () -> Unit
) { ) {
val enabled = when (tool) { val enabled = when (tool) {
ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
@ -1886,6 +2063,7 @@ private fun HiddenEpubToolMenuItem(
ReaderTool.SEARCH -> onToggleSearch() ReaderTool.SEARCH -> onToggleSearch()
ReaderTool.AI_FEATURES -> onOpenAiHub() ReaderTool.AI_FEATURES -> onOpenAiHub()
ReaderTool.TTS_CONTROLS -> onToggleTts() ReaderTool.TTS_CONTROLS -> onToggleTts()
ReaderTool.SCREEN_ORIENTATION -> onOpenScreenOrientation()
else -> Unit else -> Unit
} }
}, },

View file

@ -26,12 +26,13 @@ import android.view.GestureDetector
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ActionMode import android.view.ActionMode
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuInflater
import android.webkit.WebView import android.webkit.WebView
import android.graphics.Rect import android.graphics.Rect
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.View import android.view.View
import android.widget.PopupMenu
import org.json.JSONObject import org.json.JSONObject
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM } enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
@ -59,7 +60,143 @@ class InteractiveWebView(
private val scrollStopHandler = Handler(Looper.getMainLooper()) private val scrollStopHandler = Handler(Looper.getMainLooper())
private var scrollStopRunnable: Runnable? = null private var scrollStopRunnable: Runnable? = null
private var mCustomCallback: ActionMode.Callback? = null private var activeSelectionActionMode: ActionMode? = null
private fun clearPendingSelectionWork() {
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
scrollStopRunnable = null
}
private fun startLocalSelectionActionMode(): ActionMode {
activeSelectionActionMode?.let { existingMode ->
showCustomSelectionMenuFromCurrentSelection(existingMode)
return existingMode
}
lateinit var localMode: ActionMode
localMode = LocalSelectionActionMode(this) {
if (activeSelectionActionMode === localMode) {
activeSelectionActionMode = null
}
onHideCustomSelectionMenu()
}
activeSelectionActionMode = localMode
showCustomSelectionMenuFromCurrentSelection(localMode)
return localMode
}
private fun finishLocalSelectionActionMode() {
activeSelectionActionMode?.finish()
activeSelectionActionMode = null
}
private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) {
val jsToGetSelectionDetails = """
(function() {
var selection = window.getSelection();
var selectedText = selection.toString().trim();
if (selectedText.length === 0 || selection.rangeCount === 0) {
return null;
}
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
// If getBoundingClientRect returns all zeros, try getClientRects()
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
var clientRects = range.getClientRects();
if (clientRects.length > 0) {
rect = clientRects[0]; // Use the first rect
} else {
return null; // No valid rect found
}
}
// Ensure the rect has some dimension
if (rect.width === 0 && rect.height === 0) {
return null;
}
return JSON.stringify({
text: selectedText,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
});
})();
""".trimIndent()
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
if (activeSelectionActionMode !== mode) {
return@evaluateJavascript
}
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
mode.finish()
return@evaluateJavascript
}
try {
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
val selectionDetails = JSONObject(unquotedJsonResult)
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
Timber.d("CustomSelection: Selected text is blank after JS processing.")
mode.finish()
return@evaluateJavascript
}
val jsLeft = selectionDetails.getDouble("left")
val jsTop = selectionDetails.getDouble("top")
val jsRight = selectionDetails.getDouble("right")
val jsBottom = selectionDetails.getDouble("bottom")
val jsWidth = selectionDetails.getDouble("width")
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth == 0.0 && jsHeight == 0.0) {
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
mode.finish()
return@evaluateJavascript
}
val density = context.resources.displayMetrics.density
val webViewLocation = IntArray(2)
getLocationOnScreen(webViewLocation)
val webViewX = webViewLocation[0]
val webViewY = webViewLocation[1]
val selectionRectScreen = Rect(
(webViewX + jsLeft * density).toInt(),
(webViewY + jsTop * density).toInt(),
(webViewX + jsRight * density).toInt(),
(webViewY + jsBottom * density).toInt()
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
mode.finish()
return@evaluateJavascript
}
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
mode.finish()
}
}
}
private val gestureDetector = private val gestureDetector =
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
@ -167,148 +304,12 @@ class InteractiveWebView(
return super.onTouchEvent(event) return super.onTouchEvent(event)
} }
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
// selections use the app's Compose popup without starting the platform toolbar.
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? { override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
if (type == ActionMode.TYPE_FLOATING) { if (type == ActionMode.TYPE_FLOATING) {
if (mCustomCallback == null) { Timber.d("CustomSelection: handling floating action mode locally.")
mCustomCallback = object : ActionMode.Callback2() { return startLocalSelectionActionMode()
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onCreateActionMode")
menu.clear()
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onPrepareActionMode")
menu.clear()
val jsToGetSelectionDetails = """
(function() {
var selection = window.getSelection();
var selectedText = selection.toString().trim();
if (selectedText.length === 0 || selection.rangeCount === 0) {
return null;
}
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
// If getBoundingClientRect returns all zeros, try getClientRects()
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
var clientRects = range.getClientRects();
if (clientRects.length > 0) {
rect = clientRects[0]; // Use the first rect
} else {
return null; // No valid rect found
}
}
// Ensure the rect has some dimension
if (rect.width === 0 && rect.height === 0) {
return null;
}
return JSON.stringify({
text: selectedText,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
});
})();
""".trimIndent()
this@InteractiveWebView.evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
try {
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
val selectionDetails = JSONObject(unquotedJsonResult)
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
Timber.d("CustomSelection: Selected text is blank after JS processing.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val jsLeft = selectionDetails.getDouble("left")
val jsTop = selectionDetails.getDouble("top")
val jsRight = selectionDetails.getDouble("right")
val jsBottom = selectionDetails.getDouble("bottom")
val jsWidth = selectionDetails.getDouble("width")
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth == 0.0 && jsHeight == 0.0) {
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val density = context.resources.displayMetrics.density
val webViewLocation = IntArray(2)
this@InteractiveWebView.getLocationOnScreen(webViewLocation)
val webViewX = webViewLocation[0]
val webViewY = webViewLocation[1]
val selectionRectScreen = Rect(
(webViewX + jsLeft * density).toInt(),
(webViewY + jsTop * density).toInt(),
(webViewX + jsRight * density).toInt(),
(webViewY + jsBottom * density).toInt()
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
onHideCustomSelectionMenu()
mode.finish()
}
}
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
Timber.d("CustomSelection: onActionItemClicked (should not be called as menu is empty)")
return false
}
override fun onDestroyActionMode(mode: ActionMode) {
Timber.d("CustomSelection: onDestroyActionMode for mode: $mode")
onHideCustomSelectionMenu()
}
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
super.onGetContentRect(mode, view, outRect)
Timber.d("CustomSelection: onGetContentRect called by system. outRect: $outRect")
}
}
}
return super.startActionMode(mCustomCallback, type)
} }
return super.startActionMode(originalCallback, type) return super.startActionMode(originalCallback, type)
} }
@ -316,18 +317,79 @@ class InteractiveWebView(
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt) super.onScrollChanged(l, t, oldl, oldt)
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) } clearPendingSelectionWork()
scrollStopRunnable = Runnable { scrollStopRunnable = Runnable {
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result -> evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
val selectedText = result?.removeSurrounding("\"") val selectedText = result?.removeSurrounding("\"")
if (!selectedText.isNullOrBlank()) { if (!selectedText.isNullOrBlank()) {
Timber.d("Selection exists after scroll. Restarting action mode.") Timber.d("Selection exists after scroll. Restarting action mode.")
mCustomCallback?.let { startLocalSelectionActionMode()
startActionMode(it, ActionMode.TYPE_FLOATING)
}
} }
} }
} }
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) } scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
} }
override fun onDetachedFromWindow() {
clearPendingSelectionWork()
finishLocalSelectionActionMode()
super.onDetachedFromWindow()
}
override fun destroy() {
clearPendingSelectionWork()
finishLocalSelectionActionMode()
super.destroy()
}
private class LocalSelectionActionMode(
anchorView: View,
private val onFinished: () -> Unit
) : ActionMode() {
private val modeContext = anchorView.context
private val menu: Menu = PopupMenu(modeContext, anchorView).menu
private val menuInflater = MenuInflater(modeContext)
private var title: CharSequence? = null
private var subtitle: CharSequence? = null
private var customView: View? = null
private var finished = false
override fun setTitle(title: CharSequence?) {
this.title = title
}
override fun setTitle(resId: Int) {
title = modeContext.getText(resId)
}
override fun setSubtitle(subtitle: CharSequence?) {
this.subtitle = subtitle
}
override fun setSubtitle(resId: Int) {
subtitle = modeContext.getText(resId)
}
override fun setCustomView(view: View?) {
customView = view
}
override fun invalidate() = Unit
override fun finish() {
if (finished) return
finished = true
onFinished()
}
override fun getMenu(): Menu = menu
override fun getTitle(): CharSequence? = title
override fun getSubtitle(): CharSequence? = subtitle
override fun getCustomView(): View? = customView
override fun getMenuInflater(): MenuInflater = menuInflater
}
} }

View file

@ -59,6 +59,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@ -76,6 +78,7 @@ private const val PRIORITY_HIGHEST = 0
private const val PRIORITY_HIGH = 1 private const val PRIORITY_HIGH = 1
private const val PRIORITY_MEDIUM = 2 private const val PRIORITY_MEDIUM = 2
private const val PRIORITY_LOW = 3 private const val PRIORITY_LOW = 3
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
data class TimedWord(val word: String, val startTime: Double, val startOffset: Int) data class TimedWord(val word: String, val startTime: Double, val startOffset: Int)
@ -187,6 +190,7 @@ class BookPaginator(
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>() private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>() private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>() private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
private var paginationWorker: Job? = null private var paginationWorker: Job? = null
@ -430,9 +434,9 @@ class BookPaginator(
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.") Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
null null
} else { } else {
pageCache.put(chapterIndex, pages)
applyPageRuntimeIndexes(chapterIndex, pages) applyPageRuntimeIndexes(chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size) updatePageCountsOnMain(chapterIndex, pages.size)
pageCache.put(chapterIndex, pages)
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.") Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
pages pages
} }
@ -574,8 +578,86 @@ class BookPaginator(
} }
} }
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<Page>? {
if (chapterIndex !in chapters.indices) {
Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.")
return null
}
pageCache[chapterIndex]?.let {
Timber.tag(TAG_STABLE_PAGE_NAV)
.d("ensure_chapter hit_memory chapter=$chapterIndex pages=${it.size}")
return it
}
val lock = chapterPaginationLocks.computeIfAbsent(chapterIndex) { Mutex() }
return lock.withLock {
pageCache[chapterIndex]?.also {
Timber.tag(TAG_STABLE_PAGE_NAV)
.d("ensure_chapter hit_after_wait chapter=$chapterIndex pages=${it.size}")
} ?: run {
Timber.tag(TAG_STABLE_PAGE_NAV)
.d("ensure_chapter paginate chapter=$chapterIndex finalized=${chapterIndex in finalizedChapterCounts}")
paginateChapter(chapterIndex)
}
}
}
private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}"
)
return resolveStableChapterStartPage(
chapterIndex = chapterIndex,
chapterCount = chapters.size,
pageCountsAreAccurate = pageCountsAreAccurate,
chapterStartPage = { chapterStartPageIndices[it] },
isChapterFinalized = { it in finalizedChapterCounts },
ensureChapterPaginated = { ensureChapterPaginated(it) != null }
)
}
suspend fun findStableChapterStartPage(chapterIndex: Int): Int? = withContext(Dispatchers.IO) {
val stableStart = ensureStableStartPageForChapter(chapterIndex) ?: return@withContext null
val targetPages = ensureChapterPaginated(chapterIndex) ?: return@withContext null
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"stable_chapter_start resolved chapter=$chapterIndex page=$stableStart targetPages=${targetPages.size}"
)
stableStart.takeIf { targetPages.isNotEmpty() }
}
suspend fun findStablePageForLocator(locator: Locator): Int? = withContext(Dispatchers.IO) {
val targetChapterIndex = locator.chapterIndex
Timber.tag("POS_DIAG").d("findStablePageForLocator: Searching for $locator")
Timber.tag(TAG_STABLE_PAGE_NAV).d("stable_locator request locator=$locator")
val chapterPages = ensureChapterPaginated(targetChapterIndex)
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
Timber.tag("POS_DIAG").d(
"findStablePageForLocator: targetChapterIndex=$targetChapterIndex, stableStart=$chapterStartPage, chapterPages.size=${chapterPages?.size}"
)
if (chapterPages.isNullOrEmpty() || chapterStartPage == null) {
Timber.e("Stable locator navigation failed: Could not stabilize target chapter $targetChapterIndex.")
return@withContext null
}
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
Timber.tag("POS_DIAG").e("findStablePageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
return@withContext null
}
val finalPageIndex = chapterStartPage + pageInChapter
Timber.tag("POS_DIAG").i("findStablePageForLocator: FOUND absolute page $finalPageIndex")
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"stable_locator resolved locator=$locator page=$finalPageIndex chapterStart=$chapterStartPage pageInChapter=$pageInChapter"
)
finalPageIndex
}
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? { suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex) val pages = ensureChapterPaginated(chapterIndex)
if (pages.isNullOrEmpty()) { if (pages.isNullOrEmpty()) {
Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.") Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.")
return null return null
@ -794,7 +876,7 @@ class BookPaginator(
} }
Timber.i("Worker: Starting pagination for chapter $chapterIndex.") Timber.i("Worker: Starting pagination for chapter $chapterIndex.")
val pages = paginateChapter(chapterIndex) val pages = ensureChapterPaginated(chapterIndex)
if (pages != null) { if (pages != null) {
Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.") Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.")
@ -836,6 +918,9 @@ class BookPaginator(
val difference = actualPageCount - estimatedPageCount val difference = actualPageCount - estimatedPageCount
if (difference == 0) { if (difference == 0) {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
)
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) { if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
} }
@ -854,8 +939,16 @@ class BookPaginator(
} }
rebuildChapterStartSnapshot() rebuildChapterStartSnapshot()
if (chapterIndex < currentUserChapterIndex.value) { val currentUserChapter = currentUserChapterIndex.value
pageShiftRequest.tryEmit(difference) val shouldShiftCurrentPage = chapterIndex < currentUserChapter
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"page_count_update chapter=$chapterIndex estimated=$estimatedPageCount actual=$actualPageCount diff=$difference currentUserChapter=$currentUserChapter shiftCurrent=$shouldShiftCurrentPage total=$totalPageCount"
)
if (shouldShiftCurrentPage) {
val emitted = pageShiftRequest.tryEmit(difference)
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"page_shift_emit chapter=$chapterIndex diff=$difference currentUserChapter=$currentUserChapter emitted=$emitted"
)
} }
} }
@ -1028,13 +1121,12 @@ class BookPaginator(
) )
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.") Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
applyPageRuntimeIndexes(chapterIndex, pages) applyPageRuntimeIndexes(chapterIndex, pages)
savePageCacheAsync(chapter, chapterIndex, pages) savePageCacheAsync(chapter, chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size) updatePageCountsOnMain(chapterIndex, pages.size)
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
return pages return pages
} }
@ -1105,26 +1197,11 @@ class BookPaginator(
return Jsoup.parse(htmlToParse).body().text() return Jsoup.parse(htmlToParse).body().text()
} }
private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int { suspend fun findStablePageForAnchor(chapterIndex: Int, anchor: String?): Int? = withContext(Dispatchers.IO) {
if (targetChapterIndex <= 0) { Timber.tag("TOC_NAV_DEBUG").d("Stable precision nav request for anchor: '$anchor'")
return 0
}
val startIndex = chapterStartPageIndices[targetChapterIndex] ?: 0
return startIndex
}
override fun findPageForAnchor(
chapterIndex: Int,
anchor: String?,
onResult: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
Timber.tag("TOC_NAV_DEBUG").d("Precision nav request for anchor: '$anchor'")
if (anchor.isNullOrBlank()) { if (anchor.isNullOrBlank()) {
val start = chapterStartPageIndices[chapterIndex] ?: 0 return@withContext findStableChapterStartPage(chapterIndex)
withContext(Dispatchers.Main) { onResult(start) }
return@launch
} }
// 1. QUICK LOOKUP: Check the Anchor Index first // 1. QUICK LOOKUP: Check the Anchor Index first
@ -1139,12 +1216,12 @@ class BookPaginator(
} }
// 2. ENSURE PAGINATION: Get pages for the determined chapter // 2. ENSURE PAGINATION: Get pages for the determined chapter
val chapterPages = pageCache[targetChapter] ?: paginateChapter(targetChapter) val chapterPages = ensureChapterPaginated(targetChapter)
val chapterStartPage = chapterStartPageIndices[targetChapter] ?: 0 val chapterStartPage = ensureStableStartPageForChapter(targetChapter)
if (chapterPages == null) { if (chapterPages == null || chapterStartPage == null) {
withContext(Dispatchers.Main) { onResult(chapterStartPage) } Timber.e("Anchor navigation failed: Could not stabilize target chapter $targetChapter.")
return@launch return@withContext null
} }
val indexedPageInChapter = targetBlock?.let { blockIndex -> val indexedPageInChapter = targetBlock?.let { blockIndex ->
@ -1156,8 +1233,7 @@ class BookPaginator(
if (indexedPageInChapter != null) { if (indexedPageInChapter != null) {
val finalPage = chapterStartPage + indexedPageInChapter val finalPage = chapterStartPage + indexedPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage") Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
withContext(Dispatchers.Main) { onResult(finalPage) } return@withContext finalPage
return@launch
} }
// 3. FIND PAGE // 3. FIND PAGE
@ -1182,7 +1258,17 @@ class BookPaginator(
val finalPage = chapterStartPage + targetPageInChapter val finalPage = chapterStartPage + targetPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)") Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
withContext(Dispatchers.Main) { onResult(finalPage) } finalPage
}
override fun findPageForAnchor(
chapterIndex: Int,
anchor: String?,
onResult: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
withContext(Dispatchers.Main) { onResult(page) }
} }
} }
@ -1234,35 +1320,39 @@ class BookPaginator(
onNavigationComplete: (pageIndex: Int) -> Unit onNavigationComplete: (pageIndex: Int) -> Unit
) { ) {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
}
}
suspend fun findStablePageForHref(currentChapterAbsPath: String, href: String): Int? = withContext(Dispatchers.IO) {
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'") Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href) val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
if (targetChapterPath == null) { if (targetChapterPath == null) {
Timber.w("Could not resolve href '$href' to a valid chapter path.") Timber.w("Could not resolve href '$href' to a valid chapter path.")
return@launch return@withContext null
} }
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath } val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
if (targetChapterIndex == -1) { if (targetChapterIndex == -1) {
Timber.w("Could not find chapter for path: $targetChapterPath") Timber.w("Could not find chapter for path: $targetChapterPath")
return@launch return@withContext null
} }
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete) findStablePageForAnchor(targetChapterIndex, anchor)
}
} }
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) { suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
coroutineScope.launch(Dispatchers.IO) {
val targetChapterIndex = result.locationInSource val targetChapterIndex = result.locationInSource
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex") Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) val chapterPages = ensureChapterPaginated(targetChapterIndex)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex) val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
if (chapterPages == null) { if (chapterPages == null || chapterStartPage == null) {
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.") Timber.e("Search result navigation failed: Could not stabilize target chapter $targetChapterIndex.")
return@launch return@withContext null
} }
var targetPageInChapter = 0 var targetPageInChapter = 0
@ -1296,7 +1386,13 @@ class BookPaginator(
} }
val finalPageIndex = chapterStartPage + targetPageInChapter val finalPageIndex = chapterStartPage + targetPageInChapter
Timber.i("Search result found. Final page index: $finalPageIndex") Timber.i("Search result found. Final page index: $finalPageIndex")
withContext(Dispatchers.Main) { onResult(finalPageIndex) } finalPageIndex
}
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
coroutineScope.launch(Dispatchers.IO) {
val page = findStablePageForSearchResult(result) ?: return@launch
withContext(Dispatchers.Main) { onResult(page) }
} }
} }
@ -1331,20 +1427,8 @@ class BookPaginator(
} }
} }
suspend fun findPageForLocator(locator: Locator): Int? { private fun findPageInChapterForLocator(locator: Locator, chapterPages: List<Page>): Int? {
val targetChapterIndex = locator.chapterIndex val targetChapterIndex = locator.chapterIndex
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
return null
}
chapterTextRangeIndex[targetChapterIndex] chapterTextRangeIndex[targetChapterIndex]
?.firstOrNull { range -> ?.firstOrNull { range ->
range.blockIndex == locator.blockIndex && range.blockIndex == locator.blockIndex &&
@ -1352,17 +1436,15 @@ class BookPaginator(
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset)) (range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
} }
?.let { range -> ?.let { range ->
val finalPageIndex = chapterStartPage + range.pageInChapter Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND via runtime index on pageInChapter ${range.pageInChapter}")
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex") return range.pageInChapter
return finalPageIndex
} }
chapterPageNavigationIndex[targetChapterIndex] chapterPageNavigationIndex[targetChapterIndex]
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex } ?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.let { entry -> ?.let { entry ->
val finalPageIndex = chapterStartPage + entry.pageInChapter Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Using block-range fallback pageInChapter ${entry.pageInChapter}")
Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex") return entry.pageInChapter
return finalPageIndex
} }
var fallbackPageInChapter = -1 var fallbackPageInChapter = -1
@ -1370,7 +1452,7 @@ class BookPaginator(
for ((pageIndex, page) in chapterPages.withIndex()) { for ((pageIndex, page) in chapterPages.withIndex()) {
val allTextBlocks = getAllTextBlocks(page.content) val allTextBlocks = getAllTextBlocks(page.content)
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) { if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})") Timber.tag("POS_DIAG").d("findPageInChapterForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex")
} }
for (textBlock in allTextBlocks) { for (textBlock in allTextBlocks) {
if (textBlock.blockIndex == locator.blockIndex) { if (textBlock.blockIndex == locator.blockIndex) {
@ -1384,15 +1466,13 @@ class BookPaginator(
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
if (isInside) { if (isInside) {
val finalPageIndex = chapterStartPage + pageIndex Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND match on pageInChapter $pageIndex")
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex") return pageIndex
return finalPageIndex
} }
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) { if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
val finalPageIndex = chapterStartPage + pageIndex Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND empty block match on pageInChapter $pageIndex")
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex") return pageIndex
return finalPageIndex
} }
} }
} }
@ -1408,15 +1488,37 @@ class BookPaginator(
} }
if (fallbackPageInChapter != -1) { if (fallbackPageInChapter != -1) {
val finalPageIndex = chapterStartPage + fallbackPageInChapter Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Exact offset not found, using block-start fallback pageInChapter $fallbackPageInChapter")
Timber.tag("POS_DIAG").w("findPageForLocator: Exact offset not found, using block-start fallback page $finalPageIndex") return fallbackPageInChapter
return finalPageIndex
} }
return null
}
suspend fun findPageForLocator(locator: Locator): Int? {
val targetChapterIndex = locator.chapterIndex
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
val chapterPages = ensureChapterPaginated(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
return null
}
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex") Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
return null return null
} }
val finalPageIndex = chapterStartPage + pageInChapter
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND absolute page $finalPageIndex")
return finalPageIndex
}
fun getLocatorForPage(pageIndex: Int): Locator? { fun getLocatorForPage(pageIndex: Int): Locator? {
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
val chStart = chapterStartPageIndices[chapterIndex] ?: 0 val chStart = chapterStartPageIndices[chapterIndex] ?: 0
@ -1472,13 +1574,12 @@ class BookPaginator(
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'") Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex) val chapterPages = ensureChapterPaginated(chapterIndex)
val chapterStartPage = calculateAccurateStartIndex(chapterIndex) val chapterStartPage = ensureStableStartPageForChapter(chapterIndex)
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.") Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
if (chapterPages == null) { if (chapterPages == null || chapterStartPage == null) {
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.") Timber.e("CFI Navigation failed: Could not stabilize target chapter $chapterIndex.")
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
return@launch return@launch
} }
Timber.d("findPageForCfi: Chapter $chapterIndex has ${chapterPages.size} pages.") Timber.d("findPageForCfi: Chapter $chapterIndex has ${chapterPages.size} pages.")

View file

@ -433,6 +433,14 @@ class ContentStyler(
baselineShift = baselineShift baselineShift = baselineShift
) )
if (!span.linkHref.isNullOrBlank()) {
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
}
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null || val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
themedSpanStyle.textDecorationColor.isSpecified || themedSpanStyle.textDecorationColor.isSpecified ||
themedSpanStyle.textUnderlineOffset.isSpecified themedSpanStyle.textUnderlineOffset.isSpecified

View file

@ -29,7 +29,6 @@ import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@ -44,6 +43,8 @@ class MathMLRenderer(private val context: Context) {
private var webView: WebView? = null private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@Volatile
private var isDestroyed = false
private var isMathJaxReady = false private var isMathJaxReady = false
private val readySignal = CompletableDeferred<Boolean>() private val readySignal = CompletableDeferred<Boolean>()
@ -59,11 +60,14 @@ class MathMLRenderer(private val context: Context) {
init { init {
handler.post { handler.post {
if (!isDestroyed) {
setupWebView() setupWebView()
} }
} }
}
suspend fun awaitReady(): Boolean { suspend fun awaitReady(): Boolean {
if (isDestroyed) return false
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...") Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
return withTimeoutOrNull(10_000) { return withTimeoutOrNull(10_000) {
readySignal.await() readySignal.await()
@ -76,9 +80,7 @@ class MathMLRenderer(private val context: Context) {
private fun setupWebView() { private fun setupWebView() {
try { try {
if (BuildConfig.DEBUG) { if (isDestroyed) return
WebView.setWebContentsDebuggingEnabled(true)
}
webView = WebView(context).apply { webView = WebView(context).apply {
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@ -114,6 +116,9 @@ class MathMLRenderer(private val context: Context) {
} }
suspend fun render(mathML: String, originalAltText: String): RenderResult { suspend fun render(mathML: String, originalAltText: String): RenderResult {
if (isDestroyed) {
return RenderResult.Failure(originalAltText)
}
if (!awaitReady()) { if (!awaitReady()) {
Timber.e("WebView is not available or failed to initialize. Failing render.") Timber.e("WebView is not available or failed to initialize. Failing render.")
return RenderResult.Failure(originalAltText) return RenderResult.Failure(originalAltText)
@ -140,6 +145,10 @@ class MathMLRenderer(private val context: Context) {
} }
private fun processNextJob() { private fun processNextJob() {
if (isDestroyed) {
isProcessing = false
return
}
synchronized(jobQueue) { synchronized(jobQueue) {
if (jobQueue.isEmpty()) { if (jobQueue.isEmpty()) {
isProcessing = false isProcessing = false
@ -153,6 +162,10 @@ class MathMLRenderer(private val context: Context) {
} }
private fun executeRender() { private fun executeRender() {
if (isDestroyed) {
isProcessing = false
return
}
if (!isMathJaxReady) { if (!isMathJaxReady) {
Timber.d("executeRender called but MathJax not ready yet. Retrying...") Timber.d("executeRender called but MathJax not ready yet. Retrying...")
handler.postDelayed({ executeRender() }, 100) handler.postDelayed({ executeRender() }, 100)
@ -208,14 +221,43 @@ class MathMLRenderer(private val context: Context) {
} }
fun destroy() { fun destroy() {
isDestroyed = true
if (!readySignal.isCompleted) {
readySignal.complete(false)
}
val pendingJobs = synchronized(jobQueue) {
val copy = jobQueue.toList()
jobQueue.clear()
isProcessing = false
copy
}
pendingJobs.forEach { job ->
job.continuation(RenderResult.Failure(extractAltText(job.mathML)))
}
handler.removeCallbacksAndMessages(null)
handler.post { handler.post {
webView?.destroy() webView?.releaseMathRendererResources()
webView = null webView = null
Timber.d("MathMLRenderer WebView destroyed.") Timber.d("MathMLRenderer WebView destroyed.")
} }
synchronized(jobQueue) { }
jobQueue.clear()
isProcessing = false private fun extractAltText(mathML: String): String =
mathML.substringAfter("alttext=\"", "").substringBefore("\"")
.ifBlank { "MathML rendering failed" }
private fun WebView.releaseMathRendererResources() {
try {
stopLoading()
removeJavascriptInterface("AndroidBridge")
webChromeClient = null
webViewClient = WebViewClient()
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
clearHistory()
removeAllViews()
destroy()
} catch (e: Exception) {
Timber.w(e, "Failed to fully release MathML WebView resources")
} }
} }
@ -229,7 +271,7 @@ class MathMLRenderer(private val context: Context) {
} else { } else {
Timber.e("onSvgReady FAILURE. Received empty SVG.") Timber.e("onSvgReady FAILURE. Received empty SVG.")
val job = synchronized(jobQueue) { jobQueue.firstOrNull() } val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed" val altText = job?.mathML?.let(::extractAltText) ?: "MathML rendering failed"
completeCurrentJob(RenderResult.Failure(altText)) completeCurrentJob(RenderResult.Failure(altText))
} }
} }

View file

@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -253,6 +254,8 @@ private fun headerFontScale(level: Int): Float = when (level) {
} }
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float { private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
@ -410,6 +413,30 @@ internal object CfiUtils {
fun getPath(cfi: String): String = cfi.split(':').first() fun getPath(cfi: String): String = cfi.split(':').first()
fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0 fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0
fun getOffsetOrNull(cfi: String): Int? = cfi.substringAfter(':', "").toIntOrNull()
fun isPathStrictlyBetween(candidate: String, start: String, end: String): Boolean {
val candidateParts = pathParts(candidate) ?: return false
val startParts = pathParts(start) ?: return false
val endParts = pathParts(end) ?: return false
return comparePathParts(candidateParts, startParts) > 0 &&
comparePathParts(candidateParts, endParts) < 0
}
private fun pathParts(cfi: String): List<Int>? {
val segments = getPath(cfi).split('/').filter { it.isNotEmpty() }
if (segments.isEmpty()) return null
return segments.map { it.toIntOrNull() ?: return null }
}
private fun comparePathParts(first: List<Int>, second: List<Int>): Int {
val length = minOf(first.size, second.size)
for (index in 0 until length) {
val cmp = first[index].compareTo(second[index])
if (cmp != 0) return cmp
}
return first.size.compareTo(second.size)
}
} }
private fun highlightQueryInText( private fun highlightQueryInText(
@ -729,6 +756,7 @@ fun PaginatedReaderScreen(
effectiveText: Color, effectiveText: Color,
pagerState: PagerState, pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean, isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean = false,
searchQuery: String, searchQuery: String,
fontSizeMultiplier: Float, fontSizeMultiplier: Float,
lineHeightMultiplier: Float, lineHeightMultiplier: Float,
@ -741,6 +769,9 @@ fun PaginatedReaderScreen(
ttsHighlightInfo: TtsHighlightInfo?, ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?, initialChapterIndexInBook: Int?,
fallbackLocatorForReconfiguration: Locator? = null, fallbackLocatorForReconfiguration: Locator? = null,
explicitNavigationAnchor: Locator? = null,
explicitNavigationEpoch: Long = 0L,
isExternalNavigationInProgress: Boolean = false,
onReconfigurationAnchorCaptured: (Locator) -> Unit = {}, onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {}, onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
onPaginatorReady: (IPaginator) -> Unit, onPaginatorReady: (IPaginator) -> Unit,
@ -754,6 +785,7 @@ fun PaginatedReaderScreen(
onStartTtsFromSelection: (String, Int) -> Unit, onStartTtsFromSelection: (String, Int) -> Unit,
onNoteRequested: (String?) -> Unit, onNoteRequested: (String?) -> Unit,
onFootnoteRequested: (String) -> Unit, onFootnoteRequested: (String) -> Unit,
onInternalLinkNavigated: (Int) -> Unit = {},
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
@ -784,6 +816,11 @@ fun PaginatedReaderScreen(
} else Modifier } else Modifier
var isNavigatingByLink by remember { mutableStateOf(false) } var isNavigatingByLink by remember { mutableStateOf(false) }
var localExplicitNavigationAnchor by remember { mutableStateOf<Locator?>(null) }
var localExplicitNavigationEpoch by remember { mutableLongStateOf(0L) }
val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor)
val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch)
val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress)
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) { BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator, pagerState) { LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount -> paginator.pageShiftRequest.collect { shiftAmount ->
val anchor = resolvePaginatedReconfigurationAnchor( if (pagerState.pageCount <= 0) {
currentPageLocator = anchorLocatorForReconfig, Timber.tag(TAG_STABLE_PAGE_NAV)
fallbackLocator = latestFallbackLocatorForReconfiguration .w("shift_drop reason=emptyPager shift=$shiftAmount")
return@collect
}
val bookPaginator = paginator as? BookPaginator
val currentPageBeforeShift = pagerState.currentPage
val now = System.currentTimeMillis()
val externalAgeMs = if (latestExternalNavigationEpoch > 0L) {
now - latestExternalNavigationEpoch
} else {
-1L
}
val localAgeMs = if (localExplicitNavigationEpoch > 0L) {
now - localExplicitNavigationEpoch
} else {
-1L
}
val recentExternalNavigation =
externalAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
val recentLocalNavigation =
localAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
val activeExplicitAnchor = when {
latestIsExternalNavigationInProgress -> latestExternalNavigationAnchor
isNavigatingByLink -> localExplicitNavigationAnchor
else -> null
}
val recentExplicitAnchor = when {
recentExternalNavigation -> latestExternalNavigationAnchor
recentLocalNavigation -> localExplicitNavigationAnchor
else -> null
}
val activeExplicitAnchorSource = when {
activeExplicitAnchor == null -> null
latestIsExternalNavigationInProgress -> "explicit_external_active"
else -> "explicit_link"
}
val recentExplicitAnchorSource = when {
recentExplicitAnchor == null -> null
recentExternalNavigation -> "explicit_external_recent"
else -> "explicit_link_recent"
}
val currentPageLocator = bookPaginator?.getLocatorForPage(currentPageBeforeShift)
val fallbackLocator = latestFallbackLocatorForReconfiguration
var anchorSource = "none"
val anchor = when {
anchorLocatorForReconfig != null -> {
anchorSource = "reconfiguration"
anchorLocatorForReconfig
}
activeExplicitAnchor != null -> {
anchorSource = activeExplicitAnchorSource ?: "explicit_active"
activeExplicitAnchor
}
fallbackLocator != null -> {
anchorSource = "last_known"
fallbackLocator
}
recentExplicitAnchor != null -> {
anchorSource = recentExplicitAnchorSource ?: "explicit_recent"
recentExplicitAnchor
}
currentPageLocator != null -> {
anchorSource = "current_page"
currentPageLocator
}
else -> null
}
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"shift_received shift=$shiftAmount currentPage=$currentPageBeforeShift anchorSource=$anchorSource anchor=$anchor currentLocator=$currentPageLocator fallback=$fallbackLocator externalInProgress=$latestIsExternalNavigationInProgress linkInProgress=$isNavigatingByLink externalAgeMs=$externalAgeMs localAgeMs=$localAgeMs"
) )
val resolvedPage = anchor?.let { locator -> val resolvedPage = anchor?.let { locator ->
(paginator as? BookPaginator)?.findPageForLocator(locator) bookPaginator?.findStablePageForLocator(locator)
} }
if (resolvedPage != null) { if (resolvedPage != null) {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"shift_apply_stable shift=$shiftAmount from=$currentPageBeforeShift to=$resolvedPage anchorSource=$anchorSource anchor=$anchor"
)
pagerState.scrollToPage(resolvedPage) pagerState.scrollToPage(resolvedPage)
paginator.onUserScrolledTo(resolvedPage) paginator.onUserScrolledTo(resolvedPage)
} else { } else {
val newPage = pagerState.currentPage + shiftAmount val maxPage = (pagerState.pageCount - 1).coerceAtLeast(0)
val newPage = (currentPageBeforeShift + shiftAmount).coerceIn(0, maxPage)
Timber.tag(TAG_STABLE_PAGE_NAV).w(
"shift_apply_relative shift=$shiftAmount from=$currentPageBeforeShift to=$newPage anchorSource=$anchorSource anchor=$anchor"
)
pagerState.scrollToPage(newPage) pagerState.scrollToPage(newPage)
paginator.onUserScrolledTo(newPage) paginator.onUserScrolledTo(newPage)
} }
@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen(
uiState = uiState, uiState = uiState,
pagerState = pagerState, pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
isRightToLeftPagination = isRightToLeftPagination,
effectiveBg = effectiveBg, effectiveBg = effectiveBg,
searchQuery = searchQuery, searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo, ttsHighlightInfo = ttsHighlightInfo,
@ -1163,9 +1278,11 @@ fun PaginatedReaderScreen(
} }
} }
}, },
onInternalLinkNavigated = onInternalLinkNavigated,
onLinkClick = { currentChapterPath, href, onNavComplete -> onLinkClick = { currentChapterPath, href, onNavComplete ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
isNavigatingByLink = true withContext(Dispatchers.Main) { isNavigatingByLink = true }
try {
var isFootnote = false var isFootnote = false
var footnoteHtml: String? = null var footnoteHtml: String? = null
@ -1185,17 +1302,18 @@ fun PaginatedReaderScreen(
val safeHref = href.replace("\"", "\\\"") val safeHref = href.replace("\"", "\\\"")
val aTag = doc.select("a[href=\"$safeHref\"]").first() val aTag = doc.select("a[href=\"$safeHref\"]").first()
if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) { val linkType = aTag?.attr("epub:type").orEmpty()
val linkRole = aTag?.attr("role").orEmpty()
if (
linkType.contains("noteref", ignoreCase = true) ||
linkRole.contains("doc-noteref", ignoreCase = true)
) {
isFootnote = true isFootnote = true
} }
} else if (href.startsWith("#")) {
isFootnote = true
} }
} else if (href.startsWith("#")) {
isFootnote = true
} }
if (isFootnote) { run {
val decodedHref = try { val decodedHref = try {
URLDecoder.decode(href, "UTF-8") URLDecoder.decode(href, "UTF-8")
} catch (_: Exception) { } catch (_: Exception) {
@ -1239,6 +1357,14 @@ fun PaginatedReaderScreen(
val doc = Jsoup.parse(targetHtml) val doc = Jsoup.parse(targetHtml)
val noteEl = doc.getElementById(anchor) val noteEl = doc.getElementById(anchor)
if (noteEl != null) { if (noteEl != null) {
val targetType = noteEl.attr("epub:type")
val targetRole = noteEl.attr("role")
val targetClass = noteEl.className()
val targetLooksLikeFootnote =
targetType.contains("footnote", ignoreCase = true) ||
targetRole.contains("doc-footnote", ignoreCase = true) ||
targetClass.contains("footnote", ignoreCase = true)
if (isFootnote || targetLooksLikeFootnote) {
footnoteHtml = noteEl.html() footnoteHtml = noteEl.html()
} }
} }
@ -1246,18 +1372,33 @@ fun PaginatedReaderScreen(
} }
} }
} }
}
withContext(Dispatchers.Main) {
if (!footnoteHtml.isNullOrBlank()) { if (!footnoteHtml.isNullOrBlank()) {
onFootnoteRequested(footnoteHtml) withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
isNavigatingByLink = false
} else { } else {
paginator.navigateToHref(currentChapterPath, href) { val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href)
onNavComplete(it) withContext(Dispatchers.Main) {
isNavigatingByLink = false if (targetPage != null) {
val targetAnchor = (paginator as? BookPaginator)?.getLocatorForPage(targetPage)
val navigationEpoch = System.currentTimeMillis()
localExplicitNavigationAnchor = targetAnchor
localExplicitNavigationEpoch = navigationEpoch
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch"
)
paginator.onUserScrolledTo(targetPage)
onNavComplete(targetPage)
} else {
Timber.tag(TAG_STABLE_PAGE_NAV).w(
"link_failed href=$href currentChapterPath=$currentChapterPath"
)
} }
} }
} }
} finally {
withContext(Dispatchers.Main) { isNavigatingByLink = false }
}
} }
}, },
onTap = onTap, onTap = onTap,
@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean =
return null return null
} }
private fun getHighlightOffsetsInBlock( internal fun getHighlightOffsetsInBlock(
block: TextContentBlock, highlight: UserHighlight block: TextContentBlock, highlight: UserHighlight
): IntRange? { ): IntRange? {
if (block.cfi == null) return null if (block.cfi == null) return null
@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock(
val parts = highlight.cfi.split('|') val parts = highlight.cfi.split('|')
val startCfi = parts.firstOrNull() ?: highlight.cfi val startCfi = parts.firstOrNull() ?: highlight.cfi
val endCfi = parts.lastOrNull() val endCfi = parts.lastOrNull()
val isMultipartHighlight = endCfi != null && endCfi != startCfi
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) { @Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
is ParagraphBlock -> block.startCharOffsetInSource is ParagraphBlock -> block.startCharOffsetInSource
@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock(
is ListItemBlock -> block.startCharOffsetInSource is ListItemBlock -> block.startCharOffsetInSource
else -> 0 else -> 0
} }
val blockEndAbs = block.endCharOffsetInSource
.takeIf { it > blockStartAbs }
?: (blockStartAbs + block.content.text.length)
Timber.d( Timber.d(
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}" "getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
@ -1419,48 +1564,28 @@ private fun getHighlightOffsetsInBlock(
) )
} }
var isAfterStart = false
var isBeforeEnd = true
if (relevantPart == null) {
if (startCfi.isNotEmpty()) {
try {
if (CfiUtils.compare(block.cfi!!, startCfi) > 0) {
isAfterStart = true
}
} catch (_: Exception) {
}
}
if (endCfi != null && endCfi != startCfi) {
try {
val endPath = CfiUtils.getPath(endCfi)
val cmp = CfiUtils.compare(blockPath, endPath)
Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp")
if (CfiUtils.compare(blockPath, endPath) > 0) {
isBeforeEnd = false
}
} catch (_: Exception) {
}
}
}
Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd")
if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) {
return null
}
val blockText = block.content.text val blockText = block.content.text
val highlightText = highlight.text val highlightText = highlight.text
if (blockText.isEmpty() || highlightText.isEmpty()) return null if (blockText.isEmpty() || highlightText.isEmpty()) return null
val isIntermediateBlock = relevantPart == null &&
isMultipartHighlight &&
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
if (relevantPart == null) {
if (!isIntermediateBlock) return null
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
val normBlock = blockText.filter { !it.isWhitespace() }
var startIndex = blockText.indexOf(highlightText, ignoreCase = false) val normHighlight = highlightText.filter { !it.isWhitespace() }
if (startIndex == -1) { return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
startIndex = blockText.indexOf(highlightText, ignoreCase = true) 0 until blockText.length
} else {
null
}
} }
if (relevantPart != null) { if (relevantPart != null) {
@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock(
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches") Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
if (startMatches || endMatches) { if (startMatches || endMatches) {
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
if (startMatches && endMatches && startAbs != null && endAbs != null) {
val rangeStartAbs = minOf(startAbs, endAbs)
val rangeEndAbs = maxOf(startAbs, endAbs)
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
Timber.d(
" -> Skipping same-path split block outside highlight offsets. " +
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
)
return null
}
} else {
if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null
if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null
}
var s = 0 var s = 0
var e = blockText.length var e = blockText.length
if (startMatches) { if (startMatches) {
val absOffset = CfiUtils.getOffset(startCfi) val absOffset = startAbs ?: CfiUtils.getOffset(startCfi)
val relOffset = absOffset - blockStartAbs val relOffset = absOffset - blockStartAbs
if (relOffset < 0) { if (relOffset < 0) {
@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock(
} }
if (endMatches) { if (endMatches) {
val absOffset = CfiUtils.getOffset(endCfi!!) val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
val relOffset = absOffset - blockStartAbs val relOffset = absOffset - blockStartAbs
Timber.d( Timber.d(
@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock(
} }
} }
if (startIndex >= 0) { if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
return startIndex until (startIndex + highlightText.length) if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
if (startIndex == -1) {
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
} }
if (relevantPart == null) { if (startIndex >= 0) {
@Suppress("KotlinConstantConditions") if (isAfterStart) { return startIndex until (startIndex + highlightText.length)
val normBlock = blockText.filter { !it.isWhitespace() }
val normHighlight = highlightText.filter { !it.isWhitespace() }
if (normHighlight.contains(normBlock, ignoreCase = true)) {
return 0 until blockText.length
}
}
} }
val match = findFuzzyMatch(blockText, highlightText) val match = findFuzzyMatch(blockText, highlightText)
@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState, uiState: PaginatedReaderUiState,
pagerState: PagerState, pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean, isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean = false,
effectiveBg: Color, effectiveBg: Color,
effectiveText: Color, effectiveText: Color,
searchQuery: String, searchQuery: String,
@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent(
onGetPage: (Int) -> Page?, onGetPage: (Int) -> Page?,
onGetChapterPath: (Int) -> String?, onGetChapterPath: (Int) -> String?,
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit, onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
onInternalLinkNavigated: (Int) -> Unit,
onTap: (Offset?) -> Unit, onTap: (Offset?) -> Unit,
isProUser: Boolean, isProUser: Boolean,
isOss: Boolean, isOss: Boolean,
@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent(
} }
} }
}, },
beyondViewportPageCount = 1 beyondViewportPageCount = 1,
reverseLayout = isRightToLeftPagination
) { pageIndex -> ) { pageIndex ->
val pageOffset = val pageOffset =
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent(
} else { } else {
currentChapterPath?.let { path -> currentChapterPath?.let { path ->
onLinkClick(path, href) { targetPageIndex -> onLinkClick(path, href) { targetPageIndex ->
onInternalLinkNavigated(targetPageIndex)
coroutineScope.launch { coroutineScope.launch {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}"
)
pagerState.scrollToPage(targetPageIndex) pagerState.scrollToPage(targetPageIndex)
} }
} }

View file

@ -0,0 +1,93 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import kotlin.math.abs
internal fun SpanStyle.withReaderLinkStyle(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): SpanStyle {
val linkStyle = readerLinkSpanStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
existingDecoration = textDecoration
)
return copy(
color = linkStyle.color,
background = linkStyle.background,
textDecoration = linkStyle.textDecoration
)
}
internal fun readerLinkSpanStyle(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color,
existingDecoration: TextDecoration? = null
): SpanStyle {
val background = themeBackgroundColor.takeIf { it.isSpecified }
?: if (isDarkTheme) Color.Black else Color.White
val text = themeTextColor.takeIf { it.isSpecified }
?: if (isDarkTheme) Color.White else Color.Black
val linkColor = readerLinkColorForTheme(isDarkTheme, background, text)
val backgroundAlpha = if (background.safeLuminance() < 0.45f) 0.24f else 0.16f
return SpanStyle(
color = linkColor,
background = linkColor.copy(alpha = backgroundAlpha),
textDecoration = existingDecoration.withUnderline()
)
}
private fun readerLinkColorForTheme(
isDarkTheme: Boolean,
background: Color,
text: Color
): Color {
val backgroundLuminance = background.safeLuminance()
val textLuminance = text.safeLuminance()
val candidates = if (isDarkTheme || backgroundLuminance < 0.45f) {
listOf(
Color(0xFF7DD3FC),
Color(0xFF5EEAD4),
Color(0xFFA5B4FC),
Color(0xFFFDE68A),
Color.White
)
} else {
listOf(
Color(0xFF005FCC),
Color(0xFF006D75),
Color(0xFF7A1E52),
Color(0xFF4A148C),
Color(0xFF111827)
)
}
return candidates.firstOrNull {
it.contrastRatio(background) >= 4.5f && abs(it.safeLuminance() - textLuminance) >= 0.08f
} ?: candidates.maxByOrNull { it.contrastRatio(background) }
?: if (isDarkTheme) Color(0xFF7DD3FC) else Color(0xFF005FCC)
}
private fun TextDecoration?.withUnderline(): TextDecoration {
val current = this ?: TextDecoration.None
val decorations = mutableListOf<TextDecoration>()
if (current.contains(TextDecoration.LineThrough)) decorations += TextDecoration.LineThrough
decorations += TextDecoration.Underline
return TextDecoration.combine(decorations)
}
private fun Color.contrastRatio(other: Color): Float {
val lighter = maxOf(safeLuminance(), other.safeLuminance())
val darker = minOf(safeLuminance(), other.safeLuminance())
return (lighter + 0.05f) / (darker + 0.05f)
}
private fun Color.safeLuminance(): Float {
return if (isSpecified) luminance() else 0f
}

View file

@ -118,6 +118,17 @@ private fun AnnotatedString.applyReaderThemeForDisplay(
} }
addStringAnnotation(range.tag, item, range.start, range.end) addStringAnnotation(range.tag, item, range.start, range.end)
} }
this@applyReaderThemeForDisplay.getStringAnnotations("URL", 0, this@applyReaderThemeForDisplay.length).forEach { range ->
addStyle(
readerLinkSpanStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
range.start,
range.end
)
}
} }
} }

View file

@ -0,0 +1,23 @@
package com.aryan.reader.paginatedreader
internal suspend fun resolveStableChapterStartPage(
chapterIndex: Int,
chapterCount: Int,
pageCountsAreAccurate: Boolean,
chapterStartPage: (Int) -> Int?,
isChapterFinalized: (Int) -> Boolean,
ensureChapterPaginated: suspend (Int) -> Boolean
): Int? {
if (chapterIndex !in 0 until chapterCount) return null
if (!pageCountsAreAccurate) {
for (prefixChapter in 0 until chapterIndex) {
if (!isChapterFinalized(prefixChapter)) {
val ready = ensureChapterPaginated(prefixChapter)
if (!ready) return null
}
}
}
return chapterStartPage(chapterIndex) ?: if (chapterIndex == 0) 0 else null
}

View file

@ -20,6 +20,7 @@
package com.aryan.reader.pdf package com.aryan.reader.pdf
import android.graphics.Rect import android.graphics.Rect
import android.graphics.RectF
import timber.log.Timber import timber.log.Timber
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
@ -43,17 +45,114 @@ import androidx.compose.ui.unit.dp
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
internal data class MagnifierContentSource(
val sourceWidth: Int,
val sourceHeight: Int,
val contentLeft: Float,
val contentTop: Float,
val contentWidth: Float,
val contentHeight: Float
) {
val scaleX: Float
get() = if (contentWidth > 0f) sourceWidth.toFloat() / contentWidth else 1f
val scaleY: Float
get() = if (contentHeight > 0f) sourceHeight.toFloat() / contentHeight else 1f
fun sourceX(contentX: Float): Float = (contentX - contentLeft) * scaleX
fun sourceY(contentY: Float): Float = (contentY - contentTop) * scaleY
}
internal data class MagnifierSampleGeometry(
val srcLeft: Int,
val srcTop: Int,
val srcWidth: Int,
val srcHeight: Int,
val outputScaleX: Float,
val outputScaleY: Float
)
internal fun calculateMagnifierSampleGeometry(
centerContentX: Float,
centerContentY: Float,
contentSource: MagnifierContentSource,
magnifierWidthPx: Float,
magnifierHeightPx: Float,
zoomFactor: Float
): MagnifierSampleGeometry? {
if (
contentSource.sourceWidth <= 0 ||
contentSource.sourceHeight <= 0 ||
contentSource.contentWidth <= 0f ||
contentSource.contentHeight <= 0f ||
magnifierWidthPx <= 0f ||
magnifierHeightPx <= 0f ||
zoomFactor <= 0f
) {
return null
}
val sourceCenterX = contentSource.sourceX(centerContentX)
val sourceCenterY = contentSource.sourceY(centerContentY)
val sourceRectWidth = (magnifierWidthPx / zoomFactor * contentSource.scaleX).coerceAtLeast(1f)
val sourceRectHeight = (magnifierHeightPx / zoomFactor * contentSource.scaleY).coerceAtLeast(1f)
val maxSrcLeft = max(0f, contentSource.sourceWidth.toFloat() - sourceRectWidth)
val maxSrcTop = max(0f, contentSource.sourceHeight.toFloat() - sourceRectHeight)
val srcLeft = (sourceCenterX - sourceRectWidth / 2f).coerceIn(0f, maxSrcLeft)
val srcTop = (sourceCenterY - sourceRectHeight / 2f).coerceIn(0f, maxSrcTop)
val srcLeftInt = srcLeft.roundToInt().coerceIn(0, contentSource.sourceWidth - 1)
val srcTopInt = srcTop.roundToInt().coerceIn(0, contentSource.sourceHeight - 1)
val srcWidthInt = (contentSource.sourceWidth - srcLeftInt)
.coerceAtMost(sourceRectWidth.roundToInt().coerceAtLeast(1))
.coerceAtLeast(1)
val srcHeightInt = (contentSource.sourceHeight - srcTopInt)
.coerceAtMost(sourceRectHeight.roundToInt().coerceAtLeast(1))
.coerceAtLeast(1)
return MagnifierSampleGeometry(
srcLeft = srcLeftInt,
srcTop = srcTopInt,
srcWidth = srcWidthInt,
srcHeight = srcHeightInt,
outputScaleX = magnifierWidthPx / srcWidthInt,
outputScaleY = magnifierHeightPx / srcHeightInt
)
}
internal fun mapContentRectToMagnifier(
contentRect: Rect,
contentSource: MagnifierContentSource,
sample: MagnifierSampleGeometry
): RectF {
val sourceLeft = contentSource.sourceX(contentRect.left.toFloat())
val sourceTop = contentSource.sourceY(contentRect.top.toFloat())
val sourceRight = contentSource.sourceX(contentRect.right.toFloat())
val sourceBottom = contentSource.sourceY(contentRect.bottom.toFloat())
return RectF(
(sourceLeft - sample.srcLeft) * sample.outputScaleX,
(sourceTop - sample.srcTop) * sample.outputScaleY,
(sourceRight - sample.srcLeft) * sample.outputScaleX,
(sourceBottom - sample.srcTop) * sample.outputScaleY
)
}
@Composable @Composable
fun MagnifierComposable( fun MagnifierComposable(
sourceBitmap: ImageBitmap, sourceBitmap: ImageBitmap,
tiles: List<PdfTile>, tiles: List<PdfTile>,
currentScale: Float, currentScale: Float,
magnifierCenterOnBitmap: Offset, magnifierCenterOnBitmap: Offset,
contentWidthPx: Int = sourceBitmap.width,
contentHeightPx: Int = sourceBitmap.height,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
magnifierWidth: Dp = 120.dp, magnifierWidth: Dp = 120.dp,
magnifierHeight: Dp = 60.dp, magnifierHeight: Dp = 60.dp,
zoomFactor: Float = 1.5f, zoomFactor: Float = 1.5f,
selectionRectsInBitmapCoords: List<Rect>, selectionRectsInContentCoords: List<Rect>,
highlightColor: Color, highlightColor: Color,
colorFilter: ColorFilter? = null colorFilter: ColorFilter? = null
) { ) {
@ -81,157 +180,73 @@ fun MagnifierComposable(
} }
} else null } else null
if (relevantTile != null) { val bitmapToUse: ImageBitmap
// --- HIGH-RES TILE PATH --- val contentSource: MagnifierContentSource
if (relevantTile != null && !relevantTile.bitmap.isRecycled) {
Timber.d("Magnifier: Using HIGH-RES TILE path.") Timber.d("Magnifier: Using HIGH-RES TILE path.")
Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}") Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}")
val bitmapToUse = relevantTile.bitmap.asImageBitmap() bitmapToUse = relevantTile.bitmap.asImageBitmap()
contentSource = MagnifierContentSource(
val tileBitmapWidth = relevantTile.bitmap.width.toFloat() sourceWidth = bitmapToUse.width,
val tileRenderRectWidth = relevantTile.renderRect.width().toFloat() sourceHeight = bitmapToUse.height,
contentLeft = relevantTile.renderRect.left.toFloat(),
val tileScale = if (tileRenderRectWidth > 0) { contentTop = relevantTile.renderRect.top.toFloat(),
tileBitmapWidth / tileRenderRectWidth contentWidth = relevantTile.renderRect.width().toFloat(),
} else { contentHeight = relevantTile.renderRect.height().toFloat()
1f
}
Timber.d("Magnifier: Using derived tileScale=$tileScale instead of parent's currentScale=$currentScale")
val centerInTileBitmap = Offset(
x = (magnifierCenterOnBitmap.x - relevantTile.renderRect.left) * tileScale,
y = (magnifierCenterOnBitmap.y - relevantTile.renderRect.top) * tileScale
) )
} else {
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
bitmapToUse = sourceBitmap
contentSource = MagnifierContentSource(
sourceWidth = sourceBitmap.width,
sourceHeight = sourceBitmap.height,
contentLeft = 0f,
contentTop = 0f,
contentWidth = contentWidthPx.toFloat(),
contentHeight = contentHeightPx.toFloat()
)
}
Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap") val sample = calculateMagnifierSampleGeometry(
centerContentX = magnifierCenterOnBitmap.x,
val sourceRectWidth = magnifierWidthPx / zoomFactor centerContentY = magnifierCenterOnBitmap.y,
val sourceRectHeight = magnifierHeightPx / zoomFactor contentSource = contentSource,
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight") magnifierWidthPx = magnifierWidthPx,
magnifierHeightPx = magnifierHeightPx,
val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f) zoomFactor = zoomFactor
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f) ) ?: run {
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)") Timber.w("Magnifier: Source geometry is invalid, returning.")
val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
val finalSrcTopInt = clampedSrcTop.roundToInt()
val finalSrcWidthInt = (bitmapToUse.width - finalSrcLeftInt)
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
val finalSrcHeightInt = (bitmapToUse.height - finalSrcTopInt)
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
Timber.d("Magnifier: Final source rect to draw from tile: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= bitmapToUse.width || finalSrcTopInt >= bitmapToUse.height) {
Timber.w("Magnifier: Final source rect is invalid, returning.")
return@Canvas return@Canvas
} }
Timber.d("Magnifier: Final source rect offset=(${sample.srcLeft}, ${sample.srcTop}), size=${sample.srcWidth}x${sample.srcHeight}")
drawImage( drawImage(
image = bitmapToUse, image = bitmapToUse,
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt), srcOffset = IntOffset(sample.srcLeft, sample.srcTop),
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt), srcSize = IntSize(sample.srcWidth, sample.srcHeight),
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()), dstSize = IntSize(
magnifierWidthPx.roundToInt().coerceAtLeast(1),
magnifierHeightPx.roundToInt().coerceAtLeast(1)
),
colorFilter = colorFilter colorFilter = colorFilter
) )
selectionRectsInBitmapCoords.forEach { rectInBitmap -> selectionRectsInContentCoords.forEach { contentRect ->
val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale val magnifierRect = mapContentRectToMagnifier(contentRect, contentSource, sample)
val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale if (magnifierRect.width() > 0f && magnifierRect.height() > 0f &&
val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale magnifierRect.right > 0f && magnifierRect.left < magnifierWidthPx &&
val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale magnifierRect.bottom > 0f && magnifierRect.top < magnifierHeightPx
) {
val finalLeft = translatedLeft - clampedSrcLeft
val finalTop = translatedTop - clampedSrcTop
val finalRight = translatedRight - clampedSrcLeft
val finalBottom = translatedBottom - clampedSrcTop
val magnifiedLeft = finalLeft * zoomFactor
val magnifiedTop = finalTop * zoomFactor
val magnifiedRight = finalRight * zoomFactor
val magnifiedBottom = finalBottom * zoomFactor
if (magnifiedRight > 0 && magnifiedLeft < magnifierWidthPx && magnifiedBottom > 0 && magnifiedTop < magnifierHeightPx) {
drawRect( drawRect(
color = highlightColor, color = highlightColor,
topLeft = Offset(magnifiedLeft, magnifiedTop), topLeft = Offset(magnifierRect.left, magnifierRect.top),
size = androidx.compose.ui.geometry.Size( size = Size(
width = magnifiedRight - magnifiedLeft, width = magnifierRect.width(),
height = magnifiedBottom - magnifiedTop height = magnifierRect.height()
) )
) )
} }
} }
} else {
// --- LOW-RES / NO-ZOOM PATH ---
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
val sourceRectWidth = magnifierWidthPx / zoomFactor
val sourceRectHeight = magnifierHeightPx / zoomFactor
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
val srcLeft = (magnifierCenterOnBitmap.x - sourceRectWidth / 2f)
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
val finalSrcTopInt = clampedSrcTop.roundToInt()
val finalSrcWidthInt = (sourceBitmap.width - finalSrcLeftInt)
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
val finalSrcHeightInt = (sourceBitmap.height - finalSrcTopInt)
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
Timber.d("Magnifier: Final source rect to draw from base: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= sourceBitmap.width || finalSrcTopInt >= sourceBitmap.height) {
Timber.w("Magnifier: Final source rect is invalid, returning.")
return@Canvas
}
drawImage(
image = sourceBitmap,
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
colorFilter = colorFilter
)
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
val translatedLeft = rectInBitmap.left - clampedSrcLeft
val translatedTop = rectInBitmap.top - clampedSrcTop
val rectWidthInBitmap = rectInBitmap.width().toFloat()
val rectHeightInBitmap = rectInBitmap.height().toFloat()
val magnifiedLeft = translatedLeft * zoomFactor
val magnifiedTop = translatedTop * zoomFactor
val magnifiedWidth = rectWidthInBitmap * zoomFactor
val magnifiedHeight = rectHeightInBitmap * zoomFactor
if (magnifiedLeft + magnifiedWidth > 0 && magnifiedLeft < magnifierWidthPx &&
magnifiedTop + magnifiedHeight > 0 && magnifiedTop < magnifierHeightPx) {
drawRect(
color = highlightColor,
topLeft = Offset(magnifiedLeft, magnifiedTop),
size = androidx.compose.ui.geometry.Size(
width = magnifiedWidth,
height = magnifiedHeight
)
)
}
}
}
} }
} }
} }

View file

@ -0,0 +1,24 @@
package com.aryan.reader.pdf
internal const val PDF_BUBBLE_PREFETCH_RADIUS = 1
internal fun buildPdfBubblePrefetchOrder(
currentPage: Int,
totalPages: Int,
radius: Int = PDF_BUBBLE_PREFETCH_RADIUS
): List<Int> {
if (totalPages <= 0 || radius < 0) return emptyList()
val clampedCurrentPage = currentPage.coerceIn(0, totalPages - 1)
val ordered = LinkedHashSet<Int>()
ordered += clampedCurrentPage
for (distance in 1..radius) {
val next = clampedCurrentPage + distance
val previous = clampedCurrentPage - distance
if (next in 0 until totalPages) ordered += next
if (previous in 0 until totalPages) ordered += previous
}
return ordered.toList()
}

View file

@ -21,8 +21,14 @@ import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlin.random.Random import kotlin.random.Random
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
object PdfiumCoreProvider { object PdfiumCoreProvider {
val core: PdfiumCoreKt by lazy { val core: PdfiumCoreKt by lazy {
PdfiumCoreKt(Dispatchers.Default) PdfiumCoreKt(Dispatchers.Default)
@ -31,7 +37,7 @@ object PdfiumCoreProvider {
internal data class DocumentCacheItem( internal data class DocumentCacheItem(
val doc: ReaderDocument, val doc: ReaderDocument,
val pfd: ParcelFileDescriptor, val pfd: ParcelFileDescriptor?,
val totalPages: Int, val totalPages: Int,
val pageAspectRatios: List<Float>, val pageAspectRatios: List<Float>,
val flatTableOfContents: List<TocEntry> val flatTableOfContents: List<TocEntry>
@ -48,7 +54,7 @@ internal class DocumentCache(val maxSize: Int = 3) {
if (evicted) { if (evicted) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) } try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) } try { oldValue.pfd?.close() } catch (e: Exception) { Timber.e(e) }
} }
} }
} }
@ -156,14 +162,34 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
page = doc.openPage(pageIndex) page = doc.openPage(pageIndex)
if (page == null) return@withContext null if (page == null) return@withContext null
val bitmapWidth = 1080 val pageWidth = page.getPageWidthPoint()
val pageHeight = page.getPageHeightPoint()
if (pageWidth <= 0 || pageHeight <= 0) {
Timber.e("Invalid page size for page $pageIndex: ${pageWidth}x${pageHeight}")
return@withContext null
}
val aspectRatio = val aspectRatio =
page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat() pageWidth.toFloat() / pageHeight.toFloat()
if (aspectRatio.isNaN() || aspectRatio <= 0) { if (aspectRatio.isNaN() || aspectRatio <= 0) {
Timber.e("Invalid aspect ratio for page $pageIndex") Timber.e("Invalid aspect ratio for page $pageIndex")
return@withContext null return@withContext null
} }
val bitmapHeight = (bitmapWidth / aspectRatio).toInt()
var bitmapWidth = PDF_PREVIEW_MAX_WIDTH_PX
var bitmapHeight = (bitmapWidth / aspectRatio).roundToInt()
if (bitmapHeight > PDF_PREVIEW_MAX_HEIGHT_PX) {
bitmapHeight = PDF_PREVIEW_MAX_HEIGHT_PX
bitmapWidth = (bitmapHeight * aspectRatio).roundToInt().coerceAtLeast(1)
}
val requestedBytes = bitmapWidth.toLong() * bitmapHeight.toLong() * 4L
if (requestedBytes > PDF_PREVIEW_MAX_BYTES) {
val scale = sqrt(PDF_PREVIEW_MAX_BYTES.toDouble() / requestedBytes.toDouble())
bitmapWidth = (bitmapWidth * scale).roundToInt().coerceAtLeast(1)
bitmapHeight = (bitmapHeight * scale).roundToInt().coerceAtLeast(1)
}
if (bitmapHeight <= 0) { if (bitmapHeight <= 0) {
Timber.e("Invalid calculated bitmap height for page $pageIndex") Timber.e("Invalid calculated bitmap height for page $pageIndex")

View file

@ -76,6 +76,7 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import timber.log.Timber import timber.log.Timber
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import com.aryan.reader.pdf.data.VirtualPage
private const val MAX_FIXED_RECURSION = 128 private const val MAX_FIXED_RECURSION = 128
@ -281,6 +282,7 @@ internal fun PdfTocTreeItem(
@Composable @Composable
internal fun PdfNavigationDrawerContent( internal fun PdfNavigationDrawerContent(
pdfDocument: ReaderDocument?, pdfDocument: ReaderDocument?,
documentKey: String,
flatTableOfContents: List<TocEntry>, flatTableOfContents: List<TocEntry>,
bookmarks: Set<PdfBookmark>, bookmarks: Set<PdfBookmark>,
userHighlights: List<PdfUserHighlight>, userHighlights: List<PdfUserHighlight>,
@ -859,13 +861,16 @@ internal fun PdfNavigationDrawerContent(
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) } val thumbPageId = remember(documentKey, pageIdx) {
pdfRenderPageId(documentKey, pageIdx, VirtualPage.PdfPage(pageIdx))
}
var thumb by remember(thumbPageId) { mutableStateOf(PdfThumbnailCache.get(thumbPageId)) }
LaunchedEffect(pageIdx, pdfDocument) { LaunchedEffect(thumbPageId, pdfDocument) {
if (thumb == null && pdfDocument != null) { if (thumb == null && pdfDocument != null) {
withContext(kotlinx.coroutines.Dispatchers.IO) { withContext(kotlinx.coroutines.Dispatchers.IO) {
try { try {
val cached = PdfThumbnailCache.get(pageIdx) val cached = PdfThumbnailCache.get(thumbPageId)
if (cached != null) { if (cached != null) {
thumb = cached thumb = cached
} else { } else {
@ -878,7 +883,7 @@ internal fun PdfNavigationDrawerContent(
val bmp = createBitmap(thumbW, thumbH) val bmp = createBitmap(thumbW, thumbH)
bmp.eraseColor(android.graphics.Color.WHITE) bmp.eraseColor(android.graphics.Color.WHITE)
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false) p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
PdfThumbnailCache.put(pageIdx, bmp) PdfThumbnailCache.put(thumbPageId, bmp)
thumb = bmp thumb = bmp
} }
} }

View file

@ -123,6 +123,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set import androidx.core.graphics.set
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.isCanvasSafeBitmap
import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
@ -135,7 +136,6 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
@ -171,6 +171,47 @@ enum class InkType {
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
} }
internal fun shouldReportPdfPageCamera(
isZoomEnabled: Boolean,
isVerticalScroll: Boolean,
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?,
hasAppliedLockedState: Boolean
): Boolean {
return !isZoomEnabled ||
isVerticalScroll ||
!isScrollLocked ||
lockedState == null ||
hasAppliedLockedState
}
internal fun initialPdfPageCamera(
isZoomEnabled: Boolean,
isVerticalScroll: Boolean,
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?
): Pair<Float, Offset> {
return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) {
lockedState.first to Offset(lockedState.second, lockedState.third)
} else {
1f to Offset.Zero
}
}
internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
isBubbleZoomModeActive: Boolean,
scale: Float,
isVerticalScroll: Boolean,
isZoomEnabled: Boolean,
isScrollLocked: Boolean
): Boolean {
return !isBubbleZoomModeActive &&
scale > 1f &&
!isVerticalScroll &&
isZoomEnabled &&
!isScrollLocked
}
data class EmbeddedAnnotation( data class EmbeddedAnnotation(
val index: Int, val index: Int,
val subtype: Int, val subtype: Int,
@ -186,8 +227,25 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f) data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String {
val sourcePageId = when (virtualPage) {
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
null -> "PDF_$pageIndex"
}
return "$documentKey:$sourcePageId"
}
private fun Throwable.readablePdfErrorDetail(): String {
return localizedMessage?.takeIf { it.isNotBlank() }
?: javaClass.simpleName.takeIf { it.isNotBlank() }
?: "Unknown error"
}
private const val PDF_TILE_SIZE_DP = 256 private const val PDF_TILE_SIZE_DP = 256
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072 private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
@ -255,17 +313,22 @@ private suspend fun renderExpandedBubbleBitmap(
} }
document.openPage(pageIndex)?.use { page -> document.openPage(pageIndex)?.use { page ->
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1) val safeRenderScale = safePdfBitmapRenderScale(
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1) contentWidth = bubbleBounds.width(),
contentHeight = bubbleBounds.height(),
requestedScale = renderScale
)
val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1)
val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1)
val bitmap = createBitmap(cropWidth, cropHeight) val bitmap = createBitmap(cropWidth, cropHeight)
try { try {
page.renderPageBitmap( page.renderPageBitmap(
bitmap = bitmap, bitmap = bitmap,
startX = (-bubbleBounds.left * renderScale).roundToInt(), startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
startY = (-bubbleBounds.top * renderScale).roundToInt(), startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth), drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight), drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight),
renderAnnot = true renderAnnot = true
) )
bitmap bitmap
@ -277,6 +340,23 @@ private suspend fun renderExpandedBubbleBitmap(
} }
} }
private fun safePdfBitmapRenderScale(
contentWidth: Float,
contentHeight: Float,
requestedScale: Float
): Float {
if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f
val requestedWidth = contentWidth * requestedScale
val requestedHeight = contentHeight * requestedScale
val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0
val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0))
val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() /
max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0)
val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat()
}
object PdfInkGeometry { object PdfInkGeometry {
fun calculateFountainPenPoints( fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
@ -380,16 +460,15 @@ internal object PdfBitmapPool {
fun get(size: Int): Bitmap = get(size, size) fun get(size: Int): Bitmap = get(size, size)
fun recycle(bitmap: Bitmap) { fun recycle(bitmap: Bitmap) {
// Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps.
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) { if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
pool.offer(bitmap) pool.offer(bitmap)
} else {
bitmap.recycle()
} }
} }
fun clear() { fun clear() {
while (!pool.isEmpty()) { while (!pool.isEmpty()) {
pool.poll()?.recycle() pool.poll()
} }
} }
} }
@ -400,20 +479,20 @@ internal object PdfThumbnailCache {
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int) private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
private val memoryCache = object : LruCache<Int, CacheEntry>(cacheSize) { private val memoryCache = object : LruCache<String, CacheEntry>(cacheSize) {
override fun sizeOf(key: Int, entry: CacheEntry): Int { override fun sizeOf(key: String, entry: CacheEntry): Int {
return entry.sizeKb return entry.sizeKb
} }
} }
fun get(pageIndex: Int): Bitmap? { fun get(pageId: String): Bitmap? {
return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled } return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
} }
fun put(pageIndex: Int, bitmap: Bitmap) { fun put(pageId: String, bitmap: Bitmap) {
if (get(pageIndex) == null) { if (get(pageId) == null) {
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1) val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
memoryCache.put(pageIndex, CacheEntry(bitmap, sizeKb)) memoryCache.put(pageId, CacheEntry(bitmap, sizeKb))
} }
} }
@ -475,6 +554,7 @@ data class PageSelectionData(
@Composable @Composable
internal fun PdfPageComposable( internal fun PdfPageComposable(
pdfDocument: StableHolder<ReaderDocument>, pdfDocument: StableHolder<ReaderDocument>,
documentKey: String,
pageIndex: Int, pageIndex: Int,
totalPages: Int, totalPages: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -507,6 +587,7 @@ internal fun PdfPageComposable(
isScrolling: Boolean = false, isScrolling: Boolean = false,
lazyListState: LazyListState? = null, lazyListState: LazyListState? = null,
isVerticalScroll: Boolean = false, isVerticalScroll: Boolean = false,
showPageNumberOverlay: Boolean = true,
visualScaleProvider: () -> Float = { 1f }, visualScaleProvider: () -> Float = { 1f },
clearSelectionTrigger: Long = 0L, clearSelectionTrigger: Long = 0L,
resetZoomTrigger: Long = 0L, resetZoomTrigger: Long = 0L,
@ -558,18 +639,13 @@ internal fun PdfPageComposable(
onShowPanelPopup: (Bitmap) -> Unit = {} onShowPanelPopup: (Bitmap) -> Unit = {}
) { ) {
val pdfDocumentItem = pdfDocument.item val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) } val targetPageId = remember(documentKey, virtualPage, pageIndex) {
var currentRenderedPageId by remember { mutableStateOf<String?>(null) } pdfRenderPageId(documentKey, pageIndex, virtualPage)
val targetPageId = remember(virtualPage, pageIndex) {
when (virtualPage) {
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
null -> "PDF_$pageIndex"
} }
} var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) }
var isLoadingPage by remember { mutableStateOf(true) } var currentRenderedPageId by remember(targetPageId) { mutableStateOf<String?>(null) }
var pageErrorMessage by remember { mutableStateOf<String?>(null) } var isLoadingPage by remember(targetPageId) { mutableStateOf(true) }
var pageErrorMessage by remember(targetPageId) { mutableStateOf<String?>(null) }
val density = LocalDensity.current val density = LocalDensity.current
val context = LocalContext.current val context = LocalContext.current
val viewConfiguration = LocalViewConfiguration.current val viewConfiguration = LocalViewConfiguration.current
@ -581,13 +657,31 @@ internal fun PdfPageComposable(
var ocrRipplePosition by remember { mutableStateOf<Offset?>(null) } var ocrRipplePosition by remember { mutableStateOf<Offset?>(null) }
var isTransforming by remember { mutableStateOf(false) } var isTransforming by remember { mutableStateOf(false) }
var scale by remember { mutableFloatStateOf(1f) } val initialCamera = initialPdfPageCamera(
var offset by remember { mutableStateOf(Offset.Zero) } isZoomEnabled = isZoomEnabled,
isVerticalScroll = isVerticalScroll,
isScrollLocked = isScrollLocked,
lockedState = lockedState
)
var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) }
var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) }
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) } var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
var hasAppliedLockedPaginationState by remember(targetPageId) {
mutableStateOf(initialCamera.second != Offset.Zero || initialCamera.first != 1f)
}
val shouldReportCamera = shouldReportPdfPageCamera(
isZoomEnabled = isZoomEnabled,
isVerticalScroll = isVerticalScroll,
isScrollLocked = isScrollLocked,
lockedState = lockedState,
hasAppliedLockedState = hasAppliedLockedPaginationState
)
LaunchedEffect(scale, offset) { LaunchedEffect(scale, offset, shouldReportCamera) {
if (shouldReportCamera) {
onZoomAndPanChanged?.invoke(scale, offset) onZoomAndPanChanged?.invoke(scale, offset)
} }
}
val currentOnSingleTap by rememberUpdatedState(onSingleTap) val currentOnSingleTap by rememberUpdatedState(onSingleTap)
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap) val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
@ -609,7 +703,7 @@ internal fun PdfPageComposable(
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) } var tiles by remember(targetPageId) { mutableStateOf<List<PdfTile>>(emptyList()) }
val tileSizeDp = PDF_TILE_SIZE_DP.dp val tileSizeDp = PDF_TILE_SIZE_DP.dp
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() } val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
val latestEffectiveScale by rememberUpdatedState(effectiveScale) val latestEffectiveScale by rememberUpdatedState(effectiveScale)
@ -645,15 +739,15 @@ internal fun PdfPageComposable(
} }
} }
val selectionCharRange = remember { mutableStateOf<Pair<Int, Int>?>(null) } val selectionCharRange = remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
var activeDraggingHandle by remember { mutableStateOf<Handle?>(null) } var activeDraggingHandle by remember(targetPageId) { mutableStateOf<Handle?>(null) }
var selectedWordScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) } var selectedWordScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
val startHandleContentPosition = remember { mutableStateOf<Offset?>(null) } val startHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
val endHandleContentPosition = remember { mutableStateOf<Offset?>(null) } val endHandleContentPosition = remember(targetPageId) { mutableStateOf<Offset?>(null) }
var actualBitmapWidthPx by remember { mutableIntStateOf(0) } var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) }
var actualBitmapHeightPx by remember { mutableIntStateOf(0) } var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
var currentPageRotation by remember { mutableIntStateOf(0) } var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage) val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
@ -736,7 +830,7 @@ internal fun PdfPageComposable(
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) } var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
val magnifierZoomFactor = 2.0f val magnifierZoomFactor = 2.0f
var customMenuState by remember { mutableStateOf<CustomPdfMenuState?>(null) } var customMenuState by remember(targetPageId) { mutableStateOf<CustomPdfMenuState?>(null) }
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
@ -826,7 +920,15 @@ internal fun PdfPageComposable(
expandedBubbleIndex = -1 expandedBubbleIndex = -1
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
expandedBubbleRender = null expandedBubbleRender = null
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) { if (
shouldResetPdfZoomAfterBubbleZoomCleanup(
isBubbleZoomModeActive = isBubbleZoomModeActive,
scale = scale,
isVerticalScroll = isVerticalScroll,
isZoomEnabled = isZoomEnabled,
isScrollLocked = isScrollLocked
)
) {
coroutineScope.launch { coroutineScope.launch {
Animatable(scale).animateTo(1f, tween(300)) { Animatable(scale).animateTo(1f, tween(300)) {
scale = this.value scale = this.value
@ -881,10 +983,10 @@ internal fun PdfPageComposable(
} }
} }
DisposableEffect(Unit) { DisposableEffect(targetPageId) {
onDispose { onDispose {
val currentBitmap = bitmapState val currentBitmap = bitmapState
val cachedBitmap = PdfThumbnailCache.get(pageIndex) val cachedBitmap = PdfThumbnailCache.get(targetPageId)
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) { if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
currentBitmap.recycle() currentBitmap.recycle()
} }
@ -895,16 +997,16 @@ internal fun PdfPageComposable(
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
// OCR // OCR
var ocrVisionTextForSelection by remember { mutableStateOf<OcrResult?>(null) } var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf<OcrResult?>(null) }
var isPerformingOcrForSelection by remember { mutableStateOf(false) } var isPerformingOcrForSelection by remember { mutableStateOf(false) }
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) } var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
var ocrSelectionSymbolIndices by remember { mutableStateOf<Pair<Int, Int>?>(null) } var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf<Pair<Int, Int>?>(null) }
var allOcrSymbolsForSelection by remember { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) } var allOcrSymbolsForSelection by remember(targetPageId) { mutableStateOf<List<OcrSymbolInfo>>(emptyList()) }
var highlightedTextScreenRects by remember { mutableStateOf<List<Rect>>(emptyList()) } var highlightedTextScreenRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f) val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
var allTextPageHighlightRects by remember { mutableStateOf<List<Rect>>(emptyList()) } var allTextPageHighlightRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) } var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
@ -937,7 +1039,7 @@ internal fun PdfPageComposable(
val mergedSearchHighlightRects = val mergedSearchHighlightRects =
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) } remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
var pageLinks by remember { mutableStateOf<List<PageLink>>(emptyList()) } var pageLinks by remember(targetPageId) { mutableStateOf<List<PageLink>>(emptyList()) }
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } } val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
@ -1091,9 +1193,9 @@ internal fun PdfPageComposable(
onHighlightLoading(false) onHighlightLoading(false)
} }
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) } @Suppress("VariableNeverRead") var embeddedAnnotations by remember(targetPageId) { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) } var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) } var imageScreenRects by remember(targetPageId) { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) { LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) { if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
@ -1213,9 +1315,6 @@ internal fun PdfPageComposable(
val pagePtr = pageWrapper.getNativePointer() val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) { if (pagePtr != 0L) {
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) {
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr) val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) { if (count > 0) {
@ -1294,7 +1393,6 @@ internal fun PdfPageComposable(
annot to screenRect annot to screenRect
} }
} }
}
} else { } else {
Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.") Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.")
} }
@ -1323,7 +1421,7 @@ internal fun PdfPageComposable(
Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.") Timber.d("Page $pageIndex hidden. Releasing bitmap to save memory.")
val old = bitmapState val old = bitmapState
bitmapState = null bitmapState = null
@Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(pageIndex)) { } @Suppress("ControlFlowWithEmptyBody") if (old != null && old !== PdfThumbnailCache.get(targetPageId)) { }
} }
} }
} }
@ -1584,10 +1682,10 @@ internal fun PdfPageComposable(
} }
} }
var searchFocusedRects by remember { mutableStateOf<List<Rect>>(emptyList()) } var searchFocusedRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
var searchAllRects by remember { mutableStateOf<List<Rect>>(emptyList()) } var searchAllRects by remember(targetPageId) { mutableStateOf<List<Rect>>(emptyList()) }
var keyboardAdjustmentOriginalOffset by remember { mutableStateOf<Float?>(null) } var keyboardAdjustmentOriginalOffset by remember(targetPageId) { mutableStateOf<Float?>(null) }
val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects } val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects }
val mergedSearchAllRects = remember(searchAllRects) { searchAllRects } val mergedSearchAllRects = remember(searchAllRects) { searchAllRects }
@ -1932,10 +2030,6 @@ internal fun PdfPageComposable(
} }
} }
val errorSelection = stringResource(R.string.error_selection)
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
val errorProcessingPage = stringResource(R.string.error_processing_page)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.onGloballyPositioned { layoutCoordinates = it } .onGloballyPositioned { layoutCoordinates = it }
@ -2706,7 +2800,10 @@ internal fun PdfPageComposable(
Timber.e( Timber.e(
e, "Long press: Error during OCR text selection" e, "Long press: Error during OCR text selection"
) )
pageErrorMessage = errorOcrSelection pageErrorMessage = context.getString(
R.string.error_ocr_selection,
e.readablePdfErrorDetail()
)
} finally { } finally {
isPerformingOcrForSelection = false isPerformingOcrForSelection = false
ocrRipplePosition = null ocrRipplePosition = null
@ -2727,7 +2824,10 @@ internal fun PdfPageComposable(
e, e,
"Error during long press text selection on page $pageIndex" "Error during long press text selection on page $pageIndex"
) )
pageErrorMessage = errorSelection pageErrorMessage = context.getString(
R.string.error_selection,
e.readablePdfErrorDetail()
)
customMenuState = null customMenuState = null
selectionCharRange.value = null selectionCharRange.value = null
selectedWordScreenRects = emptyList() selectedWordScreenRects = emptyList()
@ -2774,7 +2874,8 @@ internal fun PdfPageComposable(
selectedTool, selectedTool,
isStylusOnlyMode, isStylusOnlyMode,
userHighlightScreenRects, userHighlightScreenRects,
bubbleTapSlopPx bubbleTapSlopPx,
isScrollLocked
) { ) {
val isTapDetectionAllowed = !isEditMode || val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT || selectedTool == InkType.TEXT ||
@ -3040,7 +3141,7 @@ internal fun PdfPageComposable(
onScaleChanged(scale) onScaleChanged(scale)
} }
} }
} else if (isVerticalScroll && currentOnDoubleTap != null) { } else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) {
currentOnDoubleTap!!(tapOffset) currentOnDoubleTap!!(tapOffset)
} }
}) })
@ -3276,7 +3377,7 @@ internal fun PdfPageComposable(
val startOffset = offset val startOffset = offset
paginationPanFlingJob = coroutineScope.launch { paginationPanFlingJob = coroutineScope.launch {
try { try {
coroutineScope { kotlinx.coroutines.coroutineScope {
launch { launch {
if (flingX != 0f) { if (flingX != 0f) {
Animatable(startOffset.x).animateDecay(flingX, decay) { Animatable(startOffset.x).animateDecay(flingX, decay) {
@ -3543,15 +3644,37 @@ internal fun PdfPageComposable(
} }
} }
var previousLockedViewportSize by remember { mutableStateOf<Pair<Dp, Dp>?>(null) }
LaunchedEffect( LaunchedEffect(
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
isScrollLocked, lockedState isScrollLocked, lockedState
) { ) {
if (isScrollLocked && !isVerticalScroll && lockedState != null) { val currentViewportSize = this@BoxWithConstraints.maxWidth to this@BoxWithConstraints.maxHeight
val previousViewportSize = previousLockedViewportSize
val orientationChanged = previousViewportSize != null &&
(previousViewportSize.first > previousViewportSize.second) !=
(currentViewportSize.first > currentViewportSize.second)
previousLockedViewportSize = currentViewportSize
if (isScrollLocked && !isVerticalScroll) {
if (orientationChanged) {
scale = 1f
offset = Offset.Zero
hasAppliedLockedPaginationState = true
Timber.tag("PdfLockDiagnostic").i(
"Orientation changed while locked; reset paginated zoom to fit on page $pageIndex"
)
} else if (lockedState != null) {
scale = lockedState.first scale = lockedState.first
offset = Offset(lockedState.second, lockedState.third) offset = Offset(lockedState.second, lockedState.third)
hasAppliedLockedPaginationState = true
} else {
hasAppliedLockedPaginationState = true
}
onScaleChanged(scale) onScaleChanged(scale)
} else if (!isScrollLocked && !isVerticalScroll) { } else if (!isScrollLocked && !isVerticalScroll) {
hasAppliedLockedPaginationState = false
scale = 1f scale = 1f
offset = Offset.Zero offset = Offset.Zero
onScaleChanged(1f) onScaleChanged(1f)
@ -3699,10 +3822,12 @@ internal fun PdfPageComposable(
currentContainerMaxHeight, currentContainerMaxHeight,
density, density,
virtualPage, virtualPage,
targetPageId,
isVisible, isVisible,
currentRenderedPageId currentRenderedPageId
) { ) {
if (!isVisible && !isVerticalScroll) return@LaunchedEffect if (!isVisible && !isVerticalScroll) return@LaunchedEffect
pageErrorMessage = null
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() } val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
val viewContainerHeightPx = val viewContainerHeightPx =
@ -3721,12 +3846,18 @@ internal fun PdfPageComposable(
1f / 1.414f 1f / 1.414f
} }
var scaledWidth = viewContainerWidthPx val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
var scaledHeight = (scaledWidth / pageAspect).toInt() viewContainerWidthPx to viewContainerHeightPx
} else {
var fittedWidth = viewContainerWidthPx
var fittedHeight = (fittedWidth / pageAspect).toInt()
if (scaledHeight > viewContainerHeightPx) { if (fittedHeight > viewContainerHeightPx) {
scaledHeight = viewContainerHeightPx fittedHeight = viewContainerHeightPx
scaledWidth = (scaledHeight * pageAspect).toInt() fittedWidth = (fittedHeight * pageAspect).toInt()
}
fittedWidth to fittedHeight
} }
if (scaledWidth == actualBitmapWidthPx && if (scaledWidth == actualBitmapWidthPx &&
@ -3760,7 +3891,7 @@ internal fun PdfPageComposable(
val old = bitmapState val old = bitmapState
if (old != null && old !== finalBitmap) { if (old != null && old !== finalBitmap) {
if (old !== PdfThumbnailCache.get(pageIndex)) { if (old !== PdfThumbnailCache.get(targetPageId)) {
old.recycle() old.recycle()
} }
} }
@ -3771,7 +3902,7 @@ internal fun PdfPageComposable(
return@LaunchedEffect return@LaunchedEffect
} }
coroutineScope.launch { kotlinx.coroutines.coroutineScope {
var localBitmap: Bitmap? = null var localBitmap: Bitmap? = null
try { try {
val renderResult = withContext(Dispatchers.IO) { val renderResult = withContext(Dispatchers.IO) {
@ -3783,24 +3914,29 @@ internal fun PdfPageComposable(
return@withContext null return@withContext null
} }
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
try {
val rotation = page.getPageRotation() val rotation = page.getPageRotation()
val screenDpi = (density.density * 160).roundToInt()
val originalWidthPdfUnits = page.getPageWidthPoint() val originalWidthPdfUnits = page.getPageWidthPoint()
val originalHeightPdfUnits = page.getPageHeightPoint() val originalHeightPdfUnits = page.getPageHeightPoint()
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) { if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
page.close()
throw Exception("Invalid page dimensions") throw Exception("Invalid page dimensions")
} }
val aspectRatio = val aspectRatio =
originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat() originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
var scaledWidth = viewContainerWidthPx val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
var scaledHeight = (scaledWidth / aspectRatio).toInt() viewContainerWidthPx to viewContainerHeightPx
} else {
var fittedWidth = viewContainerWidthPx
var fittedHeight = (fittedWidth / aspectRatio).toInt()
if (scaledHeight > viewContainerHeightPx) { if (fittedHeight > viewContainerHeightPx) {
scaledHeight = viewContainerHeightPx fittedHeight = viewContainerHeightPx
scaledWidth = (scaledHeight * aspectRatio).toInt() fittedWidth = (fittedHeight * aspectRatio).toInt()
}
fittedWidth to fittedHeight
} }
if (scaledWidth == actualBitmapWidthPx && if (scaledWidth == actualBitmapWidthPx &&
@ -3808,7 +3944,6 @@ internal fun PdfPageComposable(
bitmapState != null && bitmapState != null &&
currentRenderedPageId == targetPageId currentRenderedPageId == targetPageId
) { ) {
page.close()
return@withContext null return@withContext null
} }
@ -3836,9 +3971,11 @@ internal fun PdfPageComposable(
baseW, baseH, baseW, baseH,
true true
) )
page.close()
Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight)) Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
} finally {
page.close()
}
} }
if (renderResult != null) { if (renderResult != null) {
@ -3856,7 +3993,7 @@ internal fun PdfPageComposable(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (old != null && old !== newBitmap && !old.isRecycled) { if (old != null && old !== newBitmap && !old.isRecycled) {
val cached = PdfThumbnailCache.get(pageIndex) val cached = PdfThumbnailCache.get(targetPageId)
if (old !== cached) { if (old !== cached) {
old.recycle() old.recycle()
} }
@ -3866,14 +4003,17 @@ internal fun PdfPageComposable(
val thumbHeight = newBitmap.height / 2 val thumbHeight = newBitmap.height / 2
if (thumbWidth > 0 && thumbHeight > 0) { if (thumbWidth > 0 && thumbHeight > 0) {
PdfThumbnailCache.put( PdfThumbnailCache.put(
pageIndex, newBitmap.scale(thumbWidth, thumbHeight) targetPageId, newBitmap.scale(thumbWidth, thumbHeight)
) )
} }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
pageErrorMessage = errorProcessingPage pageErrorMessage = context.getString(
R.string.error_processing_page,
e.readablePdfErrorDetail()
)
} finally { } finally {
isLoadingPage = false isLoadingPage = false
localBitmap?.recycle() localBitmap?.recycle()
@ -4265,6 +4405,7 @@ internal fun PdfPageComposable(
contentToScreenCoordinates = contentToScreenCoordinates, contentToScreenCoordinates = contentToScreenCoordinates,
density = density, density = density,
isVerticalScroll = isVerticalScroll, isVerticalScroll = isVerticalScroll,
showPageNumberOverlay = showPageNumberOverlay,
isScrolling = isScrolling, isScrolling = isScrolling,
isEditMode = isEditMode, isEditMode = isEditMode,
selectedTool = selectedTool, selectedTool = selectedTool,
@ -4296,7 +4437,7 @@ internal fun PdfPageComposable(
else -> { else -> {
Text( Text(
text = stringResource(R.string.error_unable_to_display_page), text = stringResource(R.string.error_unable_to_display_page, pageIndex + 1),
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
.align(Alignment.Center) .align(Alignment.Center)
@ -4355,7 +4496,13 @@ private fun PdfBitmapLayer(
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) { Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) { translate(left = centeringOffsetX, top = centeringOffsetY) {
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) { clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) { if (
bitmapState != null &&
bitmapState.isCanvasSafeBitmap(
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
)
) {
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
val dstH = if (targetHeight > 0) targetHeight else bitmapState.height val dstH = if (targetHeight > 0) targetHeight else bitmapState.height
val srcSize = IntSize(bitmapState.width, bitmapState.height) val srcSize = IntSize(bitmapState.width, bitmapState.height)
@ -4400,7 +4547,12 @@ private fun PdfBitmapLayer(
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
if (needsTiling) { if (needsTiling) {
tiles.forEach { tile -> tiles.forEach { tile ->
if (!tile.bitmap.isRecycled) { if (
tile.bitmap.isCanvasSafeBitmap(
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
)
) {
drawImage( drawImage(
image = tile.bitmap.asImageBitmap(), image = tile.bitmap.asImageBitmap(),
srcOffset = IntOffset.Zero, srcOffset = IntOffset.Zero,
@ -5086,6 +5238,7 @@ private fun PdfPageRenderer(
contentToScreenCoordinates: (Offset) -> Offset, contentToScreenCoordinates: (Offset) -> Offset,
density: Density, density: Density,
isVerticalScroll: Boolean, isVerticalScroll: Boolean,
showPageNumberOverlay: Boolean,
isScrolling: Boolean, isScrolling: Boolean,
isEditMode: Boolean, isEditMode: Boolean,
selectedTool: InkType, selectedTool: InkType,
@ -5282,7 +5435,7 @@ private fun PdfPageRenderer(
} }
// Layer 4: Page Number Indicator // Layer 4: Page Number Indicator
if (totalPages > 0) { if (showPageNumberOverlay && totalPages > 0) {
val pageNumColor = if (staticData.isDarkMode) { val pageNumColor = if (staticData.isDarkMode) {
Color.White Color.White
} else { } else {
@ -5439,10 +5592,12 @@ private fun PdfPageRenderer(
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(), tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
currentScale = effectiveScale, currentScale = effectiveScale,
magnifierCenterOnBitmap = magnifierCenterTarget, magnifierCenterOnBitmap = magnifierCenterTarget,
contentWidthPx = staticData.targetWidth,
contentHeightPx = staticData.targetHeight,
magnifierWidth = magnifierWidth, magnifierWidth = magnifierWidth,
magnifierHeight = magnifierHeight, magnifierHeight = magnifierHeight,
zoomFactor = effectiveZoomFactor, zoomFactor = effectiveZoomFactor,
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item, selectionRectsInContentCoords = selectionData.mergedSelectionRects.item,
highlightColor = Color(0x6633B5E5), highlightColor = Color(0x6633B5E5),
colorFilter = staticData.colorFilter.item, colorFilter = staticData.colorFilter.item,
modifier = Modifier modifier = Modifier
@ -5593,6 +5748,21 @@ private fun PdfPageRenderer(
} }
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) { if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
val baseBitmap = staticData.bitmap.item ?: return@Canvas
if (
!baseBitmap.isCanvasSafeBitmap(
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
)
) {
return@Canvas
}
val safeExpandedBubbleRender = expandedBubbleRender?.takeIf {
it.bitmap.isCanvasSafeBitmap(
maxBytes = PDF_MAX_DRAW_BITMAP_BYTES,
maxDimension = PDF_MAX_DRAW_BITMAP_DIMENSION_PX
)
}
val bubble = detectedBubbles[animatingBubbleIndex] val bubble = detectedBubbles[animatingBubbleIndex]
val left = bubble.bounds.left + staticData.centeringOffsetX val left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY val top = bubble.bounds.top + staticData.centeringOffsetY
@ -5600,7 +5770,7 @@ private fun PdfPageRenderer(
val logicalHeight = bubble.bounds.height() val logicalHeight = bubble.bounds.height()
val pivotX = left + logicalWidth / 2f val pivotX = left + logicalWidth / 2f
val pivotY = top + logicalHeight / 2f val pivotY = top + logicalHeight / 2f
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor( val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds, bubbleBounds = bubble.bounds,
viewportWidth = staticData.canvasWidth, viewportWidth = staticData.canvasWidth,
viewportHeight = staticData.canvasHeight viewportHeight = staticData.canvasHeight
@ -5613,8 +5783,8 @@ private fun PdfPageRenderer(
val dstOffset = IntOffset(left.toInt(), top.toInt()) val dstOffset = IntOffset(left.toInt(), top.toInt())
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt()) val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat() val renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat()
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat() val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat()
val srcOffset = IntOffset( val srcOffset = IntOffset(
(bubble.bounds.left * renderScaleX).toInt(), (bubble.bounds.left * renderScaleX).toInt(),
@ -5651,12 +5821,12 @@ private fun PdfPageRenderer(
) )
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint()) drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
drawImage( drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(), image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset, srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) { srcSize = if (safeExpandedBubbleRender != null) {
IntSize( IntSize(
expandedBubbleRender.bitmap.width, safeExpandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height) safeExpandedBubbleRender.bitmap.height)
} else { } else {
srcSize srcSize
}, },
@ -5675,12 +5845,12 @@ private fun PdfPageRenderer(
} else { } else {
clipRect(left, top, left + logicalWidth, top + logicalHeight) { clipRect(left, top, left + logicalWidth, top + logicalHeight) {
drawImage( drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(), image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset, srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) { srcSize = if (safeExpandedBubbleRender != null) {
IntSize( IntSize(
expandedBubbleRender.bitmap.width, safeExpandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height) safeExpandedBubbleRender.bitmap.height)
} else { } else {
srcSize srcSize
}, },

View file

@ -43,7 +43,11 @@ internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order" internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools" internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode" internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible"
internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug" internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
enum class PdfReaderTool(val title: String, val category: String) { enum class PdfReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"), DICTIONARY("External Apps", "Top Bar"),
@ -62,8 +66,9 @@ enum class PdfReaderTool(val title: String, val category: String) {
OCR_LANGUAGE("OCR Language", "Overflow Menu"), OCR_LANGUAGE("OCR Language", "Overflow Menu"),
READING_MODE("Reading Mode", "Overflow Menu"), READING_MODE("Reading Mode", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"), AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"), TTS_SETTINGS("TTS Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"), TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"), BOOKMARK("Bookmark", "Overflow Menu"),
PAGE_MANAGEMENT("Page Management", "Overflow Menu"), PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
@ -91,12 +96,28 @@ val PdfBuiltInThemes = listOf(
internal fun loadPdfHiddenTools(context: Context): Set<String> { internal fun loadPdfHiddenTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet() val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
val migratedHiddenTools = savedHiddenTools + setOf(
PdfReaderTool.SCREEN_ORIENTATION.name,
PdfReaderTool.HIGHLIGHT_ALL.name
)
prefs.edit {
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
}
return migratedHiddenTools
}
return savedHiddenTools
} }
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) { internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) } prefs.edit {
putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools)
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
}
} }
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> { internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
@ -164,6 +185,26 @@ internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
} }
internal fun savePdfVerticalPageGapVisible(context: Context, isVisible: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, isVisible) }
}
internal fun loadPdfVerticalPageGapVisible(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY, true)
}
internal fun savePdfPageNumberOverlayVisible(context: Context, isVisible: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, isVisible) }
}
internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true)
}
internal fun savePdfThemeId(context: Context, themeId: String) { internal fun savePdfThemeId(context: Context, themeId: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_THEME_KEY, themeId) } prefs.edit { putString(PDF_THEME_KEY, themeId) }

View file

@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
@ -154,9 +153,10 @@ fun PdfSearchResultsPanel(
HorizontalDivider() HorizontalDivider()
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) { LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
items(count = lazyResults.itemCount, key = lazyResults.itemKey { items(
"${it.locationInSource}_${it.occurrenceIndexInLocation}" count = lazyResults.itemCount,
}, contentType = lazyResults.itemContentType { "SearchResult" }) { index -> contentType = lazyResults.itemContentType { "SearchResult" }
) { index ->
val result = lazyResults[index] val result = lazyResults[index]
if (result != null) { if (result != null) {
ListItem( ListItem(

View file

@ -36,11 +36,14 @@ import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ScreenRotation
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -143,7 +146,8 @@ fun PdfCustomizeToolsSheet(
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING, PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
PdfReaderTool.SCREEN_ORIENTATION
) )
var localHiddenTools by remember { mutableStateOf(hiddenTools) } var localHiddenTools by remember { mutableStateOf(hiddenTools) }
@ -504,6 +508,7 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp)) PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp)) PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp)) PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
} }
} }
@ -511,7 +516,11 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
@Composable @Composable
fun PdfVisualOptionsSheet( fun PdfVisualOptionsSheet(
systemUiMode: SystemUiMode, systemUiMode: SystemUiMode,
showVerticalPageGap: Boolean,
showPageNumberOverlay: Boolean,
onSystemUiModeChange: (SystemUiMode) -> Unit, onSystemUiModeChange: (SystemUiMode) -> Unit,
onShowVerticalPageGapChange: (Boolean) -> Unit,
onShowPageNumberOverlayChange: (Boolean) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -549,6 +558,54 @@ fun PdfVisualOptionsSheet(
onOptionSelected = onSystemUiModeChange, onOptionSelected = onSystemUiModeChange,
getLabel = { it.title } getLabel = { it.title }
) )
Spacer(modifier = Modifier.height(20.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
PdfVisualOptionSwitchRow(
title = stringResource(R.string.visual_options_remove_page_gap),
description = stringResource(R.string.visual_options_remove_page_gap_desc),
checked = !showVerticalPageGap,
onCheckedChange = { removeGap ->
onShowVerticalPageGapChange(!removeGap)
}
)
PdfVisualOptionSwitchRow(
title = stringResource(R.string.visual_options_hide_page_number_overlay),
description = stringResource(R.string.visual_options_hide_page_number_overlay_desc),
checked = !showPageNumberOverlay,
onCheckedChange = { hideOverlay ->
onShowPageNumberOverlayChange(!hideOverlay)
}
)
} }
} }
} }
@Composable
private fun PdfVisualOptionSwitchRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}

View file

@ -47,6 +47,8 @@ import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
import kotlin.collections.isNotEmpty import kotlin.collections.isNotEmpty
internal val PdfTabStripHeight = 44.dp
private val pdfToolbarTools = setOf( private val pdfToolbarTools = setOf(
PdfReaderTool.DICTIONARY, PdfReaderTool.DICTIONARY,
PdfReaderTool.THEME, PdfReaderTool.THEME,
@ -57,7 +59,8 @@ private val pdfToolbarTools = setOf(
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.HIGHLIGHT_ALL,
PdfReaderTool.AI_FEATURES, PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE, PdfReaderTool.EDIT_MODE,
PdfReaderTool.TTS_CONTROLS PdfReaderTool.TTS_CONTROLS,
PdfReaderTool.SCREEN_ORIENTATION
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -81,6 +84,7 @@ internal fun PdfTopBar(
isScrollLocked: Boolean, isScrollLocked: Boolean,
isEditMode: Boolean, isEditMode: Boolean,
displayMode: DisplayMode, displayMode: DisplayMode,
isRightToLeftPagination: Boolean,
isKeepScreenOn: Boolean, isKeepScreenOn: Boolean,
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
isBookmarked: Boolean, isBookmarked: Boolean,
@ -101,6 +105,7 @@ internal fun PdfTopBar(
onShowCustomizeTools: () -> Unit, onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit, onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit, onShowVisualOptions: () -> Unit,
onShowScreenOrientation: () -> Unit,
onShowSlider: () -> Unit, onShowSlider: () -> Unit,
onShowToc: () -> Unit, onShowToc: () -> Unit,
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
@ -114,6 +119,7 @@ internal fun PdfTopBar(
tapToNavigateEnabled: Boolean, tapToNavigateEnabled: Boolean,
onToggleTapToNavigate: () -> Unit, onToggleTapToNavigate: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit, onChangeDisplayMode: (DisplayMode) -> Unit,
onSetRightToLeftPagination: (Boolean) -> Unit,
onToggleKeepScreenOn: () -> Unit, onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit, onStartAutoScroll: () -> Unit,
onShowTtsSettings: () -> Unit, onShowTtsSettings: () -> Unit,
@ -262,6 +268,13 @@ internal fun PdfTopBar(
) { ) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
} }
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onShowScreenOrientation
) {
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Unit else -> Unit
} }
} }
@ -281,11 +294,17 @@ internal fun PdfTopBar(
Box { Box {
var showMoreMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) }
var showHiddenToolsExpanded by remember { mutableStateOf(false) } var showHiddenToolsExpanded by remember { mutableStateOf(false) }
var showReadingModeExpanded by remember { mutableStateOf(false) }
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
var showFileActionsExpanded by remember { mutableStateOf(false) }
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_more_options), text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc), description = stringResource(R.string.tooltip_more_options_desc),
onClick = { onClick = {
showHiddenToolsExpanded = false showHiddenToolsExpanded = false
showReadingModeExpanded = false
showTtsSettingsExpanded = false
showFileActionsExpanded = false
showMoreMenu = true showMoreMenu = true
}) { }) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options)) Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
@ -295,6 +314,9 @@ internal fun PdfTopBar(
expanded = showMoreMenu, expanded = showMoreMenu,
onDismissRequest = { onDismissRequest = {
showHiddenToolsExpanded = false showHiddenToolsExpanded = false
showReadingModeExpanded = false
showTtsSettingsExpanded = false
showFileActionsExpanded = false
showMoreMenu = false showMoreMenu = false
} }
) { ) {
@ -340,7 +362,8 @@ internal fun PdfTopBar(
onToggleHighlights = onToggleHighlights, onToggleHighlights = onToggleHighlights,
onShowAiHub = onShowAiHub, onShowAiHub = onShowAiHub,
onToggleEditMode = onToggleEditMode, onToggleEditMode = onToggleEditMode,
onToggleTts = onToggleTts onToggleTts = onToggleTts,
onShowScreenOrientation = onShowScreenOrientation
) )
} }
} }
@ -365,19 +388,53 @@ internal fun PdfTopBar(
} }
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) { if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
)
}
)
if (showReadingModeExpanded) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsSessionActive, enabled = !isTtsSessionActive,
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false }, onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
) )
HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsSessionActive, enabled = !isTtsSessionActive,
onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false }, onClick = {
trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } onSetRightToLeftPagination(false)
onChangeDisplayMode(DisplayMode.PAGINATION)
showMoreMenu = false
},
trailingIcon = {
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
}
}
) )
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
enabled = !isTtsSessionActive,
onClick = {
onSetRightToLeftPagination(true)
onChangeDisplayMode(DisplayMode.PAGINATION)
showMoreMenu = false
},
trailingIcon = {
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
}
}
)
}
HorizontalDivider() HorizontalDivider()
} }
@ -419,23 +476,40 @@ internal fun PdfTopBar(
HorizontalDivider() HorizontalDivider()
} }
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) { val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
if (showTtsVoiceSettings || showTtsReplacements) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings)) },
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
)
}
)
if (showTtsSettingsExpanded) {
if (showTtsVoiceSettings) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsSessionActive, enabled = !isTtsSessionActive,
onClick = { showMoreMenu = false; onShowTtsSettings() }, onClick = { showMoreMenu = false; onShowTtsSettings() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
) )
HorizontalDivider()
} }
if (showTtsReplacements) {
if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = { showMoreMenu = false; onShowTtsReplacements() }, onClick = { showMoreMenu = false; onShowTtsReplacements() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
) )
} }
}
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) { if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
DropdownMenuItem( DropdownMenuItem(
@ -462,7 +536,7 @@ internal fun PdfTopBar(
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) { if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) }, text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
enabled = isPdfDocumentLoaded && !isReflowingThisBook, enabled = isPdfDocumentLoaded && !isReflowingThisBook,
onClick = { showMoreMenu = false; onReflowAction() }, onClick = { showMoreMenu = false; onReflowAction() },
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) } leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
@ -470,23 +544,38 @@ internal fun PdfTopBar(
HorizontalDivider() HorizontalDivider()
} }
if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) { val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
if (showShareAction || showSaveCopyAction || showPrintAction) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_share_save_print)) },
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
)
}
)
if (showFileActionsExpanded) {
if (showShareAction) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.action_share)) }, text = { Text(stringResource(R.string.action_share)) },
onClick = { showMoreMenu = false; onShare() }, onClick = { showMoreMenu = false; onShare() },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
) )
} }
if (showSaveCopyAction) {
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.action_save_copy_to_device)) }, text = { Text(stringResource(R.string.action_save_copy_to_device)) },
onClick = { showMoreMenu = false; onSaveCopy() }, onClick = { showMoreMenu = false; onSaveCopy() },
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
) )
} }
if (showPrintAction) {
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.action_print)) }, text = { Text(stringResource(R.string.action_print)) },
onClick = { showMoreMenu = false; onPrint() }, onClick = { showMoreMenu = false; onPrint() },
@ -497,9 +586,11 @@ internal fun PdfTopBar(
} }
} }
} }
}
}
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) { if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
LazyRow( LazyRow(
modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), modifier = Modifier.fillMaxWidth().height(PdfTabStripHeight).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
items(openTabs, key = { it.bookId }) { tab -> items(openTabs, key = { it.bookId }) { tab ->
@ -509,7 +600,7 @@ internal fun PdfTopBar(
Row( Row(
modifier = Modifier modifier = Modifier
.height(if (isSelected) 44.dp else 36.dp) .height(if (isSelected) PdfTabStripHeight else 36.dp)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(bgColor) .background(bgColor)
.clickable { onTabClick(tab.bookId) } .clickable { onTabClick(tab.bookId) }
@ -564,7 +655,8 @@ private fun HiddenPdfToolMenuItem(
onToggleHighlights: () -> Unit, onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit, onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit, onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit onToggleTts: () -> Unit,
onShowScreenOrientation: () -> Unit
) { ) {
val enabled = when (tool) { val enabled = when (tool) {
PdfReaderTool.SLIDER, PdfReaderTool.SLIDER,
@ -588,6 +680,7 @@ private fun HiddenPdfToolMenuItem(
PdfReaderTool.AI_FEATURES -> onShowAiHub() PdfReaderTool.AI_FEATURES -> onShowAiHub()
PdfReaderTool.EDIT_MODE -> onToggleEditMode() PdfReaderTool.EDIT_MODE -> onToggleEditMode()
PdfReaderTool.TTS_CONTROLS -> onToggleTts() PdfReaderTool.TTS_CONTROLS -> onToggleTts()
PdfReaderTool.SCREEN_ORIENTATION -> onShowScreenOrientation()
else -> Unit else -> Unit
} }
}, },
@ -606,6 +699,7 @@ private fun HiddenPdfToolMenuItem(
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp)) PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
} }
} }
@ -766,6 +860,8 @@ fun PdfBottomBar(
onShowAiHub: () -> Unit, onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit, onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit, onToggleTts: () -> Unit,
onShowScreenOrientation: () -> Unit,
showBubbleZoom: Boolean,
isBubbleZoomModeActive: Boolean, isBubbleZoomModeActive: Boolean,
onToggleBubbleZoom: () -> Unit onToggleBubbleZoom: () -> Unit
) { ) {
@ -868,11 +964,18 @@ fun PdfBottomBar(
) { ) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
} }
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onShowScreenOrientation
) {
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Unit else -> Unit
} }
} }
if (BuildConfig.FLAVOR != "oss") { if (BuildConfig.FLAVOR != "oss" && showBubbleZoom) {
TooltipIconButton( TooltipIconButton(
text = if (isBubbleZoomModeActive) stringResource(R.string.action_exit_smart_zoom) else stringResource(R.string.action_smart_comic_zoom), text = if (isBubbleZoomModeActive) stringResource(R.string.action_exit_smart_zoom) else stringResource(R.string.action_smart_comic_zoom),
description = stringResource(R.string.desc_toggle_smart_comic_zoom), description = stringResource(R.string.desc_toggle_smart_comic_zoom),

View file

@ -63,7 +63,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -81,7 +80,6 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@ -101,7 +99,6 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -116,6 +113,8 @@ import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -175,14 +174,70 @@ fun rememberVerticalPdfReaderState(): VerticalPdfReaderState {
private data class PdfPageLayout( private data class PdfPageLayout(
val index: Int, val index: Int,
val y: Float, val yPx: Int,
val height: Float, val heightPx: Int,
val width: Float, val widthPx: Int,
val widthDp: Dp, val widthDp: Dp,
val heightDp: Dp val heightDp: Dp
) {
val y: Float
get() = yPx.toFloat()
val height: Float
get() = heightPx.toFloat()
val width: Float
get() = widthPx.toFloat()
}
private data class DividerLayout(val yPx: Int, val widthPx: Int, val heightPx: Int) {
val y: Float
get() = yPx.toFloat()
val width: Float
get() = widthPx.toFloat()
val height: Float
get() = heightPx.toFloat()
}
internal data class PdfLockedOrientationResetCamera(
val zoom: Float,
val panX: Float,
val panY: Float
) )
private data class DividerLayout(val y: Float, val width: Float, val height: Float) internal fun calculateLockedOrientationResetCamera(
pageTopY: Float,
totalDocHeight: Float,
screenWidth: Float,
screenHeight: Float,
headerHeightPx: Float,
footerHeightPx: Float,
fitZoom: Float
): PdfLockedOrientationResetCamera {
val targetPanY = headerHeightPx - (pageTopY * fitZoom)
val zoomedDocHeight = totalDocHeight * fitZoom
val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) {
headerHeightPx
} else {
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
}
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
val zoomedDocWidth = screenWidth * fitZoom
val targetPanX = if (zoomedDocWidth < screenWidth) {
(screenWidth - zoomedDocWidth) / 2f
} else {
0f
}
return PdfLockedOrientationResetCamera(
zoom = fitZoom,
panX = targetPanX,
panY = finalPanY
)
}
@Suppress("UnusedVariable") @Suppress("UnusedVariable")
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber") @SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
@ -192,6 +247,7 @@ internal fun PdfVerticalReader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: VerticalPdfReaderState, state: VerticalPdfReaderState,
pdfDocument: StableHolder<ReaderDocument>, pdfDocument: StableHolder<ReaderDocument>,
documentKey: String,
activeTheme: com.aryan.reader.ReaderTheme, activeTheme: com.aryan.reader.ReaderTheme,
activeTextureAlpha: Float = 0.55f, activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false, excludeImages: Boolean = false,
@ -230,6 +286,7 @@ internal fun PdfVerticalReader(
selectedTool: InkType, selectedTool: InkType,
richTextController: RichTextController? = null, richTextController: RichTextController? = null,
textBoxes: List<PdfTextBox> = emptyList(), textBoxes: List<PdfTextBox> = emptyList(),
textBoxesByPage: Map<Int, List<PdfTextBox>> = emptyMap(),
selectedTextBoxId: String? = null, selectedTextBoxId: String? = null,
onTextBoxChange: (PdfTextBox) -> Unit = {}, onTextBoxChange: (PdfTextBox) -> Unit = {},
onTextBoxSelect: (String) -> Unit = {}, onTextBoxSelect: (String) -> Unit = {},
@ -245,6 +302,7 @@ internal fun PdfVerticalReader(
stylusButtonHovering: Boolean = false, stylusButtonHovering: Boolean = false,
isHighlighterSnapEnabled: Boolean = false, isHighlighterSnapEnabled: Boolean = false,
userHighlights: List<PdfUserHighlight> = emptyList(), userHighlights: List<PdfUserHighlight> = emptyList(),
userHighlightsByPage: Map<Int, List<PdfUserHighlight>> = emptyMap(),
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {}, onHighlightDelete: (String) -> Unit = {},
@ -258,9 +316,10 @@ internal fun PdfVerticalReader(
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null, onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
resetZoomTrigger: Long = 0L, resetZoomTrigger: Long = 0L,
isBubbleZoomModeActive: Boolean = false, isBubbleZoomModeActive: Boolean = false,
showPageGap: Boolean = true,
showPageNumberOverlay: Boolean = true,
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() } onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
) { ) {
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
DisposableEffect(state) { DisposableEffect(state) {
onDispose { onDispose {
state.scrollToPageHandler = null state.scrollToPageHandler = null
@ -273,6 +332,13 @@ internal fun PdfVerticalReader(
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) } var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
var isStylusEraserOverride by remember { mutableStateOf(false) } var isStylusEraserOverride by remember { mutableStateOf(false) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse" val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
val verticalPageBackgroundColor = remember(activeTheme) {
when (activeTheme.id) {
"no_theme", "system" -> Color.White
"reverse" -> Color.Black
else -> activeTheme.backgroundColor
}
}
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
val density = LocalDensity.current val density = LocalDensity.current
@ -282,6 +348,12 @@ internal fun PdfVerticalReader(
val ratios = pageAspectRatios.item val ratios = pageAspectRatios.item
val bookmarkSet = bookmarks.item val bookmarkSet = bookmarks.item
val effectiveTextBoxesByPage = remember(textBoxes, textBoxesByPage) {
textBoxesByPage.ifEmpty { textBoxes.groupBy { it.pageIndex } }
}
val effectiveUserHighlightsByPage = remember(userHighlights, userHighlightsByPage) {
userHighlightsByPage.ifEmpty { userHighlights.groupBy { it.pageIndex } }
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -294,56 +366,37 @@ internal fun PdfVerticalReader(
val headerHeightPx = with(density) { headerHeight.toPx() } val headerHeightPx = with(density) { headerHeight.toPx() }
val footerHeightPx = with(density) { footerHeight.toPx() } val footerHeightPx = with(density) { footerHeight.toPx() }
val dividerHeightDp = 8.dp val dividerHeightDp = pdfVerticalPageGapDp(showPageGap, 8.dp)
val dividerHeightPx = with(density) { dividerHeightDp.toPx() } val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
val dividerHeightPxInt = dividerHeightPx.roundToInt().coerceAtLeast(0)
var isFlinging by remember { mutableStateOf(false) } var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) } var isFastFlinging by remember { mutableStateOf(false) }
var isInteracting by remember { mutableStateOf(false) } var isInteracting by remember { mutableStateOf(false) }
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
val layoutState = remember(ratios, screenWidth, screenHeight, density) { val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float) data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
var currentY = 0.0 val verticalLayout = calculatePdfVerticalPageLayoutPx(
pageAspectRatios = ratios,
viewportWidthPx = constraints.maxWidth,
viewportHeightPx = constraints.maxHeight,
pageGapPx = dividerHeightPxInt
)
if (ratios.size == 1) { val pages = verticalLayout.pages.map { page ->
val ratio = ratios[0] PdfPageLayout(
val safeRatio = if (ratio <= 0f) 1f else ratio index = page.pageIndex,
val pageHeight = screenWidth / safeRatio yPx = page.topPx,
if (pageHeight < screenHeight) { heightPx = page.heightPx,
currentY = ((screenHeight - pageHeight) / 2f).toDouble() widthPx = page.widthPx,
} widthDp = with(density) { page.widthPx.toDp() },
heightDp = with(density) { page.heightPx.toDp() }
)
} }
val pages = ratios.mapIndexed { index, ratio -> LayoutResult(pages, verticalLayout.totalHeightPx.toFloat())
val safeRatio = if (ratio <= 0f) 1f else ratio
val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble()
val pageHeight = pageHeightDouble.toFloat()
val info = PdfPageLayout(
index = index,
y = currentY.toFloat(),
height = pageHeight,
width = screenWidth,
widthDp = with(density) { screenWidth.toDp() },
heightDp = with(density) { pageHeight.toDp() })
currentY += pageHeightDouble
if (index < ratios.lastIndex) {
currentY += dividerHeightPx
}
info
}
val totalH = if (pages.isNotEmpty()) {
val last = pages.last()
last.y + last.height
} else {
0f
}
LayoutResult(pages, totalH)
} }
val layoutInfo = layoutState.pages val layoutInfo = layoutState.pages
@ -375,11 +428,17 @@ internal fun PdfVerticalReader(
var isResizing by remember { mutableStateOf(false) } var isResizing by remember { mutableStateOf(false) }
var previousScreenWidth by remember { mutableFloatStateOf(0f) } var previousScreenWidth by remember { mutableFloatStateOf(0f) }
var previousScreenHeight by remember { mutableFloatStateOf(0f) } var previousScreenHeight by remember { mutableFloatStateOf(0f) }
var lockedOrientationChangedDuringResize by remember { mutableStateOf(false) }
val targetPageDuringResize = remember { mutableIntStateOf(-1) } val targetPageDuringResize = remember { mutableIntStateOf(-1) }
if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) { if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
if (previousScreenWidth > 0f) { if (previousScreenWidth > 0f) {
val previousWasLandscape = previousScreenWidth > previousScreenHeight
val currentIsLandscape = screenWidth > screenHeight
isResizing = true isResizing = true
if (isScrollLocked && previousWasLandscape != currentIsLandscape) {
lockedOrientationChangedDuringResize = true
}
if (targetPageDuringResize.intValue == -1) { if (targetPageDuringResize.intValue == -1) {
targetPageDuringResize.intValue = state.currentPage targetPageDuringResize.intValue = state.currentPage
} }
@ -424,7 +483,45 @@ internal fun PdfVerticalReader(
} }
LaunchedEffect(layoutState.pages) { LaunchedEffect(layoutState.pages) {
if (!isInitialLayout && !isScrollLocked) { if (!isInitialLayout && isScrollLocked && lockedOrientationChangedDuringResize) {
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
targetPageDuringResize.intValue
} else {
state.currentPage
}
val newLayout = layoutState.pages
val pageLayout = newLayout.getOrNull(targetPageIdx)
if (pageLayout != null) {
val resetCamera = calculateLockedOrientationResetCamera(
pageTopY = pageLayout.y,
totalDocHeight = layoutState.totalHeight,
screenWidth = screenWidth,
screenHeight = screenHeight,
headerHeightPx = headerHeightPx,
footerHeightPx = footerHeightPx,
fitZoom = fitZoom
)
panXAnimatable.updateBounds(null, null)
panYAnimatable.updateBounds(null, null)
coroutineScope {
launch { zoomAnimatable.snapTo(resetCamera.zoom) }
launch { panXAnimatable.snapTo(resetCamera.panX) }
launch { panYAnimatable.snapTo(resetCamera.panY) }
}
state.currentPage = targetPageIdx
hasRestoredLockedState = true
onZoomChange(resetCamera.zoom)
onZoomAndPanChanged?.invoke(resetCamera.zoom, Offset(resetCamera.panX, resetCamera.panY))
Timber.tag("PdfLockDiagnostic").i(
"Orientation changed while locked; reset zoom to fit and kept page $targetPageIdx"
)
}
} else if (!isInitialLayout && !isScrollLocked) {
val targetPageIdx = if (targetPageDuringResize.intValue != -1) { val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
targetPageDuringResize.intValue targetPageDuringResize.intValue
} else { } else {
@ -466,6 +563,7 @@ internal fun PdfVerticalReader(
if (!isInitialLayout) { if (!isInitialLayout) {
delay(50) delay(50)
isResizing = false isResizing = false
lockedOrientationChangedDuringResize = false
targetPageDuringResize.intValue = -1 targetPageDuringResize.intValue = -1
} }
isInitialLayout = false isInitialLayout = false
@ -1080,8 +1178,9 @@ internal fun PdfVerticalReader(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
.then(globalDrawingModifier) .then(globalDrawingModifier)
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode) { .pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) {
Timber.tag("PdfTouchDebug").v( Timber.tag("PdfTouchDebug").v(
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode" "VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
) )
@ -1101,8 +1200,10 @@ internal fun PdfVerticalReader(
onPageClick() onPageClick()
} }
}, onDoubleTap = { offset -> }, onDoubleTap = { offset ->
if (!isScrollLocked) {
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected") Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
onDoubleTapToZoom(offset) onDoubleTapToZoom(offset)
}
}) })
} }
.pointerInput( .pointerInput(
@ -1428,11 +1529,16 @@ internal fun PdfVerticalReader(
} }
val cached = cachedVisiblePages.value val cached = cachedVisiblePages.value
val indicesMatch = cached.size == finalPages.size && cached.indices.all { val layoutMatches = cached.size == finalPages.size && cached.indices.all {
cached[it].index == finalPages[it].index val cachedPage = cached[it]
val newPage = finalPages[it]
cachedPage.index == newPage.index &&
cachedPage.yPx == newPage.yPx &&
cachedPage.heightPx == newPage.heightPx &&
cachedPage.widthPx == newPage.widthPx
} }
if (!indicesMatch) { if (!layoutMatches) {
cachedVisiblePages.value = finalPages cachedVisiblePages.value = finalPages
Timber.tag("PdfDrawPerf").d( Timber.tag("PdfDrawPerf").d(
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})" "Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
@ -1472,20 +1578,13 @@ internal fun PdfVerticalReader(
Layout( Layout(
content = { content = {
visiblePages.forEach { page -> visiblePages.forEach { page ->
key(page.index) { key(documentKey, page.index) {
val isBookmarked by remember(bookmarkSet, page.index) { val isBookmarked by remember(bookmarkSet, page.index) {
derivedStateOf { derivedStateOf {
bookmarkSet.any { it.pageIndex == page.index } bookmarkSet.any { it.pageIndex == page.index }
} }
} }
SideEffect {
if (page.index == state.currentPage) {
Timber.tag("PdfDrawPerf")
.v("VERTICAL READER: Emitting Page ${page.index}")
}
}
val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) { val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) {
{ {
val zoom = zoomAnimatable.value val zoom = zoomAnimatable.value
@ -1579,6 +1678,7 @@ internal fun PdfVerticalReader(
{ text: String -> onSearchText(text) } { text: String -> onSearchText(text) }
} }
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) { val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
{ localOffset: Offset -> { localOffset: Offset ->
Timber.tag("PdfZoomDebug").d( Timber.tag("PdfZoomDebug").d(
@ -1592,7 +1692,7 @@ internal fun PdfVerticalReader(
val screenX = contentX * currentZ + panX val screenX = contentX * currentZ + panX
val screenY = contentY * currentZ + panY val screenY = contentY * currentZ + panY
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
onDoubleTapToZoom(Offset(screenX, screenY)) currentOnDoubleTapToZoom(Offset(screenX, screenY))
} }
} }
@ -1661,32 +1761,10 @@ internal fun PdfVerticalReader(
Box(modifier = Modifier Box(modifier = Modifier
.layoutId(page) .layoutId(page)
.graphicsLayer { ) {
val z = zoomAnimatable.value
val px = panXAnimatable.value
val py = panYAnimatable.value
scaleX = z
scaleY = z
translationX = px
translationY = page.y * (z - 1f) + py
transformOrigin = TransformOrigin(0f, 0f)
if (page.index < 2 && z > 1.1f) {
Timber.tag("PdfZoomDebug").v("Page ${page.index} Render: TransY=$translationY (PageY=${page.y}, GlobalY=${page.y + translationY})")
}
}
.clipToBounds()
.onGloballyPositioned { coordinates ->
if (page.index == 0) {
val pos = coordinates.positionInWindow()
Timber.d(
"Page 0 Box | GlobalPos: $pos | Size: ${coordinates.size} | PageY: ${page.y}"
)
}
}) {
PdfPageComposable( PdfPageComposable(
pdfDocument = pdfDocument, pdfDocument = pdfDocument,
documentKey = documentKey,
pageIndex = page.index, pageIndex = page.index,
virtualPage = virtualPage, virtualPage = virtualPage,
totalPages = totalPages, totalPages = totalPages,
@ -1716,6 +1794,8 @@ internal fun PdfVerticalReader(
isZoomEnabled = false, isZoomEnabled = false,
isScrolling = isDragging || (isFlinging && isFastFlinging), isScrolling = isDragging || (isFlinging && isFastFlinging),
isVerticalScroll = true, isVerticalScroll = true,
showPageNumberOverlay = showPageNumberOverlay,
isScrollLocked = isScrollLocked,
visualScaleProvider = currentScaleProvider, visualScaleProvider = currentScaleProvider,
onDoubleTap = onDoubleTapLambda, onDoubleTap = onDoubleTapLambda,
clearSelectionTrigger = selectionClearTrigger, clearSelectionTrigger = selectionClearTrigger,
@ -1734,11 +1814,11 @@ internal fun PdfVerticalReader(
isStylusOnlyMode = isStylusOnlyMode, isStylusOnlyMode = isStylusOnlyMode,
stylusButtonHovering = stylusButtonHovering, stylusButtonHovering = stylusButtonHovering,
isAutoScrollPlaying = isAutoScrollPlaying, isAutoScrollPlaying = isAutoScrollPlaying,
textBoxes = textBoxes.filter { it.pageIndex == page.index }, textBoxes = effectiveTextBoxesByPage[page.index].orEmpty(),
selectedTextBoxId = selectedTextBoxId, selectedTextBoxId = selectedTextBoxId,
onTextBoxChange = onTextBoxChange, onTextBoxChange = onTextBoxChange,
onTextBoxSelect = onTextBoxSelect, onTextBoxSelect = onTextBoxSelect,
userHighlights = userHighlights.filter { it.pageIndex == page.index }, userHighlights = effectiveUserHighlightsByPage[page.index].orEmpty(),
onHighlightAdd = onHighlightAdd, onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate, onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete, onHighlightDelete = onHighlightDelete,
@ -1852,26 +1932,17 @@ internal fun PdfVerticalReader(
) )
} }
if (page.index < totalPages - 1) { if (page.index < totalPages - 1 && dividerHeightPxInt > 0) {
val dividerY = page.y + page.height val dividerYPx = page.yPx + page.heightPx
Box( Box(
modifier = Modifier modifier = Modifier
.layoutId( .layoutId(
DividerLayout( DividerLayout(
dividerY, page.width, dividerHeightPx yPx = dividerYPx,
widthPx = page.widthPx,
heightPx = dividerHeightPxInt
) )
) )
.graphicsLayer {
val z = zoomAnimatable.value
val px = panXAnimatable.value
val py = panYAnimatable.value
scaleX = z
scaleY = z
translationX = px
translationY = dividerY * (z - 1f) + py
transformOrigin = TransformOrigin(0f, 0f)
}
.background( .background(
MaterialTheme.colorScheme.surfaceVariant MaterialTheme.colorScheme.surfaceVariant
)) ))
@ -1881,6 +1952,15 @@ internal fun PdfVerticalReader(
}, },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer {
val z = zoomAnimatable.value
scaleX = z
scaleY = z
translationX = panXAnimatable.value
translationY = panYAnimatable.value
transformOrigin = TransformOrigin(0f, 0f)
}
.onGloballyPositioned { _ -> }) { measurables, constraints -> .onGloballyPositioned { _ -> }) { measurables, constraints ->
val layoutStart = System.nanoTime() val layoutStart = System.nanoTime()
Timber.tag("PdfDrawPerf") Timber.tag("PdfDrawPerf")
@ -1891,19 +1971,19 @@ internal fun PdfVerticalReader(
is PdfPageLayout -> { is PdfPageLayout -> {
val placeable = measurable.measure( val placeable = measurable.measure(
Constraints.fixed( Constraints.fixed(
id.width.roundToInt(), id.height.roundToInt() id.widthPx, id.heightPx
) )
) )
placeable.place(0, id.y.roundToInt()) placeable.place(0, id.yPx)
} }
is DividerLayout -> { is DividerLayout -> {
val placeable = measurable.measure( val placeable = measurable.measure(
Constraints.fixed( Constraints.fixed(
id.width.roundToInt(), id.height.roundToInt() id.widthPx, id.heightPx
) )
) )
placeable.place(0, id.y.roundToInt()) placeable.place(0, id.yPx)
} }
} }
} }

View file

@ -120,7 +120,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -198,13 +197,13 @@ import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiFeature import com.aryan.reader.AiFeature
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiHubBottomSheet import com.aryan.reader.AiHubBottomSheet
import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.FileType import com.aryan.reader.FileType
import com.aryan.reader.HighlightColorPickerDialog import com.aryan.reader.HighlightColorPickerDialog
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderScreenOrientationEffect
import com.aryan.reader.ReaderScreenOrientationSheet
import com.aryan.reader.ReaderThemePanel import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
@ -225,6 +224,8 @@ import com.aryan.reader.callByokGeminiInlineAi
import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderScreenOrientationMode
import com.aryan.reader.loadPdfRightToLeftPagination
import com.aryan.reader.loadTtsReplacementPreferences import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository import com.aryan.reader.pdf.data.AnnotationSettingsRepository
@ -240,7 +241,10 @@ import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderScreenOrientationMode
import com.aryan.reader.savePdfRightToLeftPagination
import com.aryan.reader.saveTtsReplacementPreferences import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.scaledToCanvasLimit
import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.summarizationUrl import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
@ -288,6 +292,36 @@ internal fun resolveEraserStrokeWidth(
eraserToolThickness: Float eraserToolThickness: Float
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness ): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
internal fun canUsePdfSidecarsForBook(
activeBookId: String?,
loadedSidecarBookId: String?,
areSidecarsLoaded: Boolean
): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId
internal fun currentPageScaleAfterPdfPageChange(
displayMode: DisplayMode,
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?,
currentActiveScale: Float
): Float {
return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) {
lockedState?.first ?: currentActiveScale
} else {
1f
}
}
internal fun activePdfCameraAfterLockPreferenceLoad(
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?
): Pair<Float, Offset> {
return if (isScrollLocked && lockedState != null) {
lockedState.first to Offset(lockedState.second, lockedState.third)
} else {
1f to Offset.Zero
}
}
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
@ -305,7 +339,6 @@ fun PdfViewerScreen(
onNavigateToPro: () -> Unit, onNavigateToPro: () -> Unit,
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
SideEffect { Timber.tag("PdfDrawPerf").v("ROOT: PdfViewerScreen Recomposing") }
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
PdfFontCache.init(context.assets) PdfFontCache.init(context.assets)
@ -338,7 +371,12 @@ fun PdfViewerScreen(
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) } var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
var showBars by rememberSaveable { mutableStateOf(true) } var showBars by rememberSaveable { mutableStateOf(true) }
var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) } var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) }
var showVerticalPageGap by remember { mutableStateOf(loadPdfVerticalPageGapVisible(context)) }
var showPageNumberOverlay by remember { mutableStateOf(loadPdfPageNumberOverlayVisible(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) } var showVisualOptionsSheet by remember { mutableStateOf(false) }
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
var showScreenOrientationSheet by remember { mutableStateOf(false) }
var isFullScreen by remember { mutableStateOf(false) } var isFullScreen by remember { mutableStateOf(false) }
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) } var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
@ -349,6 +387,7 @@ fun PdfViewerScreen(
var showPasswordDialog by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) }
var isPasswordError by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) }
LocalView.current LocalView.current
ReaderScreenOrientationEffect(screenOrientationMode)
var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) } var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) }
var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) } var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) }
@ -397,6 +436,7 @@ fun PdfViewerScreen(
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7
var showNewTabSheet by remember { mutableStateOf(false) } var showNewTabSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
@ -404,6 +444,7 @@ fun PdfViewerScreen(
val isTabsEnabled = uiState.isTabsEnabled val isTabsEnabled = uiState.isTabsEnabled
val openTabs = uiState.openTabs val openTabs = uiState.openTabs
val activeTabBookId = uiState.activeTabBookId val activeTabBookId = uiState.activeTabBookId
val isPdfTabStripVisible = isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF
val originalFileName by remember(uiState.recentFiles, effectivePdfUri) { val originalFileName by remember(uiState.recentFiles, effectivePdfUri) {
derivedStateOf { derivedStateOf {
uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName
@ -412,6 +453,7 @@ fun PdfViewerScreen(
} }
var currentBookId by remember { mutableStateOf<String?>(null) } var currentBookId by remember { mutableStateOf<String?>(null) }
val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString() val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString()
val activeDocumentRenderKey = currentBookId ?: effectivePdfUri.toString()
var documentMetadataTitle by remember { mutableStateOf<String?>(null) } var documentMetadataTitle by remember { mutableStateOf<String?>(null) }
val view = LocalView.current val view = LocalView.current
var isDockDragging by remember { mutableStateOf(false) } var isDockDragging by remember { mutableStateOf(false) }
@ -425,8 +467,16 @@ fun PdfViewerScreen(
} }
LaunchedEffect(bookId) { LaunchedEffect(bookId) {
isScrollLocked = loadPdfScrollLocked(context, bookId) val savedIsScrollLocked = loadPdfScrollLocked(context, bookId)
lockedState = loadPdfLockedState(context, bookId) val savedLockedState = loadPdfLockedState(context, bookId)
val activeCamera = activePdfCameraAfterLockPreferenceLoad(
isScrollLocked = savedIsScrollLocked,
lockedState = savedLockedState
)
isScrollLocked = savedIsScrollLocked
lockedState = savedLockedState
currentActiveScale = activeCamera.first
currentActiveOffset = activeCamera.second
} }
var isAutoScrollModeActive by remember { mutableStateOf(false) } var isAutoScrollModeActive by remember { mutableStateOf(false) }
@ -496,9 +546,8 @@ fun PdfViewerScreen(
val customFonts by viewModel.customFonts.collectAsState() val customFonts by viewModel.customFonts.collectAsState()
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) } fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
fun showBanner(message: String, isError: Boolean = false) { viewModel.showBanner(message, isError, isPersistent)
bannerMessage = BannerMessage(message, isError = isError)
} }
val onOcrStateChange: (Boolean) -> Unit = {} val onOcrStateChange: (Boolean) -> Unit = {}
@ -638,6 +687,13 @@ fun PdfViewerScreen(
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) } var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState() val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
LaunchedEffect(isComicFile) {
if (!isComicFile) {
isBubbleZoomModeActive = false
showBubbleZoomDownloadDialog = false
}
}
var dockLocation by remember { mutableStateOf(initialDockLocation) } var dockLocation by remember { mutableStateOf(initialDockLocation) }
var dockOffset by remember { mutableStateOf(initialDockOffset) } var dockOffset by remember { mutableStateOf(initialDockOffset) }
var snapPreviewLocation by remember { mutableStateOf<DockLocation?>(null) } var snapPreviewLocation by remember { mutableStateOf<DockLocation?>(null) }
@ -725,7 +781,8 @@ fun PdfViewerScreen(
val targetTopOverlayInset = remember( val targetTopOverlayInset = remember(
showStandardBars, showStandardBars,
systemUiMode, systemUiMode,
statusBarHeightDp statusBarHeightDp,
isPdfTabStripVisible
) { ) {
if (!showStandardBars) { if (!showStandardBars) {
0.dp 0.dp
@ -737,6 +794,9 @@ fun PdfViewerScreen(
if (isStatusBarVisible) { if (isStatusBarVisible) {
inset += statusBarHeightDp inset += statusBarHeightDp
} }
if (isPdfTabStripVisible) {
inset += PdfTabStripHeight
}
inset inset
} }
} }
@ -775,13 +835,14 @@ fun PdfViewerScreen(
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) } LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) { LaunchedEffect(bookId, currentActiveScale, currentActiveOffset, isScrollLocked) {
if (isScrollLocked) { if (isScrollLocked) {
val requestedCamera = currentActiveScale to currentActiveOffset
delay(500) delay(500)
Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=$currentActiveScale | X=${currentActiveOffset.x} | Y=${currentActiveOffset.y}") Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=${requestedCamera.first} | X=${requestedCamera.second.x} | Y=${requestedCamera.second.y}")
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) lockedState = Triple(requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) savePdfLockedState(context, bookId, requestedCamera.first, requestedCamera.second.x, requestedCamera.second.y)
} }
} }
@ -800,14 +861,6 @@ fun PdfViewerScreen(
errorMessage?.let { showBanner(it, isError = true) } errorMessage?.let { showBanner(it, isError = true) }
} }
LaunchedEffect(bannerMessage) {
val message = bannerMessage ?: return@LaunchedEffect
if (!message.isPersistent) {
delay(2500L)
bannerMessage = null
}
}
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState() val toolSettings by annotationSettingsRepo.settings.collectAsState()
var showToolSettings by rememberSaveable { mutableStateOf(false) } var showToolSettings by rememberSaveable { mutableStateOf(false) }
@ -861,6 +914,7 @@ fun PdfViewerScreen(
var lastEraserPoint by remember { mutableStateOf<PdfPoint?>(null) } var lastEraserPoint by remember { mutableStateOf<PdfPoint?>(null) }
var areAnnotationsLoaded by remember { mutableStateOf(false) } var areAnnotationsLoaded by remember { mutableStateOf(false) }
var loadedSidecarBookId by remember { mutableStateOf<String?>(null) }
val richTextRepository = remember(context) { PdfRichTextRepository(context) } val richTextRepository = remember(context) { PdfRichTextRepository(context) }
val richTextController = remember(currentBookId) { val richTextController = remember(currentBookId) {
@ -936,16 +990,10 @@ fun PdfViewerScreen(
} }
fun buildSpeechBubblePrefetchOrder(): List<Int> { fun buildSpeechBubblePrefetchOrder(): List<Int> {
if (totalDisplayPages <= 0) return emptyList() return buildPdfBubblePrefetchOrder(
val ordered = LinkedHashSet<Int>() currentPage = currentPage,
ordered += currentPage.coerceIn(0, totalDisplayPages - 1) totalPages = totalDisplayPages
for (distance in 1 until totalDisplayPages) { )
val next = currentPage + distance
val previous = currentPage - distance
if (next in 0 until totalDisplayPages) ordered += next
if (previous in 0 until totalDisplayPages) ordered += previous
}
return ordered.toList()
} }
suspend fun detectSpeechBubblesForPage( suspend fun detectSpeechBubblesForPage(
@ -1125,39 +1173,80 @@ fun PdfViewerScreen(
val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } } val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } }
val sidecarsReadyForCurrentBook =
canUsePdfSidecarsForBook(currentBookId, loadedSidecarBookId, areAnnotationsLoaded)
val textBoxesSnapshot by remember { derivedStateOf { textBoxes.toList() } }
val userHighlightsSnapshot by remember { derivedStateOf { userHighlights.toList() } }
val visibleAllAnnotations = if (sidecarsReadyForCurrentBook) allAnnotations else emptyMap()
val visibleTextBoxes = if (sidecarsReadyForCurrentBook) textBoxesSnapshot else emptyList()
val visibleUserHighlights = if (sidecarsReadyForCurrentBook) userHighlightsSnapshot else emptyList()
val visibleTextBoxesByPage = remember(sidecarsReadyForCurrentBook, textBoxesSnapshot) {
if (sidecarsReadyForCurrentBook) {
textBoxesSnapshot.groupBy { it.pageIndex }
} else {
emptyMap()
}
}
val visibleUserHighlightsByPage = remember(sidecarsReadyForCurrentBook, userHighlightsSnapshot) {
if (sidecarsReadyForCurrentBook) {
userHighlightsSnapshot.groupBy { it.pageIndex }
} else {
emptyMap()
}
}
val currentAnnotations by rememberUpdatedState(allAnnotations) val currentAnnotations by rememberUpdatedState(allAnnotations)
val currentTextBoxes by rememberUpdatedState(textBoxes.toList()) val currentTextBoxes by rememberUpdatedState(textBoxesSnapshot)
val currentHighlights by rememberUpdatedState(userHighlights.toList()) val currentHighlights by rememberUpdatedState(userHighlightsSnapshot)
val currentLoadedSidecarBookId by rememberUpdatedState(loadedSidecarBookId)
val currentAreAnnotationsLoaded by rememberUpdatedState(areAnnotationsLoaded)
val currentBookmarks by rememberUpdatedState(bookmarks) val currentBookmarks by rememberUpdatedState(bookmarks)
val currentTotalPages by rememberUpdatedState(totalDisplayPages) val currentTotalPages by rememberUpdatedState(totalDisplayPages)
val currentPageState by rememberUpdatedState(currentPage) val currentPageState by rememberUpdatedState(currentPage)
val currentPendingPage by rememberUpdatedState(pendingRestorePage) val currentPendingPage by rememberUpdatedState(pendingRestorePage)
val currentVisibleAllAnnotations by rememberUpdatedState(visibleAllAnnotations)
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) { val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
{ force: Boolean -> { force: Boolean ->
val bookIdSnapshot = currentBookId
val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId
val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook(
bookIdSnapshot,
loadedSidecarBookIdSnapshot,
currentAreAnnotationsLoaded
)
val isDocumentReadySnapshot = isDocumentReady
val initialScrollDoneSnapshot = initialScrollDone
val annotsSnapshot = currentAnnotations
val boxesSnapshot = currentTextBoxes
val highlightsSnapshot = currentHighlights
val bookmarksSnapshot = currentBookmarks
val totalPagesSnapshot = currentTotalPages
val currentPageSnapshot = currentPageState
val pendingPageSnapshot = currentPendingPage
viewModel.viewModelScope.launch { viewModel.viewModelScope.launch {
val bookId = currentBookId ?: return@launch val bookId = bookIdSnapshot ?: return@launch
if (!isDocumentReady && !force) { if (!isDocumentReadySnapshot && !force) {
Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.") Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.")
return@launch return@launch
} }
val annots = currentAnnotations val annots = annotsSnapshot
val boxes = currentTextBoxes val boxes = boxesSnapshot
val highlights = currentHighlights val highlights = highlightsSnapshot
val bms = currentBookmarks val bms = bookmarksSnapshot
val totalPgs = currentTotalPages val totalPgs = totalPagesSnapshot
val restoreTarget = currentPendingPage ?: 0 val restoreTarget = pendingPageSnapshot ?: 0
val page = if (!initialScrollDone) { val page = if (!initialScrollDoneSnapshot) {
Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageState)") Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageSnapshot)")
restoreTarget restoreTarget
} else { } else {
currentPageState currentPageSnapshot
} }
Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageState, Target: $restoreTarget, Done: $initialScrollDone)") Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageSnapshot, Target: $restoreTarget, Done: $initialScrollDoneSnapshot)")
val annotsHash = annots.hashCode() val annotsHash = annots.hashCode()
val boxesHash = boxes.hashCode() val boxesHash = boxes.hashCode()
@ -1169,6 +1258,7 @@ fun PdfViewerScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@Suppress("VariableNeverRead") var didSave = false @Suppress("VariableNeverRead") var didSave = false
if (canSaveSidecarsSnapshot) {
if (force || annotsHash != lastSavedHashes[0]) { if (force || annotsHash != lastSavedHashes[0]) {
annotationRepository.saveAnnotations(bookId, annots) annotationRepository.saveAnnotations(bookId, annots)
lastSavedHashes[0] = annotsHash lastSavedHashes[0] = annotsHash
@ -1184,6 +1274,11 @@ fun PdfViewerScreen(
lastSavedHashes[2] = highlightsHash lastSavedHashes[2] = highlightsHash
didSave = true didSave = true
} }
} else {
Timber.tag("PdfTabSync").d(
"Skipping PDF sidecar save for $bookId; loaded sidecars belong to $loadedSidecarBookIdSnapshot"
)
}
if (force || bmsHash != lastSavedHashes[3]) { if (force || bmsHash != lastSavedHashes[3]) {
val objectList = bms.map { bookmark -> val objectList = bms.map { bookmark ->
JSONObject().apply { JSONObject().apply {
@ -1239,18 +1334,19 @@ fun PdfViewerScreen(
LaunchedEffect( LaunchedEffect(
allAnnotations, allAnnotations,
textBoxes.toList(), textBoxesSnapshot,
userHighlights.toList(), userHighlightsSnapshot,
bookmarks, bookmarks,
currentPage currentPage,
sidecarsReadyForCurrentBook
) { ) {
if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) { if (sidecarsReadyForCurrentBook && initialScrollDone) {
delay(2000) // Debounce period delay(2000) // Debounce period
saveAllData(false) saveAllData(false)
} }
} }
val allAnnotationsProvider = remember { { allAnnotations } } val allAnnotationsProvider = remember { { currentVisibleAllAnnotations } }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'") Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'")
@ -2070,26 +2166,39 @@ fun PdfViewerScreen(
} }
LaunchedEffect(currentBookId) { LaunchedEffect(currentBookId) {
if (currentBookId != null) { val loadingBookId = currentBookId
val loaded = annotationRepository.loadAnnotations(currentBookId!!)
allAnnotations = loaded
areAnnotationsLoaded = true
val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!) areAnnotationsLoaded = false
loadedSidecarBookId = null
allAnnotations = emptyMap()
textBoxes.clear() textBoxes.clear()
textBoxes.addAll(loadedBoxes)
val loadedHighlights = highlightRepository.loadHighlights(currentBookId!!)
userHighlights.clear() userHighlights.clear()
selectedTextBoxId = null
undoStack.clear()
redoStack.clear()
erasedAnnotationsFromStroke.clear()
drawingState.onDrawCancel()
if (loadingBookId == null) return@LaunchedEffect
val loaded = annotationRepository.loadAnnotations(loadingBookId)
val loadedBoxes = textBoxRepository.loadTextBoxes(loadingBookId)
val loadedHighlights = highlightRepository.loadHighlights(loadingBookId)
if (currentBookId != loadingBookId) return@LaunchedEffect
allAnnotations = loaded
textBoxes.addAll(loadedBoxes)
userHighlights.addAll(loadedHighlights) userHighlights.addAll(loadedHighlights)
} loadedSidecarBookId = loadingBookId
areAnnotationsLoaded = true
} }
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) } var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) { LaunchedEffect(pdfDocument, currentBookId, userHighlightsSnapshot, sidecarsReadyForCurrentBook) {
val document = pdfDocument ?: return@LaunchedEffect val document = pdfDocument ?: return@LaunchedEffect
if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect if (!sidecarsReadyForCurrentBook || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
val snapshot = userHighlights.toList() val snapshot = userHighlightsSnapshot
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
isRebuildingSyncedHighlightBounds = true isRebuildingSyncedHighlightBounds = true
@ -2116,18 +2225,18 @@ fun PdfViewerScreen(
coroutineScope.launch { coroutineScope.launch {
val currentRichTextLayouts = richTextController?.pageLayouts val currentRichTextLayouts = richTextController?.pageLayouts
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}") Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
if (userHighlights.isEmpty()) { if (visibleUserHighlights.isEmpty()) {
Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.") Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.")
} }
viewModel.savePdfWithAnnotations( viewModel.savePdfWithAnnotations(
sourceUri = effectivePdfUri, sourceUri = effectivePdfUri,
destUri = uri, destUri = uri,
annotations = allAnnotations, annotations = visibleAllAnnotations,
richTextPageLayouts = currentRichTextLayouts, richTextPageLayouts = currentRichTextLayouts,
textBoxes = textBoxes.toList(), textBoxes = visibleTextBoxes,
highlights = userHighlights.toList(), highlights = visibleUserHighlights,
bookId = currentBookId!! bookId = currentBookId!!
) )
} }
@ -2879,6 +2988,17 @@ fun PdfViewerScreen(
isDocumentReady = false isDocumentReady = false
errorMessage = null errorMessage = null
documentMetadataTitle = null documentMetadataTitle = null
currentBookId = null
areAnnotationsLoaded = false
loadedSidecarBookId = null
allAnnotations = emptyMap()
textBoxes.clear()
userHighlights.clear()
selectedTextBoxId = null
undoStack.clear()
redoStack.clear()
erasedAnnotationsFromStroke.clear()
drawingState.onDrawCancel()
if (showPasswordDialog) isPasswordError = false if (showPasswordDialog) isPasswordError = false
@ -2932,12 +3052,13 @@ fun PdfViewerScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri") Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri")
if (pdfUri.scheme != "opds-pse") { val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
if (pdfUri.scheme != "opds-pse" && selectedDocumentType == FileType.PDF) {
currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r") currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor") if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
} }
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore) val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
if (!isActive) { if (!isActive) {
doc.close() doc.close()
@ -3027,7 +3148,7 @@ fun PdfViewerScreen(
currentBookId!!, currentBookId!!,
DocumentCacheItem( DocumentCacheItem(
doc = doc, doc = doc,
pfd = currentPfdOpened!!, pfd = currentPfdOpened,
totalPages = pagesCount, totalPages = pagesCount,
pageAspectRatios = ratios, pageAspectRatios = ratios,
flatTableOfContents = flatTableOfContents flatTableOfContents = flatTableOfContents
@ -3094,8 +3215,8 @@ fun PdfViewerScreen(
isLoadingDocument = false isLoadingDocument = false
} }
} else { } else {
Timber.e(e, "Error loading PDF document") Timber.e(e, "Error loading fixed-layout document")
errorMessage = "Error loading PDF: ${e.localizedMessage}" errorMessage = "Error loading document: ${e.localizedMessage}"
isLoadingDocument = false isLoadingDocument = false
} }
if (pdfDocument == null) { if (pdfDocument == null) {
@ -3132,8 +3253,14 @@ fun PdfViewerScreen(
summarizationResult = null summarizationResult = null
} }
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage, displayMode, isScrollLocked, lockedState) {
currentPageScale = 1f val nextPageScale = currentPageScaleAfterPdfPageChange(
displayMode = displayMode,
isScrollLocked = isScrollLocked,
lockedState = lockedState,
currentActiveScale = currentActiveScale
)
currentPageScale = nextPageScale
ocrUsedForCurrentPageTts = false ocrUsedForCurrentPageTts = false
} }
@ -3172,7 +3299,8 @@ fun PdfViewerScreen(
LaunchedEffect(effectivePdfUri, currentBookId, totalPages) { LaunchedEffect(effectivePdfUri, currentBookId, totalPages) {
if (currentBookId == null || totalPages == 0) return@LaunchedEffect if (currentBookId == null || totalPages == 0) return@LaunchedEffect
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect val selectedDocumentType = uiState.selectedFileType ?: return@LaunchedEffect
if (selectedDocumentType != FileType.PDF && selectedDocumentType != FileType.PPTX) return@LaunchedEffect
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!) val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!)
@ -3183,6 +3311,7 @@ fun PdfViewerScreen(
isBackgroundIndexing = true isBackgroundIndexing = true
var bgPfd: ParcelFileDescriptor? = null var bgPfd: ParcelFileDescriptor? = null
var bgDoc: PdfDocumentKt? = null var bgDoc: PdfDocumentKt? = null
var genericDoc: ReaderDocument? = null
try { try {
val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!) val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!)
@ -3199,17 +3328,18 @@ fun PdfViewerScreen(
"Indexer: Starting background indexing for ${totalPages - existingPages.size} pages." "Indexer: Starting background indexing for ${totalPages - existingPages.size} pages."
) )
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
val openedBgPfd = bgPfd
if (openedBgPfd != null) {
bgDoc = PdfiumEngineProvider.withPdfium {
pdfiumCore.newDocument(openedBgPfd, documentPassword)
}
val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) } val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
val totalToDo = pagesToIndex.size val totalToDo = pagesToIndex.size
var completed = 0 var completed = 0
if (selectedDocumentType == FileType.PDF) {
bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
val openedBgPfd = bgPfd
if (openedBgPfd == null) return@withContext
bgDoc = PdfiumEngineProvider.withPdfium {
pdfiumCore.newDocument(openedBgPfd, documentPassword)
}
for (pageIndex in pagesToIndex) { for (pageIndex in pagesToIndex) {
if (!isActive) break if (!isActive) break
@ -3223,6 +3353,37 @@ fun PdfViewerScreen(
Timber.e(e, "Indexer: Failed on page $pageIndex") Timber.e(e, "Indexer: Failed on page $pageIndex")
} }
completed++
if (completed % 5 == 0 || completed == totalToDo) {
val totalIndexedSoFar = initialIndexedCount + completed
backgroundIndexingProgress =
totalIndexedSoFar.toFloat() / totalPages.toFloat()
}
}
} else {
val openedGenericDoc = DocumentFactory.loadDocument(
context = context,
uri = effectivePdfUri,
type = selectedDocumentType,
password = null,
pdfiumCore = pdfiumCore
)
genericDoc = openedGenericDoc
for (pageIndex in pagesToIndex) {
if (!isActive) break
try {
pdfTextRepository.indexReaderPage(
bookId = currentBookId!!,
document = openedGenericDoc,
pageIndex = pageIndex,
onOcrModelDownloading = { isOcrModelDownloading = true }
)
} catch (e: Exception) {
Timber.e(e, "Indexer: Failed on page $pageIndex")
}
completed++ completed++
if (completed % 5 == 0 || completed == totalToDo) { if (completed % 5 == 0 || completed == totalToDo) {
val totalIndexedSoFar = initialIndexedCount + completed val totalIndexedSoFar = initialIndexedCount + completed
@ -3238,6 +3399,7 @@ fun PdfViewerScreen(
PdfiumEngineProvider.withPdfium { PdfiumEngineProvider.withPdfium {
bgDoc?.close() bgDoc?.close()
} }
genericDoc?.close()
bgPfd?.close() bgPfd?.close()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Indexer: Cleanup failed") Timber.e(e, "Indexer: Cleanup failed")
@ -3510,9 +3672,10 @@ fun PdfViewerScreen(
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
PdfNavigationDrawerContent( PdfNavigationDrawerContent(
pdfDocument = pdfDocument, pdfDocument = pdfDocument,
documentKey = activeDocumentRenderKey,
flatTableOfContents = flatTableOfContents, flatTableOfContents = flatTableOfContents,
bookmarks = bookmarks, bookmarks = bookmarks,
userHighlights = userHighlights, userHighlights = visibleUserHighlights,
currentPage = currentPage, currentPage = currentPage,
totalPages = totalDisplayPages, totalPages = totalDisplayPages,
customHighlightColors = customHighlightColors, customHighlightColors = customHighlightColors,
@ -3649,7 +3812,7 @@ fun PdfViewerScreen(
} }
pdfDocument != null && totalPages > 0 -> { pdfDocument != null && totalPages > 0 -> {
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) } val stablePdfDocument = remember(activeDocumentRenderKey, pdfDocument) { StableHolder(pdfDocument!!) }
when (displayMode) { when (displayMode) {
DisplayMode.PAGINATION -> { DisplayMode.PAGINATION -> {
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset -> val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
@ -3664,7 +3827,11 @@ fun PdfViewerScreen(
tapOffset.x < oneQuarterWidthPx -> { tapOffset.x < oneQuarterWidthPx -> {
coroutineScope.launch { coroutineScope.launch {
val targetPage = val targetPage =
if (rightToLeftPagination) {
(pagerState.currentPage + 1).coerceAtMost(pagerState.pageCount - 1)
} else {
(pagerState.currentPage - 1).coerceAtLeast(0) (pagerState.currentPage - 1).coerceAtLeast(0)
}
if (targetPage != pagerState.currentPage) { if (targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage) pagerState.scrollToPage(targetPage)
} }
@ -3675,9 +3842,13 @@ fun PdfViewerScreen(
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> { tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
coroutineScope.launch { coroutineScope.launch {
val targetPage = val targetPage =
if (rightToLeftPagination) {
(pagerState.currentPage - 1).coerceAtLeast(0)
} else {
(pagerState.currentPage + 1).coerceAtMost( (pagerState.currentPage + 1).coerceAtMost(
pagerState.pageCount - 1 pagerState.pageCount - 1
) )
}
if (targetPage != pagerState.currentPage) { if (targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage) pagerState.scrollToPage(targetPage)
} }
@ -3694,14 +3865,14 @@ fun PdfViewerScreen(
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
key = { it }, key = { page -> "$activeDocumentRenderKey:$page" },
beyondViewportPageCount = dynamicBeyondViewportPageCount, beyondViewportPageCount = dynamicBeyondViewportPageCount,
reverseLayout = rightToLeftPagination,
userScrollEnabled = run { userScrollEnabled = run {
val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) &&
SideEffect { !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) &&
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)") !isPageSliderVisible &&
} paginationDraggingBoxId == null
enabled
} }
) { pageIndex -> ) { pageIndex ->
val isVisiblePage = remember(pagerState.currentPage, pageIndex) { val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
@ -3881,6 +4052,7 @@ fun PdfViewerScreen(
PdfPageComposable( PdfPageComposable(
pdfDocument = stablePdfDocument, pdfDocument = stablePdfDocument,
documentKey = activeDocumentRenderKey,
pageIndex = pageIndex, pageIndex = pageIndex,
virtualPage = virtualPage, virtualPage = virtualPage,
totalPages = totalDisplayPages, totalPages = totalDisplayPages,
@ -3923,6 +4095,7 @@ fun PdfViewerScreen(
isBookmarked = isPageBookmarked, isBookmarked = isPageBookmarked,
onBookmarkClick = { onToggleBookmark(pageIndex) }, onBookmarkClick = { onToggleBookmark(pageIndex) },
isZoomEnabled = true, isZoomEnabled = true,
showPageNumberOverlay = showPageNumberOverlay,
clearSelectionTrigger = selectionClearTrigger, clearSelectionTrigger = selectionClearTrigger,
resetZoomTrigger = resetZoomTrigger, resetZoomTrigger = resetZoomTrigger,
pageAnnotations = pageAnnotationsProvider, pageAnnotations = pageAnnotationsProvider,
@ -3961,7 +4134,7 @@ fun PdfViewerScreen(
onOcrModelDownloading = { onOcrModelDownloading = {
isOcrModelDownloading = true isOcrModelDownloading = true
}, },
userHighlights = userHighlights.filter { it.pageIndex == pageIndex }, userHighlights = visibleUserHighlightsByPage[pageIndex].orEmpty(),
onHighlightAdd = onHighlightAdd, onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate, onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete, onHighlightDelete = onHighlightDelete,
@ -3980,7 +4153,12 @@ fun PdfViewerScreen(
detectSpeechBubblesForPage(sourcePageIndex, bitmap) detectSpeechBubblesForPage(sourcePageIndex, bitmap)
}, },
onShowPanelPopup = { bitmapWithRects -> onShowPanelPopup = { bitmapWithRects ->
poppedUpPanelBitmap = bitmapWithRects val safeBitmap = bitmapWithRects.scaledToCanvasLimit()
if (safeBitmap !== bitmapWithRects && !bitmapWithRects.isRecycled) {
bitmapWithRects.recycle()
}
poppedUpPanelBitmap?.takeUnless { it.isRecycled }?.recycle()
poppedUpPanelBitmap = safeBitmap
}, },
onTwoFingerSwipe = { direction -> onTwoFingerSwipe = { direction ->
coroutineScope.launch { coroutineScope.launch {
@ -3999,7 +4177,7 @@ fun PdfViewerScreen(
isAutoScrollPlaying = isAutoScrollPlaying, isAutoScrollPlaying = isAutoScrollPlaying,
isHighlighterSnapEnabled = isHighlighterSnapEnabled, isHighlighterSnapEnabled = isHighlighterSnapEnabled,
isEditMode = isDrawingActive, isEditMode = isDrawingActive,
textBoxes = textBoxes.filter { it.pageIndex == pageIndex }, textBoxes = visibleTextBoxesByPage[pageIndex].orEmpty(),
selectedTextBoxId = selectedTextBoxId, selectedTextBoxId = selectedTextBoxId,
onTextBoxChange = { updatedBox -> onTextBoxChange = { updatedBox ->
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id } val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
@ -4326,7 +4504,7 @@ fun PdfViewerScreen(
Box(modifier = Modifier Box(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RectangleShape)) { .clip(RectangleShape)) {
val docHolder = remember(pdfDocument) { val docHolder = remember(activeDocumentRenderKey, pdfDocument) {
StableHolder(pdfDocument!!) StableHolder(pdfDocument!!)
} }
val bookmarksHolder = val bookmarksHolder =
@ -4338,6 +4516,7 @@ fun PdfViewerScreen(
PdfVerticalReader( PdfVerticalReader(
state = verticalReaderState, state = verticalReaderState,
pdfDocument = docHolder, pdfDocument = docHolder,
documentKey = activeDocumentRenderKey,
activeTheme = activeTheme, activeTheme = activeTheme,
activeTextureAlpha = 1f - globalTextureTransparency, activeTextureAlpha = 1f - globalTextureTransparency,
excludeImages = excludeImages, excludeImages = excludeImages,
@ -4364,7 +4543,8 @@ fun PdfViewerScreen(
onSearchText = onSearchTextStable, onSearchText = onSearchTextStable,
ttsHighlightData = ttsHighlightData, ttsHighlightData = ttsHighlightData,
ttsReadingPage = ttsDisplayPageIndex, ttsReadingPage = ttsDisplayPageIndex,
userHighlights = userHighlights, userHighlights = visibleUserHighlights,
userHighlightsByPage = visibleUserHighlightsByPage,
onHighlightAdd = onHighlightAdd, onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate, onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete, onHighlightDelete = onHighlightDelete,
@ -4419,7 +4599,8 @@ fun PdfViewerScreen(
isStylusOnlyMode = isStylusOnlyMode, isStylusOnlyMode = isStylusOnlyMode,
stylusButtonHovering = stylusButtonHovering, stylusButtonHovering = stylusButtonHovering,
isEditMode = isDrawingActive, isEditMode = isDrawingActive,
textBoxes = textBoxes, textBoxes = visibleTextBoxes,
textBoxesByPage = visibleTextBoxesByPage,
selectedTextBoxId = selectedTextBoxId, selectedTextBoxId = selectedTextBoxId,
onTextBoxChange = { updatedBox -> onTextBoxChange = { updatedBox ->
val idx = textBoxes.indexOfFirst { it.id == updatedBox.id } val idx = textBoxes.indexOfFirst { it.id == updatedBox.id }
@ -4444,6 +4625,8 @@ fun PdfViewerScreen(
autoScrollSpeed = autoScrollSpeed * 0.5f, autoScrollSpeed = autoScrollSpeed * 0.5f,
onInteractionListener = onAutoScrollInteraction, onInteractionListener = onAutoScrollInteraction,
lockedState = lockedState, lockedState = lockedState,
showPageGap = showVerticalPageGap,
showPageNumberOverlay = showPageNumberOverlay,
onZoomAndPanChanged = { newScale, newOffset -> onZoomAndPanChanged = { newScale, newOffset ->
currentActiveScale = newScale currentActiveScale = newScale
currentActiveOffset = newOffset currentActiveOffset = newOffset
@ -5030,6 +5213,7 @@ fun PdfViewerScreen(
isScrollLocked = isScrollLocked, isScrollLocked = isScrollLocked,
isEditMode = isEditMode, isEditMode = isEditMode,
displayMode = displayMode, displayMode = displayMode,
isRightToLeftPagination = rightToLeftPagination,
isKeepScreenOn = isKeepScreenOn, isKeepScreenOn = isKeepScreenOn,
isTtsSessionActive = isTtsSessionActive, isTtsSessionActive = isTtsSessionActive,
isBookmarked = isBookmarked, isBookmarked = isBookmarked,
@ -5084,6 +5268,7 @@ fun PdfViewerScreen(
} }
}, },
onShowVisualOptions = { showVisualOptionsSheet = true }, onShowVisualOptions = { showVisualOptionsSheet = true },
onShowScreenOrientation = { showScreenOrientationSheet = true },
isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading, isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading,
showAllTextHighlights = showAllTextHighlights, showAllTextHighlights = showAllTextHighlights,
isHighlightingLoading = isHighlightingLoading, isHighlightingLoading = isHighlightingLoading,
@ -5100,6 +5285,10 @@ fun PdfViewerScreen(
saveTapToNavigateSetting(context, tapToNavigateEnabled) saveTapToNavigateSetting(context, tapToNavigateEnabled)
}, },
onChangeDisplayMode = { displayMode = it }, onChangeDisplayMode = { displayMode = it },
onSetRightToLeftPagination = { enabled ->
rightToLeftPagination = enabled
savePdfRightToLeftPagination(context, enabled)
},
onToggleKeepScreenOn = { onToggleKeepScreenOn = {
isKeepScreenOn = !isKeepScreenOn isKeepScreenOn = !isKeepScreenOn
saveKeepScreenOn(context, isKeepScreenOn) saveKeepScreenOn(context, isKeepScreenOn)
@ -5215,7 +5404,11 @@ fun PdfViewerScreen(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(R.string.msg_indexing_pages_progress), text = stringResource(
R.string.msg_indexing_pages_progress,
(backgroundIndexingProgress * 100f).roundToInt()
.coerceIn(0, 100)
),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
@ -5427,6 +5620,8 @@ fun PdfViewerScreen(
onShowAiHub = showPdfAiHub, onShowAiHub = showPdfAiHub,
onToggleEditMode = togglePdfEditMode, onToggleEditMode = togglePdfEditMode,
onToggleTts = togglePdfTts, onToggleTts = togglePdfTts,
onShowScreenOrientation = { showScreenOrientationSheet = true },
showBubbleZoom = isComicFile,
isBubbleZoomModeActive = isBubbleZoomModeActive, isBubbleZoomModeActive = isBubbleZoomModeActive,
onToggleBubbleZoom = { onToggleBubbleZoom = {
if (isOss) { if (isOss) {
@ -6166,8 +6361,6 @@ fun PdfViewerScreen(
} }
) )
} }
CustomTopBanner(bannerMessage = bannerMessage)
} }
} }
} }
@ -6314,6 +6507,17 @@ fun PdfViewerScreen(
// --- PANEL POPUP --- // --- PANEL POPUP ---
if (poppedUpPanelBitmap != null) { if (poppedUpPanelBitmap != null) {
val sourcePanelBitmap = poppedUpPanelBitmap
val displayPanelBitmap = remember(sourcePanelBitmap) {
sourcePanelBitmap?.scaledToCanvasLimit()
}
DisposableEffect(sourcePanelBitmap, displayPanelBitmap) {
onDispose {
if (displayPanelBitmap != null && displayPanelBitmap !== sourcePanelBitmap && !displayPanelBitmap.isRecycled) {
displayPanelBitmap.recycle()
}
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -6327,8 +6531,9 @@ fun PdfViewerScreen(
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
displayPanelBitmap?.takeUnless { it.isRecycled }?.let { panelBitmap ->
Image( Image(
bitmap = poppedUpPanelBitmap!!.asImageBitmap(), bitmap = panelBitmap.asImageBitmap(),
contentDescription = stringResource(R.string.content_desc_annotated_page), contentDescription = stringResource(R.string.content_desc_annotated_page),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -6336,6 +6541,7 @@ fun PdfViewerScreen(
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Fit contentScale = ContentScale.Fit
) )
}
IconButton( IconButton(
onClick = { onClick = {
@ -6832,7 +7038,7 @@ fun PdfViewerScreen(
onClick = { onClick = {
showShareDialog = false showShareDialog = false
isShareLoading = true isShareLoading = true
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}") Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
val filename = getSuggestedFilename( val filename = getSuggestedFilename(
originalFileName, isAnnotated = true originalFileName, isAnnotated = true
) )
@ -6842,10 +7048,10 @@ fun PdfViewerScreen(
viewModel.sharePdf( viewModel.sharePdf(
activityContext = context, activityContext = context,
sourceUri = effectivePdfUri, sourceUri = effectivePdfUri,
annotations = allAnnotations, annotations = visibleAllAnnotations,
richTextPageLayouts = currentRichTextLayouts, richTextPageLayouts = currentRichTextLayouts,
textBoxes = textBoxes.toList(), textBoxes = visibleTextBoxes,
highlights = userHighlights.toList(), highlights = visibleUserHighlights,
includeAnnotations = true, includeAnnotations = true,
filename = filename, filename = filename,
bookId = currentBookId bookId = currentBookId
@ -6869,7 +7075,7 @@ fun PdfViewerScreen(
viewModel.sharePdf( viewModel.sharePdf(
activityContext = context, activityContext = context,
sourceUri = pdfUri, sourceUri = pdfUri,
annotations = allAnnotations, annotations = emptyMap(),
includeAnnotations = false, includeAnnotations = false,
filename = filename filename = filename
) )
@ -6917,13 +7123,33 @@ fun PdfViewerScreen(
if (showVisualOptionsSheet) { if (showVisualOptionsSheet) {
PdfVisualOptionsSheet( PdfVisualOptionsSheet(
systemUiMode = systemUiMode, systemUiMode = systemUiMode,
showVerticalPageGap = showVerticalPageGap,
showPageNumberOverlay = showPageNumberOverlay,
onSystemUiModeChange = { mode -> onSystemUiModeChange = { mode ->
systemUiMode = mode systemUiMode = mode
savePdfSystemUiMode(context, mode) savePdfSystemUiMode(context, mode)
}, },
onShowVerticalPageGapChange = { isVisible ->
showVerticalPageGap = isVisible
savePdfVerticalPageGapVisible(context, isVisible)
},
onShowPageNumberOverlayChange = { isVisible ->
showPageNumberOverlay = isVisible
savePdfPageNumberOverlayVisible(context, isVisible)
},
onDismiss = { showVisualOptionsSheet = false } onDismiss = { showVisualOptionsSheet = false }
) )
} }
if (showScreenOrientationSheet) {
ReaderScreenOrientationSheet(
selectedMode = screenOrientationMode,
onModeSelected = { mode ->
screenOrientationMode = mode
saveReaderScreenOrientationMode(context, mode)
},
onDismiss = { showScreenOrientationSheet = false }
)
}
if (showCustomizeToolsSheet) { if (showCustomizeToolsSheet) {
PdfCustomizeToolsSheet( PdfCustomizeToolsSheet(
hiddenTools = hiddenTools, hiddenTools = hiddenTools,

View file

@ -57,7 +57,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -267,18 +266,18 @@ class TextPaginationEngine {
dirtyGlobalIndex: Int = 0 dirtyGlobalIndex: Int = 0
): List<PageTextLayout> { ): List<PageTextLayout> {
val totalLen = globalText.length val totalLen = globalText.length
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " + "android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex" "margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
) )
if (totalLen == 0) { if (totalLen == 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0") Timber.d("android.paginate empty -> p0:0-0")
return listOf( return listOf(
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx) PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
) )
} }
if (pageWidthPx <= 0 || pageHeightPx <= 0) { if (pageWidthPx <= 0 || pageHeightPx <= 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size") Timber.d("android.paginate aborted invalid page size")
return emptyList() return emptyList()
} }
@ -320,7 +319,7 @@ class TextPaginationEngine {
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]" " Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
} }
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog") Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}") Timber.d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
return resultLayouts return resultLayouts
} }
@ -350,7 +349,7 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
pageHeightPx = pageHeightPx pageHeightPx = pageHeightPx
) )
) )
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd" "android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
) )
return nextPageIndex + 1 return nextPageIndex + 1
@ -398,11 +397,11 @@ private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
) )
) )
if (isLastContentPage && explicitBreakEnd != null) { if (isLastContentPage && explicitBreakEnd != null) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd" "android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
) )
} else if (!fitsOnPage) { } else if (!fitsOnPage) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex" "android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
) )
} }
@ -475,12 +474,12 @@ class PdfRichTextRepository(private val context: Context) {
suspend fun load(bookId: String) { suspend fun load(bookId: String) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val file = getFile(bookId) val file = getFile(bookId)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}" "android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
) )
if (!file.exists()) { if (!file.exists()) {
_document.value = GlobalRichDocument("", emptyList()) _document.value = GlobalRichDocument("", emptyList())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId") Timber.d("android.repository.load missing -> empty book=$bookId")
return@withContext return@withContext
} }
try { try {
@ -508,11 +507,11 @@ class PdfRichTextRepository(private val context: Context) {
) )
} }
_document.value = GlobalRichDocument(text, spans) _document.value = GlobalRichDocument(text, spans)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}" "android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
) )
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId") Timber.e(e, "android.repository.load failed book=$bookId")
Timber.e(e, "Failed to load rich text doc") Timber.e(e, "Failed to load rich text doc")
_document.value = GlobalRichDocument("", emptyList()) _document.value = GlobalRichDocument("", emptyList())
} }
@ -523,7 +522,7 @@ class PdfRichTextRepository(private val context: Context) {
_document.value = document _document.value = document
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}" "android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
) )
val obj = JSONObject().apply { val obj = JSONObject().apply {
@ -548,11 +547,11 @@ class PdfRichTextRepository(private val context: Context) {
} }
val file = getFile(bookId) val file = getFile(bookId)
file.writeText(obj.toString()) file.writeText(obj.toString())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( Timber.d(
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}" "android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
) )
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId") Timber.e(e, "android.repository.save failed book=$bookId")
Timber.e(e, "Failed to save rich text doc") Timber.e(e, "Failed to save rich text doc")
} }
} }

View file

@ -14,6 +14,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import com.aryan.reader.FileType import com.aryan.reader.FileType
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.pptx.PptxDocumentWrapper
import io.legere.pdfiumandroid.api.Bookmark import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt import io.legere.pdfiumandroid.suspend.PdfPageKt
@ -79,7 +80,20 @@ object DocumentFactory {
val catalogId = uri.getQueryParameter("catalogId") val catalogId = uri.getQueryParameter("catalogId")
return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId) return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId)
} }
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { return if (type == FileType.PPTX) {
val cacheFile = File(context.cacheDir, "temp_pptx_${System.currentTimeMillis()}.pptx")
try {
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { input ->
cacheFile.outputStream().use { output -> input.copyTo(output) }
} ?: throw Exception("Failed to open PPTX")
}
} catch (e: Exception) {
runCatching { cacheFile.delete() }
throw e
}
PptxDocumentWrapper(cacheFile, deleteOnClose = true)
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}") val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { input -> context.contentResolver.openInputStream(uri)?.use { input ->

View file

@ -31,6 +31,7 @@ import com.aryan.reader.pdf.PdfUserHighlight
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.Locale import java.util.Locale
import java.util.UUID
data class PdfTextBox( data class PdfTextBox(
val id: String, val id: String,
@ -54,7 +55,9 @@ data class PdfAnnotation(
val pageIndex: Int, val pageIndex: Int,
val points: List<PdfPoint>, val points: List<PdfPoint>,
val color: Color, val color: Color,
val strokeWidth: Float val strokeWidth: Float,
val id: String = UUID.randomUUID().toString(),
val note: String? = null
) )
object AnnotationSerializer { object AnnotationSerializer {
@ -63,11 +66,15 @@ object AnnotationSerializer {
annotations.forEach { (_, list) -> annotations.forEach { (_, list) ->
list.forEach { annotation -> list.forEach { annotation ->
val obj = JSONObject() val obj = JSONObject()
obj.put("id", annotation.id)
obj.put("pageIndex", annotation.pageIndex) obj.put("pageIndex", annotation.pageIndex)
obj.put("annotationType", annotation.type.name) obj.put("annotationType", annotation.type.name)
obj.put("inkType", annotation.inkType.name) obj.put("inkType", annotation.inkType.name)
obj.put("color", annotation.color.toArgb()) obj.put("color", annotation.color.toArgb())
obj.put("strokeWidth", annotation.strokeWidth.toDouble()) obj.put("strokeWidth", annotation.strokeWidth.toDouble())
if (!annotation.note.isNullOrBlank()) {
obj.put("note", annotation.note)
}
val pointsArray = JSONArray() val pointsArray = JSONArray()
annotation.points.forEach { p -> annotation.points.forEach { p ->
@ -123,7 +130,10 @@ object AnnotationSerializer {
pageIndex = pageIndex, pageIndex = pageIndex,
points = points, points = points,
color = Color(colorInt), color = Color(colorInt),
strokeWidth = strokeWidth strokeWidth = strokeWidth,
id = obj.optString("id").takeIf { it.isNotBlank() }
?: UUID.randomUUID().toString(),
note = obj.optString("note").takeIf { it.isNotBlank() }
) )
if (!resultMap.containsKey(pageIndex)) { if (!resultMap.containsKey(pageIndex)) {

View file

@ -40,6 +40,8 @@ class PdfAnnotationRepository(private val context: Context) {
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}") Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
if (annotations.isEmpty()) { if (annotations.isEmpty()) {
val file = getFile(bookId)
if (file.exists()) file.delete()
return@withContext return@withContext
} }

View file

@ -114,6 +114,9 @@ interface PdfTextDao {
@Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId") @Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId")
suspend fun getIndexedPageIndices(bookId: String): List<Int> suspend fun getIndexedPageIndices(bookId: String): List<Int>
@Query("DELETE FROM pdf_search_index WHERE bookId = :bookId AND pageIndex = :pageIndex")
suspend fun deletePageText(bookId: String, pageIndex: Int)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPageText(entity: PdfSearchIndex) suspend fun insertPageText(entity: PdfSearchIndex)

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import com.aryan.reader.pdf.ReaderDocument
private const val TAG = "PdfSearchDiag" private const val TAG = "PdfSearchDiag"
@ -55,6 +56,11 @@ class PdfTextRepository(context: Context) {
private val dao = db.pdfTextDao() private val dao = db.pdfTextDao()
private val metaDao = db.pdfMetaDao() private val metaDao = db.pdfMetaDao()
private suspend fun replacePageText(bookId: String, pageIndex: Int, content: String) {
dao.deletePageText(bookId, pageIndex)
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = content))
}
suspend fun getPageRatios(bookId: String): List<Float>? { suspend fun getPageRatios(bookId: String): List<Float>? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val meta = metaDao.getMetadata(bookId) val meta = metaDao.getMetadata(bookId)
@ -250,7 +256,7 @@ class PdfTextRepository(context: Context) {
Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean") Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean")
} else if (text.isNotBlank()) { } else if (text.isNotBlank()) {
Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).") Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).")
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = text)) replacePageText(bookId = bookId, pageIndex = pageIndex, content = text)
} else { } else {
Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.") Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.")
} }
@ -279,6 +285,69 @@ class PdfTextRepository(context: Context) {
} }
} }
suspend fun indexReaderPage(
bookId: String,
document: ReaderDocument,
pageIndex: Int,
onOcrModelDownloading: () -> Unit = {}
): Boolean {
return withContext(Dispatchers.IO) {
var text = ""
var ocrUsed = false
try {
document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars()
if (count > 0) {
text = textPage.textPageGetText(0, count).orEmpty()
}
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "ReaderDocument extraction failed for page $pageIndex")
}
if (text.isBlank()) {
var bitmap: android.graphics.Bitmap? = null
try {
document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080
val pageWidth = page.getPageWidthPoint()
val pageHeight = page.getPageHeightPoint()
if (pageWidth > 0 && pageHeight > 0) {
val aspectRatio = pageWidth.toFloat() / pageHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
}
}
bitmap?.let {
try {
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
text = visionText?.text.orEmpty()
ocrUsed = true
} finally {
it.recycle()
bitmap = null
}
}
} catch (e: Exception) {
bitmap?.recycle()
Timber.tag(TAG).e(e, "ReaderDocument OCR failed for page $pageIndex")
}
}
val cleaned = cleanIndexedText(text)
if (cleaned.isNotBlank()) {
replacePageText(bookId = bookId, pageIndex = pageIndex, content = cleaned)
}
ocrUsed && cleaned.isNotEmpty()
}
}
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean { suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
@ -599,4 +668,17 @@ class PdfTextRepository(context: Context) {
return null return null
} }
private fun cleanIndexedText(raw: String): String {
if (raw.isBlank()) return ""
var text = raw
val patterns = listOf(
Regex("(?i)file:/?/?/?\\S+"),
Regex("(?i)/data/user/\\d+/\\S+"),
Regex("(?i)/storage/emulated/\\d+/\\S+"),
Regex("(?i)\\S*com\\.aryan\\.reader\\S*")
)
patterns.forEach { pattern -> text = text.replace(pattern, " ") }
return text.replace(Regex("\\s+"), " ").trim()
}
} }

File diff suppressed because it is too large Load diff

View file

@ -83,7 +83,9 @@ private const val PREFETCH_LOOKAHEAD = 3
class TtsPlaybackManager( class TtsPlaybackManager(
private val player: Player, private val player: Player,
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData, private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
private val onResetContext: () -> Unit private val onResetContext: () -> Unit,
private val onPlaybackSessionPreparing: (bookTitle: String?, chapterTitle: String?) -> Unit = { _, _ -> },
private val onPlaybackSessionStopped: () -> Unit = {}
) : MediaSession.Callback, Player.Listener { ) : MediaSession.Callback, Player.Listener {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -415,6 +417,8 @@ class TtsPlaybackManager(
handleStopTts(clearState = false) handleStopTts(clearState = false)
} }
onPlaybackSessionPreparing(bookTitle, chapterTitle)
textChunks = chunks textChunks = chunks
currentSpeakerId = speakerId currentSpeakerId = speakerId
currentTtsMode = ttsMode currentTtsMode = ttsMode
@ -550,6 +554,7 @@ class TtsPlaybackManager(
val firstChunk = textChunks.getOrNull(startAtIndex) val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) { if (firstChunk == null) {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.") _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
onPlaybackSessionStopped()
return return
} }
@ -627,6 +632,7 @@ class TtsPlaybackManager(
isLoading = false, isLoading = false,
errorMessage = ttsAudioData.error ?: "Failed to load audio." errorMessage = ttsAudioData.error ?: "Failed to load audio."
) )
onPlaybackSessionStopped()
} }
} }
@ -667,6 +673,7 @@ class TtsPlaybackManager(
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated" "handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
) )
onPlaybackSessionStopped()
onResetContext() onResetContext()
preparationJob?.cancel() preparationJob?.cancel()
wordTrackingJob?.cancel() wordTrackingJob?.cancel()

View file

@ -20,9 +20,15 @@
package com.aryan.reader.tts package com.aryan.reader.tts
import android.Manifest import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
@ -30,13 +36,16 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.aryan.reader.R
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.loadAiByokSettings import com.aryan.reader.loadAiByokSettings
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.Job
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
@ -229,6 +238,11 @@ class InputStreamDataSource : androidx.media3.datasource.BaseDataSource(true) {
} }
} }
private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback"
// Keep this aligned with Media3's default notification ID so playback updates replace the fallback.
private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001
private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L
@UnstableApi @UnstableApi
class TtsService : MediaSessionService() { class TtsService : MediaSessionService() {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@ -237,6 +251,24 @@ class TtsService : MediaSessionService() {
private lateinit var playbackManager: TtsPlaybackManager private lateinit var playbackManager: TtsPlaybackManager
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
private lateinit var cacheManager: TtsCacheManager private lateinit var cacheManager: TtsCacheManager
private var foregroundNotificationShown = false
private var foregroundPlaybackExpected = false
private var foregroundIdleJob: Job? = null
private var foregroundBookTitle: String? = null
private var foregroundChapterTitle: String? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val result = super.onStartCommand(intent, flags, startId)
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onStartCommand. action=${intent?.action}, startId=$startId, result=$result"
)
val hasPreparedMedia = ::player.isInitialized && player.mediaItemCount > 0
if (!hasPreparedMedia) {
showPreparingForegroundNotification("onStartCommand")
scheduleForegroundIdleStop(startId)
}
return result
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
@ -252,21 +284,133 @@ class TtsService : MediaSessionService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
if (startInForegroundRequired) { "POST_NOTIFICATIONS is missing, but MediaSession notifications are exempt. Delegating notification update."
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().") )
stopSelf()
} else {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
}
return
} }
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.") Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
super.onUpdateNotification(session, startInForegroundRequired) super.onUpdateNotification(session, startInForegroundRequired)
} }
private fun showPreparingForegroundNotification(
reason: String,
bookTitle: String? = foregroundBookTitle,
chapterTitle: String? = foregroundChapterTitle
) {
foregroundBookTitle = bookTitle
foregroundChapterTitle = chapterTitle
ensureTtsNotificationChannel()
try {
val notification = buildPreparingForegroundNotification(bookTitle, chapterTitle)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
TTS_FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(TTS_FOREGROUND_NOTIFICATION_ID, notification)
}
foregroundNotificationShown = true
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Fallback foreground notification shown. reason=$reason")
} catch (e: Exception) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "Failed to show fallback foreground notification. reason=$reason")
stopSelf()
}
}
private fun buildPreparingForegroundNotification(bookTitle: String?, chapterTitle: String?): Notification {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val contentIntent = launchIntent?.let {
PendingIntent.getActivity(this, 0, it, pendingIntentFlags())
}
val title = bookTitle?.takeIf { it.isNotBlank() } ?: getString(R.string.app_name)
val text = chapterTitle?.takeIf { it.isNotBlank() }
?.let { getString(R.string.tts_notification_preparing_chapter, it) }
?: getString(R.string.tts_notification_preparing)
return NotificationCompat.Builder(this, TTS_FOREGROUND_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (contentIntent != null) {
setContentIntent(contentIntent)
}
}
.build()
}
private fun ensureTtsNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
TTS_FOREGROUND_CHANNEL_ID,
getString(R.string.tts_notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.tts_notification_channel_desc)
setShowBadge(false)
}
notificationManager.createNotificationChannel(channel)
}
private fun pendingIntentFlags(): Int {
return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
private fun scheduleForegroundIdleStop(startId: Int) {
foregroundIdleJob?.cancel()
foregroundIdleJob = scope.launch {
delay(TTS_FOREGROUND_IDLE_GRACE_MS)
val playbackInactive = !::player.isInitialized ||
(!player.isPlaying && !player.playWhenReady && player.mediaItemCount == 0)
if (!foregroundPlaybackExpected && playbackInactive) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
"Foreground service start did not become an active TTS session. Stopping fallback foreground."
)
stopTtsForeground()
stopSelf(startId)
}
}
}
private fun onPlaybackSessionPreparing(bookTitle: String?, chapterTitle: String?) {
foregroundPlaybackExpected = true
foregroundIdleJob?.cancel()
showPreparingForegroundNotification("START_TTS_COMMAND", bookTitle, chapterTitle)
}
private fun onPlaybackSessionStopped() {
foregroundPlaybackExpected = false
foregroundIdleJob?.cancel()
foregroundBookTitle = null
foregroundChapterTitle = null
stopTtsForeground()
}
private fun stopTtsForeground() {
if (!foregroundNotificationShown) return
try {
stopForeground(android.app.Service.STOP_FOREGROUND_REMOVE)
} catch (e: Exception) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(e, "Failed to stop fallback foreground notification.")
} finally {
foregroundNotificationShown = false
}
}
private val okHttpClient = OkHttpClient.Builder().build() private val okHttpClient = OkHttpClient.Builder().build()
private val liveClient by lazy { private val liveClient by lazy {
GeminiLiveClient(okHttpClient) { errorMsg -> GeminiLiveClient(okHttpClient) { errorMsg ->
@ -668,7 +812,9 @@ class TtsService : MediaSessionService() {
playbackManager = TtsPlaybackManager( playbackManager = TtsPlaybackManager(
player = player, player = player,
generateAudioChunk = audioGenerator, generateAudioChunk = audioGenerator,
onResetContext = { liveClient.close() } onResetContext = { liveClient.close() },
onPlaybackSessionPreparing = ::onPlaybackSessionPreparing,
onPlaybackSessionStopped = ::onPlaybackSessionStopped
) )
mediaSession = MediaSession.Builder(this, player) mediaSession = MediaSession.Builder(this, player)
@ -683,7 +829,7 @@ class TtsService : MediaSessionService() {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}" "onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
) )
if (!player.playWhenReady) { if (!::player.isInitialized || !player.playWhenReady) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().") Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
stopSelf() stopSelf()
} }
@ -700,13 +846,26 @@ class TtsService : MediaSessionService() {
override fun onDestroy() { override fun onDestroy() {
Timber.d("TtsService is being destroyed.") Timber.d("TtsService is being destroyed.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.") Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
foregroundIdleJob?.cancel()
stopTtsForeground()
if (::baseTtsSynthesizer.isInitialized) {
baseTtsSynthesizer.shutdown() baseTtsSynthesizer.shutdown()
}
if (::playbackManager.isInitialized) {
playbackManager.release() playbackManager.release()
mediaSession?.run { }
var playerReleased = false
mediaSession?.let { session ->
if (::player.isInitialized) {
player.release() player.release()
release() playerReleased = true
}
session.release()
mediaSession = null mediaSession = null
} }
if (!playerReleased && ::player.isInitialized) {
player.release()
}
super.onDestroy() super.onDestroy()
} }
} }

View file

@ -508,6 +508,7 @@
<string name="error_recent_item_not_found">Could not find recent item.</string> <string name="error_recent_item_not_found">Could not find recent item.</string>
<string name="error_import_file_failed">Failed to import file.</string> <string name="error_import_file_failed">Failed to import file.</string>
<string name="error_file_location_not_found">Could not find file location.</string> <string name="error_file_location_not_found">Could not find file location.</string>
<string name="error_unsupported_file_type">This file type is not supported.</string>
<!-- "Text view" is the name of the PDF reflow feature in this app. --> <!-- "Text view" is the name of the PDF reflow feature in this app. -->
<string name="error_load_generated_text_view">Failed to load generated text view.</string> <string name="error_load_generated_text_view">Failed to load generated text view.</string>
<!-- "Text view" is the name of the PDF reflow feature in this app. --> <!-- "Text view" is the name of the PDF reflow feature in this app. -->
@ -690,10 +691,10 @@
<string name="menu_view_original_pdf">View Original PDF</string> <string name="menu_view_original_pdf">View Original PDF</string>
<!-- "Text View" is the name of the PDF reflow feature in this app — keep consistent. --> <!-- "Text View" is the name of the PDF reflow feature in this app — keep consistent. -->
<string name="menu_delete_text_view">Delete Text View</string> <string name="menu_delete_text_view">Delete Text View</string>
<!-- Menu item to switch reading mode. The colon-separated format "Reading Mode: Vertical" is intentional — do not omit the colon. --> <!-- Menu item inside the reading mode submenu. -->
<string name="menu_reading_mode_vertical">Reading Mode: Vertical</string> <string name="menu_reading_mode_vertical">Vertical</string>
<!-- Menu item to switch reading mode. The colon-separated format "Reading Mode: Paginated" is intentional — do not omit the colon. --> <!-- Menu item inside the reading mode submenu. -->
<string name="menu_reading_mode_paginated">Reading Mode: Paginated</string> <string name="menu_reading_mode_paginated">Paginated (left-to-right)</string>
<!-- Accessibility label for a menu item that is currently active/checked. --> <!-- Accessibility label for a menu item that is currently active/checked. -->
<string name="content_desc_enabled">Enabled</string> <string name="content_desc_enabled">Enabled</string>
<string name="menu_remove_bookmark">Remove bookmark</string> <string name="menu_remove_bookmark">Remove bookmark</string>
@ -704,11 +705,17 @@
<string name="menu_realistic_page_turns">Realistic Page Turns</string> <string name="menu_realistic_page_turns">Realistic Page Turns</string>
<string name="menu_keep_screen_on">Keep Screen On</string> <string name="menu_keep_screen_on">Keep Screen On</string>
<string name="menu_visual_options">Visual Options</string> <string name="menu_visual_options">Visual Options</string>
<string name="menu_screen_orientation">Screen Orientation</string>
<string name="menu_change_reading_mode">Change Reading Mode</string>
<string name="menu_right_to_left_pagination">Paginated (right-to-left)</string>
<string name="menu_auto_scroll">Auto Scroll</string> <string name="menu_auto_scroll">Auto Scroll</string>
<!-- TTS = Text-to-Speech. --> <!-- TTS = Text-to-Speech. -->
<string name="menu_tts_settings">TTS Settings</string>
<!-- TTS = Text-to-Speech. -->
<string name="menu_tts_voice_settings">TTS Voice Settings</string> <string name="menu_tts_voice_settings">TTS Voice Settings</string>
<!-- TTS = Text-to-Speech. --> <!-- TTS = Text-to-Speech. -->
<string name="menu_tts_word_replacements">TTS Word Replacements</string> <string name="menu_tts_word_replacements">TTS Word Replacements</string>
<string name="menu_share_save_print">Share, Save or Print</string>
<!-- TTS = Text-to-Speech. Debug-only menu item. --> <!-- TTS = Text-to-Speech. Debug-only menu item. -->
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string> <string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
<string name="content_desc_navigate_slider">Navigate with slider</string> <string name="content_desc_navigate_slider">Navigate with slider</string>
@ -824,9 +831,16 @@
<string name="button_import_from_files">Import from Files</string> <string name="button_import_from_files">Import from Files</string>
<string name="no_imported_fonts_yet">No imported fonts yet.</string> <string name="no_imported_fonts_yet">No imported fonts yet.</string>
<string name="visual_options_title">Visual Options</string> <string name="visual_options_title">Visual Options</string>
<string name="visual_options_page_layout">Page layout</string>
<string name="visual_options_remove_page_gap">Remove gap between pages</string>
<string name="visual_options_remove_page_gap_desc">Applies to vertical reading mode.</string>
<string name="visual_options_hide_page_number_overlay">Hide page number overlay</string>
<string name="visual_options_hide_page_number_overlay_desc">Removes the small page count label from each page.</string>
<!-- "Status Bar" and "Navigation Bar" are standard Android UI terms — keep consistent with your OS locale if applicable. --> <!-- "Status Bar" and "Navigation Bar" are standard Android UI terms — keep consistent with your OS locale if applicable. -->
<string name="visual_options_system_ui">System UI (Status &amp; Navigation Bars)</string> <string name="visual_options_system_ui">System UI (Status &amp; Navigation Bars)</string>
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string> <string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
<string name="visual_options_screen_orientation">Screen Orientation</string>
<string name="visual_options_screen_orientation_desc">Choose whether the reader follows the system orientation or prefers portrait or landscape when Android allows it.</string>
<string name="visual_options_progress_bar">Progress Bar</string> <string name="visual_options_progress_bar">Progress Bar</string>
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator on the reading screen.</string> <string name="visual_options_progress_bar_desc">The reading progress and chapter indicator on the reading screen.</string>
<string name="visual_options_progress_bar_position">Position</string> <string name="visual_options_progress_bar_position">Position</string>
@ -1164,6 +1178,11 @@
<string name="section_files">Files</string> <string name="section_files">Files</string>
<!-- TTS settings sheet labels. TTS = Text-to-Speech. --> <!-- TTS settings sheet labels. TTS = Text-to-Speech. -->
<string name="tts_notification_channel_name">Text to speech</string>
<string name="tts_notification_channel_desc">Playback controls for text to speech.</string>
<string name="tts_notification_preparing">Preparing text to speech</string>
<!-- Notification text. %1$s = chapter title. -->
<string name="tts_notification_preparing_chapter">Preparing: %1$s</string>
<string name="tts_active_engine">Active TTS Engine</string> <string name="tts_active_engine">Active TTS Engine</string>
<string name="tts_mode_cloud_ai">Cloud AI</string> <string name="tts_mode_cloud_ai">Cloud AI</string>
<string name="tts_mode_device_native">Device Native</string> <string name="tts_mode_device_native">Device Native</string>

View file

@ -14,6 +14,7 @@ data class ProUpgradeState(
val creditProducts: List<ProductDetailsEntity> = emptyList(), val creditProducts: List<ProductDetailsEntity> = emptyList(),
val hasValidPurchase: Boolean = false, val hasValidPurchase: Boolean = false,
val activePurchases: List<PurchaseEntity> = emptyList(), val activePurchases: List<PurchaseEntity> = emptyList(),
val hasAccountConflict: Boolean = false,
val billingClientReady: Boolean = false, val billingClientReady: Boolean = false,
val error: String? = null, val error: String? = null,
val isVerifying: Boolean = false val isVerifying: Boolean = false
@ -35,7 +36,11 @@ class BillingClientWrapper(
// No-op // No-op
} }
fun launchPurchaseFlow(activity: Activity, productId: String = PRO_LIFETIME_PRODUCT_ID) { fun launchPurchaseFlow(
activity: Activity,
productId: String = PRO_LIFETIME_PRODUCT_ID,
obfuscatedAccountId: String? = null
) {
_proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version") _proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version")
} }
fun consumePurchase(purchaseToken: String) {} fun consumePurchase(purchaseToken: String) {}
@ -44,6 +49,14 @@ class BillingClientWrapper(
_proUpgradeState.value = _proUpgradeState.value.copy(error = null) _proUpgradeState.value = _proUpgradeState.value.copy(error = null)
} }
fun markAccountConflict() {
_proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = true, isVerifying = false)
}
fun clearAccountConflict() {
_proUpgradeState.value = _proUpgradeState.value.copy(hasAccountConflict = false)
}
fun clearVerificationState() { fun clearVerificationState() {
_proUpgradeState.value = _proUpgradeState.value.copy(isVerifying = false) _proUpgradeState.value = _proUpgradeState.value.copy(isVerifying = false)
} }

View file

@ -23,8 +23,17 @@ data class BookMetadata(
val lastModifiedTimestamp: Long = 0L, val lastModifiedTimestamp: Long = 0L,
val bookmarksJson: String? = null, val bookmarksJson: String? = null,
val hasAnnotations: Boolean = false, val hasAnnotations: Boolean = false,
val fileContentModifiedTimestamp: Long = 0L,
val customName: String? = null, val customName: String? = null,
val highlightsJson: String? = null val highlightsJson: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
val originalTitle: String? = null,
val originalAuthor: String? = null,
val originalSeriesName: String? = null,
val originalSeriesIndex: Double? = null,
val originalDescription: String? = null
) )
data class DeviceItem( data class DeviceItem(

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name" translatable="false">Episteme (OSS)</string> <string name="app_name">Episteme oss</string>
</resources> </resources>

View file

@ -0,0 +1,195 @@
package com.aryan.reader
import com.aryan.reader.shared.SharedSettingsAction
import com.aryan.reader.shared.SharedSettingsDestination
import com.aryan.reader.shared.SharedSettingsHubModel
import com.aryan.reader.shared.SharedSettingsItemModel
import com.aryan.reader.shared.sharedSettingsHubModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidSettingsHubModelsTest {
@Test
fun `offline android settings hide network backed sections`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = true,
isOfflineBuild = true,
isDebugBuild = false
)
)
val actions = model.visibleNestedActions()
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
assertFalse(SharedSettingsAction.HIDE_READER_AI in actions)
assertFalse(SharedSettingsAction.SIGN_IN in actions)
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
assertFalse(SharedSettingsAction.FOLDER_SYNC in actions)
assertFalse(SharedSettingsAction.HELP_FEEDBACK in actions)
assertFalse(SharedSettingsAction.SUPPORT in actions)
assertTrue(SharedSettingsAction.TTS_SETTINGS in actions)
assertTrue(SharedSettingsAction.CUSTOM_FONTS in actions)
}
@Test
fun `oss online settings hide sync rows but keep oss ai key settings`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(
currentUser = UserData(
uid = "user-id",
displayName = "Reader",
photoUrl = null,
email = "reader@example.com"
),
isProUser = true,
isSyncEnabled = true,
isFolderSyncEnabled = true
),
isOssBuild = true,
isOfflineBuild = false,
isDebugBuild = true
)
)
val actions = model.visibleNestedActions()
assertTrue(SharedSettingsAction.AI_SETTINGS in actions)
assertTrue(SharedSettingsAction.HIDE_READER_AI in actions)
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
assertFalse(SharedSettingsAction.FOLDER_SYNC in actions)
assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions)
assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions)
assertTrue(SharedSettingsAction.SUPPORT in actions)
assertEquals(
"TTS & AI",
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title
)
}
@Test
fun `non oss settings do not expose oss ai key settings`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(isProUser = true),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
)
val actions = model.visibleNestedActions()
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
assertTrue(SharedSettingsAction.HIDE_READER_AI in actions)
assertTrue(SharedSettingsAction.CLOUD_SYNC in actions)
assertFalse(SharedSettingsAction.SUPPORT in actions)
assertEquals(
"TTS",
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title
)
}
@Test
fun `android settings expose debug-only storage actions only in debug`() {
val releaseActions = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
).visibleNestedActions()
val debugActions = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = true
)
).visibleNestedActions()
assertFalse(SharedSettingsAction.EXPORT_LOGS in releaseActions)
assertTrue(SharedSettingsAction.EXPORT_LOGS in debugActions)
}
@Test
fun `android settings reflect global toggles from reader state`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(
isTabsEnabled = true,
useStrictFileFilter = true,
isScreenCaptureProtectionEnabled = true
),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
)
val toggles = model.visibleNestedItems().associateBy { it.action }
assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true)
}
@Test
fun `android extra settings expose home overflow actions without settings duplicate`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = true
)
)
val extraActions = model.page(SharedSettingsDestination.EXTRA).items.map { it.action }
assertTrue(SharedSettingsAction.TABS_TOGGLE in extraActions)
assertTrue(SharedSettingsAction.LANGUAGE in extraActions)
assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions)
assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions)
assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions)
assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions)
assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions)
assertTrue(SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION in extraActions)
assertTrue(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in extraActions)
}
@Test
fun `cloud sync row is gated by pro state`() {
val freeSync = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(isProUser = false),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC }
val proSync = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(isProUser = true),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC }
assertFalse(freeSync.enabled)
assertTrue(proSync.enabled)
}
}
private fun SharedSettingsHubModel.visibleNestedItems(): List<SharedSettingsItemModel> {
return rootCategories.flatMap { category ->
page(category.destination).items
}
}
private fun SharedSettingsHubModel.visibleNestedActions(): List<SharedSettingsAction> {
return visibleNestedItems().map { it.action }
}

View file

@ -0,0 +1,230 @@
package com.aryan.reader
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.AppAction as SharedAppAction
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidSharedStateBridgeTest {
@Test
fun `prepareLibraryProjection builds shared input and Android lookup context`() {
val tag = tag("tag", "Favorite")
val book = recentFile("book", sourceFolderUri = "content://folder")
val reflowCopy = recentFile("book_reflow", sourceFolderUri = "content://folder")
val context = AndroidSharedStateBridge.prepareLibraryProjection(
input = LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(book, reflowCopy),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = book.bookId, tagId = tag.id))
),
folderPathResolver = EmptyFolderPathResolver
)
assertEquals(listOf("book"), context.androidBooksById.keys.toList())
assertEquals(listOf("book"), context.sharedInput.booksFromStore.map { it.id })
assertEquals(listOf("tag"), context.sharedInput.booksFromStore.single().tags.map { it.id })
assertEquals(listOf(AndroidSharedFolderProjectionKey("content://folder", "Local Folder")), context.folderKeys)
}
@Test
fun `reduceLibraryAction applies shared library state back to Android fields`() {
val book = recentFile("book")
val filters = LibraryFilters(readStatus = ReadStatusFilter.COMPLETED)
val selected = AndroidSharedStateBridge.reduceLibraryAction(
current = ReaderScreenState(),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)),
action = SharedLibraryAction.BookSelectionToggled(book.bookId)
)
val filtered = AndroidSharedStateBridge.reduceLibraryAction(
current = selected,
projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)),
action = SharedLibraryAction.FiltersChanged(filters.toSharedLibraryFilters())
)
assertEquals(setOf(book), selected.contextualActionItems)
assertEquals(filters, filtered.libraryFilters)
}
@Test
fun `reduceLibraryAction drops selection ids that are not in projected Android books`() {
val result = AndroidSharedStateBridge.reduceLibraryAction(
current = ReaderScreenState(),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(recentFile("book"))),
action = SharedLibraryAction.BookSelectionToggled("missing")
)
assertTrue(result.contextualActionItems.isEmpty())
}
@Test
fun `reduceAppAction applies shared app state back to Android fields`() {
val result = AndroidSharedStateBridge.reduceAppAction(
current = ReaderScreenState(appThemeMode = AppThemeMode.LIGHT),
projectedState = ReaderScreenState(),
action = SharedAppAction.AppThemeChanged(SharedAppThemeMode.DARK)
)
assertEquals(AppThemeMode.DARK, result.appThemeMode)
}
@Test
fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() {
val result = AndroidSharedStateBridge.setTabsEnabled(
current = ReaderScreenState(
isTabsEnabled = true,
openTabIds = listOf("one", "two"),
activeTabBookId = "two"
),
projectedState = ReaderScreenState(),
enabled = false
)
assertEquals(false, result.isTabsEnabled)
assertEquals(listOf("two"), result.openTabIds)
assertEquals("two", result.activeTabBookId)
}
@Test
fun `openBookTab delegates tab ordering and activation to shared reducer`() {
val result = AndroidSharedStateBridge.openBookTab(
current = ReaderScreenState(
isTabsEnabled = false,
openTabIds = listOf("old"),
activeTabBookId = "old"
),
projectedState = ReaderScreenState(),
bookId = "new"
)
assertEquals(true, result.isTabsEnabled)
assertEquals(listOf("old", "new"), result.openTabIds)
assertEquals("new", result.activeTabBookId)
}
@Test
fun `closeBookTab selects the previous tab when the active tab closes`() {
val result = AndroidSharedStateBridge.closeBookTab(
current = ReaderScreenState(
isTabsEnabled = true,
openTabIds = listOf("one", "two", "three"),
activeTabBookId = "three"
),
projectedState = ReaderScreenState(),
bookId = "three"
)
assertEquals(true, result.isTabsEnabled)
assertEquals(listOf("one", "two"), result.openTabIds)
assertEquals("two", result.activeTabBookId)
}
@Test
fun `closeAllTabs clears Android tab ids through shared reducer`() {
val result = AndroidSharedStateBridge.closeAllTabs(
current = ReaderScreenState(
isTabsEnabled = true,
openTabIds = listOf("one", "two"),
activeTabBookId = "two"
),
projectedState = ReaderScreenState()
)
assertEquals(true, result.isTabsEnabled)
assertTrue(result.openTabIds.isEmpty())
assertEquals(null, result.activeTabBookId)
}
@Test
fun `togglePinsForSelectedBooks pins mixed home selection and clears selection`() {
val pinned = recentFile("pinned")
val unpinned = recentFile("unpinned")
val result = AndroidSharedStateBridge.togglePinsForSelectedBooks(
current = ReaderScreenState(
rawLibraryFiles = listOf(pinned, unpinned),
contextualActionItems = setOf(pinned, unpinned),
pinnedHomeBookIds = setOf(pinned.bookId)
),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(pinned, unpinned)),
isHome = true
)
assertEquals(setOf("pinned", "unpinned"), result.pinnedHomeBookIds)
assertTrue(result.contextualActionItems.isEmpty())
}
@Test
fun `togglePinsForSelectedBooks unpins when all selected library books are pinned`() {
val first = recentFile("first")
val second = recentFile("second")
val result = AndroidSharedStateBridge.togglePinsForSelectedBooks(
current = ReaderScreenState(
rawLibraryFiles = listOf(first, second),
contextualActionItems = setOf(first, second),
pinnedLibraryBookIds = setOf(first.bookId, second.bookId)
),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(first, second)),
isHome = false
)
assertTrue(result.pinnedLibraryBookIds.isEmpty())
assertTrue(result.contextualActionItems.isEmpty())
}
@Test
fun `replaceBookSelectionWithVisibleBooks selects visible books through shared reducer`() {
val visible = recentFile("visible")
val hidden = recentFile("hidden")
val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
current = ReaderScreenState(),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible, hidden)),
visibleBooks = listOf(visible)
)
assertEquals(setOf(visible), result.contextualActionItems)
}
@Test
fun `replaceBookSelectionWithVisibleBooks clears when visible books are already selected`() {
val visible = recentFile("visible")
val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
current = ReaderScreenState(contextualActionItems = setOf(visible)),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible)),
visibleBooks = listOf(visible)
)
assertTrue(result.contextualActionItems.isEmpty())
}
private fun recentFile(
id: String,
sourceFolderUri: String? = null
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = FileType.EPUB,
displayName = "$id.epub",
timestamp = 1L,
sourceFolderUri = sourceFolderUri
)
private fun tag(id: String, name: String) = TagEntity(
id = id,
name = name,
createdAt = 1L
)
}

View file

@ -0,0 +1,215 @@
package com.aryan.reader
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.Base64
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
class EmbeddedEbookMetadataExtractorTest {
@Test
fun `epub extracts text metadata and explicitly referenced cover image`() {
val coverBytes = onePixelPngBytes()
val epubBytes = zipBytes(
"META-INF/container.xml" to """
<container>
<rootfiles>
<rootfile full-path="OEBPS/content.opf"/>
</rootfiles>
</container>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Folder EPUB</dc:title>
<dc:creator>Octavia Butler</dc:creator>
<dc:description>&lt;p&gt;Folder summary&lt;/p&gt;</dc:description>
<meta content="Patternist" name="calibre:series"/>
<meta content="3" name="calibre:series_index"/>
<meta name="cover" content="cover-image"/>
</metadata>
<manifest>
<item id="cover-image" href="images/cover.png" media-type="image/png"/>
</manifest>
</package>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/images/cover.png" to coverBytes
)
val metadata = EmbeddedEbookMetadataExtractor.extract(
type = FileType.EPUB,
displayName = "folder.epub",
openStream = { ByteArrayInputStream(epubBytes) }
)
assertEquals("Folder EPUB", metadata.title)
assertEquals("Octavia Butler", metadata.author)
assertEquals("<p>Folder summary</p>", metadata.description)
assertEquals("Patternist", metadata.seriesName)
assertEquals(3.0, metadata.seriesIndex)
val cover = metadata.cover
assertNotNull(cover)
assertEquals("png", cover!!.extension)
assertArrayEquals(coverBytes, cover.bytes)
}
@Test
fun `fb2 extracts coverpage binary without parsing book body`() {
val coverBytes = onePixelPngBytes()
val fb2 = """
<FictionBook xmlns:l="http://www.w3.org/1999/xlink">
<description>
<title-info>
<book-title>Folder FB2</book-title>
<author>
<first-name>Ursula</first-name>
<last-name>Le Guin</last-name>
</author>
<annotation><image l:href="#not-cover.png"/></annotation>
<coverpage><image l:href="#cover.png"/></coverpage>
</title-info>
</description>
<body><section><p>Body text should not matter.</p></section></body>
<binary id="not-cover.png" content-type="image/png">${Base64.getEncoder().encodeToString(ByteArray(0))}</binary>
<binary id="cover.png" content-type="image/png">${Base64.getEncoder().encodeToString(coverBytes)}</binary>
</FictionBook>
""".trimIndent()
val metadata = EmbeddedEbookMetadataExtractor.extract(
type = FileType.FB2,
displayName = "folder.fb2",
openStream = { ByteArrayInputStream(fb2.toByteArray(Charsets.UTF_8)) }
)
assertEquals("Folder FB2", metadata.title)
assertEquals("Ursula Le Guin", metadata.author)
val cover = metadata.cover
assertNotNull(cover)
assertEquals("png", cover!!.extension)
assertArrayEquals(coverBytes, cover.bytes)
}
@Test
fun `mobi extracts EXTH text metadata and embedded cover record`() {
val coverBytes = onePixelPngBytes()
val mobiBytes = minimalMobiBytes(
title = "Folder MOBI",
author = "N K Jemisin",
coverBytes = coverBytes
)
val metadata = EmbeddedEbookMetadataExtractor.extract(
type = FileType.MOBI,
displayName = "folder.mobi",
openStream = { ByteArrayInputStream(mobiBytes) }
)
assertEquals("Folder MOBI", metadata.title)
assertEquals("N K Jemisin", metadata.author)
val cover = metadata.cover
assertNotNull(cover)
assertEquals("png", cover!!.extension)
assertArrayEquals(coverBytes, cover.bytes)
}
private fun zipBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
entries.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name))
zip.write(content)
zip.closeEntry()
}
}
return out.toByteArray()
}
private fun minimalMobiBytes(title: String, author: String, coverBytes: ByteArray): ByteArray {
val exthRecords = listOf(
exthStringRecord(99, title),
exthStringRecord(100, author),
exthIntRecord(201, 0)
)
val exthSize = 12 + exthRecords.sumOf { it.size }
val mobiHeaderLength = 232
val record0 = ByteArray(16 + mobiHeaderLength + exthSize)
putU16(record0, 0, 1)
putU32(record0, 4, 0)
putU16(record0, 8, 0)
putU16(record0, 12, 0)
putAscii(record0, 16, "MOBI")
putU32(record0, 20, mobiHeaderLength)
putU32(record0, 16 + 12, 65001)
putU32(record0, 16 + 68, 0)
putU32(record0, 16 + 72, 0)
putU32(record0, 16 + 92, 1)
val exthOffset = 16 + mobiHeaderLength
putAscii(record0, exthOffset, "EXTH")
putU32(record0, exthOffset + 4, exthSize)
putU32(record0, exthOffset + 8, exthRecords.size)
var cursor = exthOffset + 12
exthRecords.forEach { record ->
record.copyInto(record0, cursor)
cursor += record.size
}
val palmHeader = ByteArray(78 + 8 * 2)
putU16(palmHeader, 76, 2)
val record0Offset = palmHeader.size
val coverOffset = record0Offset + record0.size
putU32(palmHeader, 78, record0Offset)
putU32(palmHeader, 86, coverOffset)
return palmHeader + record0 + coverBytes
}
private fun exthStringRecord(type: Int, value: String): ByteArray {
val data = value.toByteArray(Charsets.UTF_8)
return exthRecord(type, data)
}
private fun exthIntRecord(type: Int, value: Int): ByteArray {
val data = ByteArray(4)
putU32(data, 0, value)
return exthRecord(type, data)
}
private fun exthRecord(type: Int, data: ByteArray): ByteArray {
val record = ByteArray(8 + data.size)
putU32(record, 0, type)
putU32(record, 4, record.size)
data.copyInto(record, 8)
return record
}
private fun putAscii(target: ByteArray, offset: Int, value: String) {
value.toByteArray(Charsets.US_ASCII).copyInto(target, offset)
}
private fun putU16(target: ByteArray, offset: Int, value: Int) {
target[offset] = ((value ushr 8) and 0xFF).toByte()
target[offset + 1] = (value and 0xFF).toByte()
}
private fun putU32(target: ByteArray, offset: Int, value: Int) {
target[offset] = ((value ushr 24) and 0xFF).toByte()
target[offset + 1] = ((value ushr 16) and 0xFF).toByte()
target[offset + 2] = ((value ushr 8) and 0xFF).toByte()
target[offset + 3] = (value and 0xFF).toByte()
}
private fun onePixelPngBytes(): ByteArray {
return Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
}
}

View file

@ -2,6 +2,7 @@ package com.aryan.reader
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -20,6 +21,21 @@ class FileTypeResolverTest {
assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv")) assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv"))
assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt")) assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt")) assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt"))
assertEquals(com.aryan.reader.shared.SharedFileCapabilities.resolveFileTypeForName("payload.json.txt"), resolveFileTypeFromName("payload.json.txt"))
}
@Test
fun `metadata resolver maps provider mime types without exposing generic archives`() {
assertEquals(FileType.PDF, resolveFileTypeFromMetadata("download", "application/pdf"))
assertEquals(FileType.DOCX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
assertEquals(FileType.PPTX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.presentationml.presentation"))
assertEquals(FileType.MD, resolveFileTypeFromMetadata("notes.markdown.txt", "text/plain; charset=utf-8"))
assertEquals(FileType.EPUB, resolveFileTypeFromMetadata("book.epub.txt", "text/plain"))
assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain"))
assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json"))
assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip"))
assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip"))
assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip"))
} }
@Test @Test
@ -39,7 +55,9 @@ class FileTypeResolverTest {
@Test @Test
fun `plain txt remains txt when inner extension is unsupported`() { fun `plain txt remains txt when inner extension is unsupported`() {
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt")) assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx"))
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt")) assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
assertNull(resolveFileTypeFromName("archive.zip"))
} }
@Test @Test

View file

@ -32,6 +32,13 @@ class LibraryStateProjectorTest {
assertEquals(files.ids(), filterBySearch(files, " ").ids()) assertEquals(files.ids(), filterBySearch(files, " ").ids())
} }
@Test
fun `filterBySearch preserves android display-name matching when a custom name exists`() {
val file = recentFile("custom", displayName = "Original File.pdf", customName = "Renamed")
assertEquals(listOf("custom"), filterBySearch(listOf(file), "original").ids())
}
@Test @Test
fun `applyLibraryFilters requires all active filters to match`() { fun `applyLibraryFilters requires all active filters to match`() {
val activeTag = tag("active", "Active") val activeTag = tag("active", "Active")
@ -188,6 +195,7 @@ class LibraryStateProjectorTest {
sortOrder = SortOrder.TITLE_ASC, sortOrder = SortOrder.TITLE_ASC,
recentFilesLimit = 1, recentFilesLimit = 1,
openTabIds = listOf("beta", "missing"), openTabIds = listOf("beta", "missing"),
activeTabBookId = "missing",
contextualActionItems = setOf(recentFile("beta"), recentFile("missing")), contextualActionItems = setOf(recentFile("beta"), recentFile("missing")),
viewingShelfId = "manual", viewingShelfId = "manual",
contextualActionShelfIds = setOf("manual", "missing") contextualActionShelfIds = setOf("manual", "missing")
@ -208,6 +216,8 @@ class LibraryStateProjectorTest {
assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids()) assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids())
assertEquals(listOf("beta"), result.recentFiles.ids()) assertEquals(listOf("beta"), result.recentFiles.ids())
assertEquals(listOf("beta"), result.openTabs.ids()) assertEquals(listOf("beta"), result.openTabs.ids())
assertEquals(listOf("beta"), result.openTabIds)
assertNull(result.activeTabBookId)
assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId }) assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
assertEquals(listOf(tag), result.contextualActionItems.first().tags) assertEquals(listOf(tag), result.contextualActionItems.first().tags)
assertEquals("manual", result.viewingShelfId) assertEquals("manual", result.viewingShelfId)
@ -266,6 +276,30 @@ class LibraryStateProjectorTest {
assertEquals(listOf(tag), result.allTags) assertEquals(listOf(tag), result.allTags)
} }
@Test
fun `project keeps pinned home and library books first using shared projector`() {
val older = recentFile("older", title = "Zulu", timestamp = 1L)
val newer = recentFile("newer", title = "Alpha", timestamp = 2L)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
sortOrder = SortOrder.TITLE_ASC,
pinnedHomeBookIds = setOf("older"),
pinnedLibraryBookIds = setOf("older")
),
recentFilesFromDb = listOf(older, newer),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertEquals(listOf("older", "newer"), result.recentFiles.ids())
assertEquals(listOf("older", "newer"), result.allRecentFiles.ids())
}
@Test @Test
fun `project builds manual tag series and unshelved shelves`() { fun `project builds manual tag series and unshelved shelves`() {
val favorite = tag("favorite", "Favorite") val favorite = tag("favorite", "Favorite")
@ -479,7 +513,8 @@ class LibraryStateProjectorTest {
tags: List<TagEntity> = emptyList(), tags: List<TagEntity> = emptyList(),
fileSize: Long = 0L, fileSize: Long = 0L,
seriesName: String? = null, seriesName: String? = null,
seriesIndex: Double? = null seriesIndex: Double? = null,
customName: String? = null
) = RecentFileItem( ) = RecentFileItem(
bookId = id, bookId = id,
uriString = uriString, uriString = uriString,
@ -494,7 +529,8 @@ class LibraryStateProjectorTest {
tags = tags, tags = tags,
fileSize = fileSize, fileSize = fileSize,
seriesName = seriesName, seriesName = seriesName,
seriesIndex = seriesIndex seriesIndex = seriesIndex,
customName = customName
) )
private fun tag(id: String, name: String) = TagEntity( private fun tag(id: String, name: String) = TagEntity(

View file

@ -9,16 +9,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.credentials.CredentialManager import androidx.credentials.CredentialManager
import androidx.work.WorkManager import androidx.work.WorkManager
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.aryan.reader.data.* import com.aryan.reader.data.*
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.tts.TtsController import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.TtsPlaybackManager
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -108,28 +104,8 @@ class MainViewModelTest {
mockkObject(WorkManager.Companion) mockkObject(WorkManager.Companion)
val mockWorkManager = mockk<WorkManager>(relaxed = true) val mockWorkManager = mockk<WorkManager>(relaxed = true)
every { WorkManager.getInstance(any()) } returns mockWorkManager every { WorkManager.getInstance(any()) } returns mockWorkManager
mockkStatic(FirebaseAuth::class)
every { FirebaseAuth.getInstance() } returns mockk(relaxed = true)
mockkStatic(FirebaseFirestore::class)
every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true)
mockkObject(CredentialManager.Companion) mockkObject(CredentialManager.Companion)
every { CredentialManager.create(any()) } returns mockk(relaxed = true) every { CredentialManager.create(any()) } returns mockk(relaxed = true)
mockkStatic(BillingClient::class)
val mockBillingClient = mockk<BillingClient>(relaxed = true)
val mockBillingBuilder = mockk<BillingClient.Builder>(relaxed = true)
every { BillingClient.newBuilder(any()) } returns mockBillingBuilder
every { mockBillingBuilder.setListener(any()) } returns mockBillingBuilder
every { mockBillingBuilder.enablePendingPurchases(any()) } returns mockBillingBuilder
every { mockBillingBuilder.build() } returns mockBillingClient
every { mockBillingClient.isReady } returns false
every { mockBillingClient.startConnection(any()) } answers {
firstArg<com.android.billingclient.api.BillingClientStateListener>()
.onBillingSetupFinished(
BillingResult.newBuilder()
.setResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
.build()
)
}
mockkConstructor(AuthRepository::class) mockkConstructor(AuthRepository::class)
mockkConstructor(RecentFilesRepository::class) mockkConstructor(RecentFilesRepository::class)
mockkConstructor(BillingClientWrapper::class) mockkConstructor(BillingClientWrapper::class)
@ -140,6 +116,14 @@ class MainViewModelTest {
mockkConstructor(TtsController::class) mockkConstructor(TtsController::class)
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
every { anyConstructed<BillingClientWrapper>().initializeConnection() } just Runs
every { anyConstructed<BillingClientWrapper>().refreshPurchasesAsync() } just Runs
every { anyConstructed<BillingClientWrapper>().clearVerificationState() } just Runs
every { anyConstructed<BillingClientWrapper>().clearAccountConflict() } just Runs
every { anyConstructed<BillingClientWrapper>().markAccountConflict() } just Runs
every { anyConstructed<BillingClientWrapper>().clearError() } just Runs
every { anyConstructed<BillingClientWrapper>().consumePurchase(any()) } just Runs
every { anyConstructed<BillingClientWrapper>().launchPurchaseFlow(any(), any(), any()) } just Runs
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null) every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
every { anyConstructed<RemoteConfigRepository>().init() } just Runs every { anyConstructed<RemoteConfigRepository>().init() } just Runs
@ -566,6 +550,36 @@ class MainViewModelTest {
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) } verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) }
} }
@Test
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val requested = LibraryFilters(
fileTypes = setOf(FileType.PDF, FileType.UNKNOWN),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf("favorite")
)
val expected = requested.copy(fileTypes = setOf(FileType.PDF))
viewModel.updateLibraryFilters(requested)
val state = viewModel.uiState.first { it.libraryFilters == expected }
assertEquals(expected, state.libraryFilters)
assertFalse(FileType.UNKNOWN in state.libraryFilters.fileTypes)
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF")) }
}
@Test
fun `saved library file filters drop stale unknown values during restore`() = runTest {
every { mockPrefs.getStringSet(KEY_FILTER_FILE_TYPES, any()) } returns mutableSetOf("PDF", "UNKNOWN")
val restored = MainViewModel(mockApplication)
assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes)
}
@Test @Test
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest { fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
@ -954,6 +968,22 @@ class MainViewModelTest {
assertEquals(null, clearedState.bannerMessage) assertEquals(null, clearedState.bannerMessage)
} }
@Test
fun `persistent banner is not auto dismissed`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showBanner("Syncing", isPersistent = true)
viewModel.uiState.first { it.bannerMessage?.message == "Syncing" }
runCurrent()
advanceTimeBy(3_000L)
runCurrent()
assertEquals("Syncing", viewModel.uiState.value.bannerMessage?.message)
}
private fun recentFile( private fun recentFile(
id: String, id: String,
type: FileType = FileType.EPUB, type: FileType = FileType.EPUB,
@ -970,10 +1000,16 @@ class MainViewModelTest {
title = title title = title
) )
private fun mockUri(uriString: String): Uri { private fun mockUri(
uriString: String,
path: String? = uriString.substringAfter(":", ""),
lastPathSegment: String? = path?.substringAfterLast('/')
): Uri {
return mockk<Uri>().also { uri -> return mockk<Uri>().also { uri ->
every { uri.toString() } returns uriString every { uri.toString() } returns uriString
every { uri.scheme } returns uriString.substringBefore(":", "") every { uri.scheme } returns uriString.substringBefore(":", "")
every { uri.path } returns path
every { uri.lastPathSegment } returns lastPathSegment
} }
} }

View file

@ -1,147 +0,0 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NonReaderScreenModelsTest {
@Test
fun `home model treats open tabs as non-empty content`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = true,
openTabs = listOf(tab),
rawLibraryFiles = listOf(tab)
).toHomeScreenModel()
assertFalse(model.isEmpty)
assertTrue(model.isLibraryEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model reports empty when there are no recents or open tabs`() {
val archivedBook = recentFile("archived", isRecent = false)
val model = ReaderScreenState(
recentFiles = emptyList(),
rawLibraryFiles = listOf(archivedBook)
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertTrue(model.isLibraryEmpty)
}
@Test
fun `home model ignores open tabs for empty state when tabs are disabled`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = false,
openTabs = listOf(tab),
recentFiles = emptyList()
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model exposes contextual selection and device limit state`() {
val selected = recentFile("selected")
val deviceState = DeviceLimitReachedState(isLimitReached = true)
val model = ReaderScreenState(
recentFiles = listOf(selected),
contextualActionItems = setOf(selected),
deviceLimitState = deviceState
).toHomeScreenModel()
assertTrue(model.isContextualModeActive)
assertEquals(setOf(selected), model.selectedItems)
assertEquals(deviceState, model.deviceLimitState)
assertFalse(model.isEmpty)
assertFalse(model.isLibraryEmpty)
}
@Test
fun `library model exposes contextual and shelf selection state`() {
val folderBook = recentFile("folder", sourceFolderUri = "content://folder")
val shelf = Shelf(
id = "manual",
name = "Manual",
type = ShelfType.MANUAL,
books = listOf(folderBook)
)
val model = ReaderScreenState(
contextualActionItems = setOf(folderBook),
contextualActionShelfIds = setOf(shelf.id),
sortOrder = SortOrder.TITLE_ASC,
shelves = listOf(shelf),
rawLibraryFiles = listOf(folderBook),
searchQuery = "folder",
isSearchActive = true
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertTrue(model.isShelfContextualModeActive)
assertTrue(model.containsFolderItemsInSelection)
assertEquals(setOf(folderBook), model.selectedItems)
assertEquals(setOf(shelf.id), model.selectedShelves)
assertEquals(SortOrder.TITLE_ASC, model.sortOrder)
assertEquals("folder", model.searchQuery)
assertTrue(model.isSearchActive)
}
@Test
fun `library model reports inactive contextual states for normal browsing`() {
val book = recentFile("book")
val model = ReaderScreenState(
allRecentFiles = listOf(book),
rawLibraryFiles = listOf(book),
sortOrder = SortOrder.RECENT
).toLibraryScreenModel()
assertFalse(model.isContextualModeActive)
assertFalse(model.isShelfContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertTrue(model.selectedItems.isEmpty())
assertTrue(model.selectedShelves.isEmpty())
assertEquals(listOf(book), model.rawLibraryFiles)
assertEquals(SortOrder.RECENT, model.sortOrder)
}
@Test
fun `library model distinguishes folder and non-folder selections`() {
val localBook = recentFile("local")
val model = ReaderScreenState(
contextualActionItems = setOf(localBook),
rawLibraryFiles = listOf(localBook)
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertEquals(setOf(localBook), model.selectedItems)
}
private fun recentFile(
id: String,
isRecent: Boolean = true,
sourceFolderUri: String? = null
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = FileType.EPUB,
displayName = "$id.epub",
timestamp = 1L,
isRecent = isRecent,
sourceFolderUri = sourceFolderUri
)
}

View file

@ -0,0 +1,27 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseAccountObfuscatorTest {
@Test
fun `obfuscated account id is stable and safe for billing`() {
val accountId = PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123")
assertTrue(accountId.startsWith("firebase_"))
assertFalse(accountId.contains("="))
assertEquals(accountId, PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123"))
}
@Test
fun `purchase token hash matches worker format`() {
val token = "jjjnbecgjekfeigbnagcheee.AO-J1OzOurukZmfLAyu6EdPlEvLIyehyOLYajYbGlEK3knhjN4nGe-BLgjXVrSCfRFocGJ5Wc8VcLazRLZxHTdgiUQ5zULRyoQ"
assertEquals(
"sha256_KBe2Ev9nqOx9PMypxP3AlwDgm4E-KIa-i5Eenr1QPF8",
PurchaseAccountObfuscator.purchaseTokenHash(token)
)
}
}

View file

@ -0,0 +1,128 @@
package com.aryan.reader
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ActivityInfo
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test
class ReaderScreenOrientationTest {
@Test
fun `screen orientation defaults to follow system and falls back for invalid ids`() {
val defaultContext = contextWithPrefs(InMemorySharedPreferences())
val invalidContext = contextWithPrefs(
InMemorySharedPreferences("reader_screen_orientation_mode" to Int.MIN_VALUE)
)
assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(defaultContext))
assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(invalidContext))
}
@Test
fun `screen orientation mode saves loads and maps to activity requested orientation`() {
val context = contextWithPrefs(InMemorySharedPreferences())
saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.LANDSCAPE)
assertEquals(ReaderScreenOrientationMode.LANDSCAPE, loadReaderScreenOrientationMode(context))
saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.PORTRAIT)
assertEquals(ReaderScreenOrientationMode.PORTRAIT, loadReaderScreenOrientationMode(context))
assertEquals(
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED,
ReaderScreenOrientationMode.FOLLOW_SYSTEM.toRequestedOrientation()
)
assertEquals(
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT,
ReaderScreenOrientationMode.PORTRAIT.toRequestedOrientation()
)
assertEquals(
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE,
ReaderScreenOrientationMode.LANDSCAPE.toRequestedOrientation()
)
}
@Test
fun `right to left pagination is separate for pdf and epub`() {
val context = contextWithPrefs(InMemorySharedPreferences())
assertEquals(false, loadPdfRightToLeftPagination(context))
assertEquals(false, loadEpubRightToLeftPagination(context))
savePdfRightToLeftPagination(context, true)
assertEquals(true, loadPdfRightToLeftPagination(context))
assertEquals(false, loadEpubRightToLeftPagination(context))
saveEpubRightToLeftPagination(context, true)
assertEquals(true, loadPdfRightToLeftPagination(context))
assertEquals(true, loadEpubRightToLeftPagination(context))
savePdfRightToLeftPagination(context, false)
assertEquals(false, loadPdfRightToLeftPagination(context))
assertEquals(true, loadEpubRightToLeftPagination(context))
}
private fun contextWithPrefs(prefs: SharedPreferences): Context {
val context = mockk<Context>()
every { context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) } returns prefs
every { context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) } returns prefs
return context
}
private class InMemorySharedPreferences(vararg initial: Pair<String, Any?>) : SharedPreferences {
private val values = initial.toMap().toMutableMap()
override fun getAll(): MutableMap<String, *> = values
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
val value = values[key] as? Set<*> ?: return defValues
return value.filterIsInstance<String>().toMutableSet()
}
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String?): Boolean = values.containsKey(key)
override fun edit(): SharedPreferences.Editor = Editor()
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
private inner class Editor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearRequested = false
override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value)
override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor =
applyPut(key, values?.toSet())
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value)
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value)
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value)
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value)
override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null)
override fun clear(): SharedPreferences.Editor {
clearRequested = true
return this
}
override fun commit(): Boolean {
flush()
return true
}
override fun apply() = flush()
private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor {
if (key != null) pending[key] = value
return this
}
private fun flush() {
if (clearRequested) values.clear()
pending.forEach { (key, value) ->
if (value == null) values.remove(key) else values[key] = value
}
}
}
}
}

View file

@ -0,0 +1,155 @@
package com.aryan.reader
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfType as SharedShelfType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Test
class SharedModelMappersTest {
@Test
fun `book mapper preserves android-only fields when shared projection maps back by id`() {
val tag = TagEntity(id = "tag", name = "Favorite", color = 0xFFAA00AA.toInt(), createdAt = 10L)
val original = recentFile(
id = "book",
type = FileType.PDF,
displayName = "Original.pdf",
customName = "Custom name",
isAvailable = false,
bookmarksJson = """[{"page":2}]""",
sourceFolderUri = "content://folder",
tags = listOf(tag)
)
val shared = original.toSharedBookItem()
val mapped = shared.toRecentFileItem(
androidBooksById = mapOf(original.bookId to original),
tagEntitiesById = mapOf(tag.id to tag)
)
assertEquals("Custom name", shared.displayName)
assertEquals(original.uriString, mapped.uriString)
assertEquals(original.displayName, mapped.displayName)
assertEquals(original.customName, mapped.customName)
assertEquals(original.bookmarksJson, mapped.bookmarksJson)
assertEquals(original.sourceFolderUri, mapped.sourceFolderUri)
assertFalse(mapped.isAvailable)
assertEquals(listOf(tag), mapped.tags)
}
@Test
fun `shared projection state maps shelves tabs selections and tags back to android state`() {
val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L)
val book = recentFile("book", tags = listOf(tag))
val sharedBook = book.toSharedBookItem()
val sharedShelf = SharedShelf(
id = "manual",
name = "Manual",
type = SharedShelfType.MANUAL,
books = listOf(sharedBook),
directBooks = listOf(sharedBook)
)
val projected = SharedReaderScreenState(
recentBooks = listOf(sharedBook),
libraryBooks = listOf(sharedBook),
rawLibraryBooks = listOf(sharedBook),
selectedBookIds = setOf("book", "missing"),
selectedShelfIds = setOf("manual"),
shelves = listOf(sharedShelf),
openTabs = listOf(sharedBook),
openTabIds = listOf("book"),
activeTabBookId = "book",
booksAvailableForAdding = listOf(sharedBook),
allTags = listOf(tag.toSharedTag())
)
val android = projected.toAndroidReaderScreenState(
base = ReaderScreenState(contextualActionItems = setOf(recentFile("missing"))),
androidBooksById = mapOf(book.bookId to book),
tagEntitiesById = mapOf(tag.id to tag)
)
assertEquals(listOf("book"), android.recentFiles.ids())
assertEquals(listOf("book"), android.allRecentFiles.ids())
assertEquals(listOf("book"), android.rawLibraryFiles.ids())
assertEquals(setOf("book"), android.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
assertEquals(setOf("manual"), android.contextualActionShelfIds)
assertEquals(listOf("manual"), android.shelves.map { it.id })
assertEquals(listOf("book"), android.shelves.single().books.ids())
assertEquals(listOf("book"), android.openTabs.ids())
assertEquals(listOf("book"), android.openTabIds)
assertEquals("book", android.activeTabBookId)
assertEquals(listOf("book"), android.booksAvailableForAdding.ids())
assertEquals(listOf(tag), android.allTags)
}
@Test
fun `enum filter and folder mappers round trip between android and shared`() {
val filters = LibraryFilters(
fileTypes = setOf(FileType.PDF, FileType.EPUB),
sourceFolders = setOf("IN_APP_STORAGE", "content://folder"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf("tag")
)
val folder = SyncedFolder(
uriString = "content://folder",
name = "Folder",
lastScanTime = 42L,
allowedFileTypes = setOf(FileType.PDF, FileType.CBZ)
)
assertEquals(FileType.PDF, SharedFileType.PDF.toAndroidFileType())
assertEquals(SharedFileType.CBZ, FileType.CBZ.toSharedFileType())
assertSame(FileType.UNKNOWN, SharedFileType.UNKNOWN.toAndroidFileType())
assertSame(filters, filters.toSharedLibraryFilters())
assertSame(folder, folder.toSharedSyncedFolder())
assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters())
assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder())
assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES)
assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES)
}
@Test
fun `tag resolver attaches database tags before shared projection`() {
val tag = TagEntity(id = "tag", name = "Reference", createdAt = 1L)
val files = listOf(recentFile("book"))
val tagged = files.withResolvedTags(
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "book", tagId = "tag"))
)
assertEquals(listOf(tag), tagged.single().tags)
}
private fun recentFile(
id: String,
type: FileType = FileType.EPUB,
displayName: String = "$id.${type.name.lowercase()}",
customName: String? = null,
isAvailable: Boolean = true,
bookmarksJson: String? = null,
sourceFolderUri: String? = null,
tags: List<TagEntity> = emptyList()
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = type,
displayName = displayName,
timestamp = 1L,
isAvailable = isAvailable,
bookmarksJson = bookmarksJson,
sourceFolderUri = sourceFolderUri,
customName = customName,
tags = tags
)
private fun List<RecentFileItem>.ids() = map { it.bookId }
}

View file

@ -0,0 +1,21 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FileTypeConverterTest {
@Test
fun `room converter stores enum names and preserves unknown fallback`() {
val converter = FileTypeConverter()
assertEquals("PDF", converter.fromFileType(FileType.PDF))
assertEquals(FileType.PDF, converter.toFileType("PDF"))
assertEquals("UNKNOWN", converter.fromFileType(FileType.UNKNOWN))
assertEquals(FileType.UNKNOWN, converter.toFileType("UNKNOWN"))
assertNull(converter.fromFileType(null))
assertNull(converter.toFileType(null))
}
}

View file

@ -31,7 +31,15 @@ class FolderBookMetadataTest {
val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString()) val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString())
assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded) assertEquals(
metadata.copy(
title = null,
author = null,
lastPage = null,
locatorCharOffset = null
),
decoded
)
} }
@Test @Test
@ -78,8 +86,8 @@ class FolderBookMetadataTest {
assertEquals("book-2", item.bookId) assertEquals("book-2", item.bookId)
assertEquals(FileType.EPUB, item.type) assertEquals(FileType.EPUB, item.type)
assertEquals("Remote Title", item.title) assertEquals("Remote", item.title)
assertEquals("Author", item.author) assertNull(item.author)
assertEquals(12, item.lastPage) assertEquals(12, item.lastPage)
assertEquals(7, item.locatorBlockIndex) assertEquals(7, item.locatorBlockIndex)
assertEquals(8, item.locatorCharOffset) assertEquals(8, item.locatorCharOffset)
@ -87,4 +95,34 @@ class FolderBookMetadataTest {
assertEquals("Shelf Name", item.customName) assertEquals("Shelf Name", item.customName)
assertEquals("highlights", item.highlightsJson) assertEquals("highlights", item.highlightsJson)
} }
@Test
fun `toRecentFileItem preserves explicit unknown file type`() {
val metadata = FolderBookMetadata(
bookId = "book-3",
title = null,
author = null,
displayName = "Remote.bin",
type = "UNKNOWN",
lastChapterIndex = null,
lastPage = null,
lastPositionCfi = null,
progressPercentage = 0f,
isRecent = false,
lastModifiedTimestamp = 500L,
bookmarksJson = null,
locatorBlockIndex = null,
locatorCharOffset = null,
customName = null,
highlightsJson = null
)
val item = metadata.toRecentFileItem(
uriString = "content://book",
coverPath = null,
sourceFolderUri = "content://folder"
)
assertEquals(FileType.UNKNOWN, item.type)
}
} }

View file

@ -0,0 +1,244 @@
package com.aryan.reader.data
import androidx.room.Room
import com.aryan.reader.FileType
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class RecentFileDaoMetadataExtractionTest {
private lateinit var db: AppDatabase
private lateinit var dao: RecentFileDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = db.recentFileDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun `cover-only ebook metadata candidate is marked attempted after no cover is found`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity(
folderTextMetadataParsed = true,
folderCoverMetadataParsed = false
)
)
assertEquals(1, dao.countFolderBooksNeedingTextMetadata("content://folder"))
dao.updateExtractedMetadata(
bookId = "book-1",
coverImagePath = null,
title = null,
author = null,
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
textMetadataParsed = false,
coverMetadataParsed = true
)
val saved = dao.getFileByBookId("book-1")!!
assertFalse(saved.coverImagePath?.isNotBlank() == true)
assertTrue(saved.folderCoverMetadataParsed)
assertEquals(0, dao.countFolderBooksNeedingTextMetadata("content://folder"))
}
@Test
fun `metadata candidate query respects batch limit`() = runTest {
dao.insertOrUpdateFile(recentFileEntity(bookId = "book-1", timestamp = 1_000L))
dao.insertOrUpdateFile(recentFileEntity(bookId = "book-2", timestamp = 2_000L))
val pending = dao.getFolderBooksNeedingTextMetadata("content://folder", limit = 1)
assertEquals(1, pending.size)
assertEquals("book-2", pending.single().bookId)
}
@Test
fun `metadata extraction does not replace user edited metadata`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "Edited title",
author = "Edited author",
originalTitle = "Original title",
originalAuthor = "Original author"
)
)
dao.updateExtractedMetadata(
bookId = "book-1",
coverImagePath = null,
title = "Extracted title",
author = "Extracted author",
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
textMetadataParsed = true,
coverMetadataParsed = false
)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Edited title", saved.title)
assertEquals("Edited author", saved.author)
assertEquals("Original title", saved.originalTitle)
assertEquals("Original author", saved.originalAuthor)
}
@Test
fun `metadata extraction promotes extracted values before user edits`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "book-1.epub",
author = null,
originalTitle = "book-1.epub",
originalAuthor = null
)
)
dao.updateExtractedMetadata(
bookId = "book-1",
coverImagePath = null,
title = "Extracted title",
author = "Extracted author",
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
textMetadataParsed = true,
coverMetadataParsed = false
)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Extracted title", saved.title)
assertEquals("Extracted author", saved.author)
assertEquals("Extracted title", saved.originalTitle)
assertEquals("Extracted author", saved.originalAuthor)
}
@Test
fun `restore original metadata restores snapshot and clears display override`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "Edited title",
author = "Edited author",
seriesName = "Edited series",
seriesIndex = 2.0,
description = "Edited summary",
customName = "Edited display",
originalTitle = "Original title",
originalAuthor = "Original author",
originalSeriesName = "Original series",
originalSeriesIndex = 1.0,
originalDescription = "Original summary"
)
)
dao.restoreOriginalMetadata("book-1", fileSize = 0L, fileContentModifiedTimestamp = 0L, timestamp = 9_000L)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Original title", saved.title)
assertEquals("Original author", saved.author)
assertEquals("Original series", saved.seriesName)
assertEquals(1.0, saved.seriesIndex)
assertEquals("Original summary", saved.description)
assertNull(saved.customName)
assertEquals(9_000L, saved.lastModifiedTimestamp)
}
@Test
fun `manual metadata update seeds missing original snapshot from previous values`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "Existing title",
author = "Existing author",
customName = "Display override",
originalTitle = null,
originalAuthor = null
)
)
dao.updateUserEditableMetadata(
bookId = "book-1",
title = "Edited title",
author = "Edited author",
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
timestamp = 5_000L
)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Edited title", saved.title)
assertEquals("Edited author", saved.author)
assertEquals("Existing title", saved.originalTitle)
assertEquals("Existing author", saved.originalAuthor)
assertNull(saved.customName)
}
private fun recentFileEntity(
bookId: String = "book-1",
timestamp: Long = 1_000L,
folderTextMetadataParsed: Boolean = false,
folderCoverMetadataParsed: Boolean = false
): RecentFileEntity {
return RecentFileEntity(
bookId = bookId,
uriString = "content://books/$bookId",
type = FileType.EPUB,
displayName = "$bookId.epub",
timestamp = timestamp,
coverImagePath = null,
title = "One",
author = "Author",
lastChapterIndex = null,
lastPage = null,
lastPositionCfi = null,
progressPercentage = null,
isRecent = false,
isAvailable = true,
lastModifiedTimestamp = timestamp,
isDeleted = false,
locatorBlockIndex = null,
locatorCharOffset = null,
bookmarks = null,
sourceFolderUri = "content://folder",
isReflowPreferred = false,
customName = null,
highlights = null,
fileSize = 123L,
fileContentModifiedTimestamp = 1_234L,
seriesName = null,
seriesIndex = null,
description = null,
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = folderCoverMetadataParsed
)
}
}

View file

@ -17,6 +17,7 @@ class RecentFileItemReadingPositionMappingTest {
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex) assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage) assertEquals(item.progressPercentage, roundTripped.progressPercentage)
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
} }
@Test @Test
@ -30,6 +31,59 @@ class RecentFileItemReadingPositionMappingTest {
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex) assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage) assertEquals(item.progressPercentage, roundTripped.progressPercentage)
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
}
@Test
fun `cloud metadata mapping preserves non epub display rename as custom name`() {
val item = recentFileItem().copy(
type = FileType.PDF,
customName = "Reader Display Name"
)
val roundTripped = item.toBookMetadata().toRecentFileItem()
assertEquals("Reader Display Name", roundTripped.customName)
assertEquals("One.epub", roundTripped.displayName)
}
@Test
fun `recent file entity mapping preserves original metadata snapshot`() {
val item = recentFileItem().copy(
title = "Edited One",
author = "Edited Author",
seriesName = "Edited Series",
seriesIndex = 2.0,
description = "Edited summary",
originalTitle = "Original One",
originalAuthor = "Original Author",
originalSeriesName = "Original Series",
originalSeriesIndex = 1.0,
originalDescription = "Original summary"
)
val roundTripped = item.toRecentFileEntity().toRecentFileItem()
assertEquals("Original One", roundTripped.originalTitle)
assertEquals("Original Author", roundTripped.originalAuthor)
assertEquals("Original Series", roundTripped.originalSeriesName)
assertEquals(1.0, roundTripped.originalSeriesIndex)
assertEquals("Original summary", roundTripped.originalDescription)
}
@Test
fun `recent file entity mapping seeds original metadata when first stored`() {
val entity = recentFileItem().copy(
seriesName = "Series",
seriesIndex = 1.0,
description = "Summary"
).toRecentFileEntity()
assertEquals("One", entity.originalTitle)
assertEquals("Author", entity.originalAuthor)
assertEquals("Series", entity.originalSeriesName)
assertEquals(1.0, entity.originalSeriesIndex)
assertEquals("Summary", entity.originalDescription)
} }
private fun recentFileItem(): RecentFileItem { private fun recentFileItem(): RecentFileItem {
@ -47,6 +101,7 @@ class RecentFileItemReadingPositionMappingTest {
locatorCharOffset = 88, locatorCharOffset = 88,
progressPercentage = 61.5f, progressPercentage = 61.5f,
lastModifiedTimestamp = 2_000L, lastModifiedTimestamp = 2_000L,
fileContentModifiedTimestamp = 3_000L,
bookmarksJson = """[{"cfi":"/4/2"}]""", bookmarksJson = """[{"cfi":"/4/2"}]""",
highlightsJson = """[{"cfi":"/4/2/6:88"}]""" highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
) )

View file

@ -14,7 +14,9 @@ import io.mockk.unmockkObject
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
@ -113,14 +115,82 @@ class RecentFilesRepositoryReadingPositionMergeTest {
assertEquals(82f, inserted.captured.progressPercentage) assertEquals(82f, inserted.captured.progressPercentage)
} }
private fun existingEntity(): RecentFileEntity { @Test
fun `addRecentFile keeps edited embedded epub metadata when cached parser returns original metadata`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy(
author = "Edited Author",
originalAuthor = "Author",
fileContentModifiedTimestamp = 5_000L
)
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
title = "Old",
author = "Author",
fileContentModifiedTimestamp = 5_000L,
isRecent = true
)
)
assertEquals("Edited Author", inserted.captured.author)
assertEquals("Author", inserted.captured.originalAuthor)
}
@Test
fun `addRecentFile clears extracted metadata when folder file size changes`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity(
fileSize = 123L,
folderTextMetadataParsed = true,
folderCoverMetadataParsed = true,
coverImagePath = "/covers/old.png"
)
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
sourceFolderUri = "content://folder",
fileSize = 456L,
isRecent = true
)
)
assertEquals(456L, inserted.captured.fileSize)
assertNull(inserted.captured.coverImagePath)
assertEquals("New", inserted.captured.title)
assertNull(inserted.captured.author)
assertNull(inserted.captured.seriesName)
assertNull(inserted.captured.description)
assertNull(inserted.captured.originalTitle)
assertFalse(inserted.captured.folderTextMetadataParsed)
assertFalse(inserted.captured.folderCoverMetadataParsed)
}
private fun existingEntity(
fileSize: Long = 123L,
folderTextMetadataParsed: Boolean = true,
folderCoverMetadataParsed: Boolean = false,
coverImagePath: String? = "/covers/old.png"
): RecentFileEntity {
return RecentFileEntity( return RecentFileEntity(
bookId = "book-1", bookId = "book-1",
uriString = "content://old", uriString = "content://old",
type = FileType.EPUB, type = FileType.EPUB,
displayName = "Old.epub", displayName = "Old.epub",
timestamp = 1_000L, timestamp = 1_000L,
coverImagePath = "/covers/old.png", coverImagePath = coverImagePath,
title = "Old", title = "Old",
author = "Author", author = "Author",
lastChapterIndex = 6, lastChapterIndex = 6,
@ -138,11 +208,12 @@ class RecentFilesRepositoryReadingPositionMergeTest {
isReflowPreferred = false, isReflowPreferred = false,
customName = "Custom", customName = "Custom",
highlights = "highlights", highlights = "highlights",
fileSize = 123L, fileSize = fileSize,
seriesName = "Series", seriesName = "Series",
seriesIndex = 1.0, seriesIndex = 1.0,
description = "Description", description = "Description",
folderTextMetadataParsed = true folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = folderCoverMetadataParsed
) )
} }
} }

View file

@ -15,6 +15,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
class EpubParserUnitTest { class EpubParserUnitTest {
@ -103,6 +104,43 @@ class EpubParserUnitTest {
assertTrue(extractionDir.list().isNullOrEmpty()) assertTrue(extractionDir.list().isNullOrEmpty())
} }
@Test
fun `metadata only extraction streams images to disk without retaining image bytes`() {
val cacheDir = temp.newFolder("cache-metadata-stream")
val extractionDir = temp.newFolder("extract-metadata-stream")
val parser = EpubParser(contextWithCache(cacheDir))
val imageBytes = ByteArray(2 * 1024 * 1024) { 7 }
val zipFileOnDisk = File(temp.root, "metadata-stream.epub")
zipFileOnDisk.writeBytes(
zipBinaryBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/content.opf" to """
<package>
<metadata />
<manifest>
<item id="cover" href="images/cover.jpg" media-type="image/jpeg"/>
</manifest>
<spine />
</package>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/images/cover.jpg" to imageBytes
)
)
val files = parser.extractEpubContents(
zipFile = ZipFile(zipFileOnDisk),
extractionDir = extractionDir,
parseContent = false,
extractImagesForMetadata = true
)
assertTrue(files["META-INF/container.xml"]!!.data.isNotEmpty())
assertEquals(0, files["OEBPS/images/cover.jpg"]!!.data.size)
assertEquals(imageBytes.size.toLong(), File(extractionDir, "OEBPS/images/cover.jpg").length())
}
@Test @Test
fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest { fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest {
val cacheDir = temp.newFolder("cache-warm-open") val cacheDir = temp.newFolder("cache-warm-open")
@ -129,6 +167,35 @@ class EpubParserUnitTest {
assertTrue(File(activeDir, "sentinel.txt").isFile) assertTrue(File(activeDir, "sentinel.txt").isFile)
} }
@Test
fun `createEpubBook invalidates active extraction cache when source fingerprint changes`() = runTest {
val cacheDir = temp.newFolder("cache-source-change")
val context = contextWithCache(cacheDir)
val parser = EpubParser(context)
val first = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "changed-book",
shouldUseToc = true,
originalBookNameHint = "changed.epub",
sourceFingerprint = "100:1000"
)
val activeDir = ImportedFileCache.activeBookDir(context, "changed-book")
File(activeDir, "sentinel.txt").writeText("old extraction")
val second = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes(author = "Edited Writer")),
bookId = "changed-book",
shouldUseToc = true,
originalBookNameHint = "changed.epub",
sourceFingerprint = "120:2000"
)
assertEquals("Jane Writer", first.author)
assertEquals("Edited Writer", second.author)
assertFalse(File(activeDir, "sentinel.txt").isFile)
}
@Test @Test
fun `metadata only parse does not clear active extracted content`() = runTest { fun `metadata only parse does not clear active extracted content`() = runTest {
val cacheDir = temp.newFolder("cache-metadata-preserve") val cacheDir = temp.newFolder("cache-metadata-preserve")
@ -325,7 +392,7 @@ class EpubParserUnitTest {
return context return context
} }
private fun sampleEpubBytes(): ByteArray = zipBytes( private fun sampleEpubBytes(author: String = "Jane Writer"): ByteArray = zipBytes(
"META-INF/container.xml" to """ "META-INF/container.xml" to """
<container version="1.0"> <container version="1.0">
<rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles> <rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles>
@ -335,7 +402,7 @@ class EpubParserUnitTest {
<package xmlns:dc="http://purl.org/dc/elements/1.1/"> <package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata> <metadata>
<dc:title>Sample/Book</dc:title> <dc:title>Sample/Book</dc:title>
<dc:creator>Jane Writer</dc:creator> <dc:creator>$author</dc:creator>
<dc:language>en</dc:language> <dc:language>en</dc:language>
<dc:description>Long description</dc:description> <dc:description>Long description</dc:description>
<meta name="calibre:series" content="Series Name"/> <meta name="calibre:series" content="Series Name"/>
@ -415,11 +482,15 @@ class EpubParserUnitTest {
) )
private fun zipBytes(vararg entries: Pair<String, String>): ByteArray { private fun zipBytes(vararg entries: Pair<String, String>): ByteArray {
return zipBinaryBytes(*entries.map { it.first to it.second.toByteArray(Charsets.UTF_8) }.toTypedArray())
}
private fun zipBinaryBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
val out = ByteArrayOutputStream() val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip -> ZipOutputStream(out).use { zip ->
entries.forEach { (name, content) -> entries.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name)) zip.putNextEntry(ZipEntry(name))
zip.write(content.toByteArray(Charsets.UTF_8)) zip.write(content)
zip.closeEntry() zip.closeEntry()
} }
} }

View file

@ -196,5 +196,6 @@ class EpubReaderBridgeAndControlsTest {
assertTrue(ReaderTool.entries.any { it.category == "Top Bar" }) assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" }) assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" }) assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
} }
} }

View file

@ -188,6 +188,10 @@ class OpdsParserTest {
val acquisitions = listOf( val acquisitions = listOf(
OpdsAcquisition("txt", "text/plain"), OpdsAcquisition("txt", "text/plain"),
OpdsAcquisition("pdf", "application/pdf"), OpdsAcquisition("pdf", "application/pdf"),
OpdsAcquisition(
"pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
),
OpdsAcquisition("epub", "application/epub+zip"), OpdsAcquisition("epub", "application/epub+zip"),
OpdsAcquisition("unknown", "application/octet-stream") OpdsAcquisition("unknown", "application/octet-stream")
) )
@ -200,9 +204,10 @@ class OpdsParserTest {
navigationUrl = null navigationUrl = null
) )
assertEquals("EPUB", acquisitions[2].formatName) assertEquals("EPUB", acquisitions[3].formatName)
assertEquals("PPTX", acquisitions[2].formatName)
assertEquals("TXT", acquisitions[0].formatName) assertEquals("TXT", acquisitions[0].formatName)
assertEquals("OCTET-STREAM", acquisitions[3].formatName) assertEquals("OCTET-STREAM", acquisitions[4].formatName)
assertEquals(acquisitions[2], entry.bestAcquisition) assertEquals(acquisitions[3], entry.bestAcquisition)
} }
} }

View file

@ -1,6 +1,8 @@
package com.aryan.reader.paginatedreader package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -20,6 +22,14 @@ class CfiUtilsTest {
assertEquals(0, CfiUtils.getOffset("/4/2/6:bad")) assertEquals(0, CfiUtils.getOffset("/4/2/6:bad"))
} }
@Test
fun `getOffsetOrNull only returns explicit numeric offsets`() {
assertEquals(13, CfiUtils.getOffsetOrNull("/4/2/6:13"))
assertEquals(0, CfiUtils.getOffsetOrNull("/4/2/6:0"))
assertNull(CfiUtils.getOffsetOrNull("/4/2/6"))
assertNull(CfiUtils.getOffsetOrNull("/4/2/6:bad"))
}
@Test @Test
fun `compare sorts numeric cfi paths before character offsets`() { fun `compare sorts numeric cfi paths before character offsets`() {
assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0) assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0)
@ -27,4 +37,12 @@ class CfiUtilsTest {
assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0) assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0)
assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6")) assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6"))
} }
@Test
fun `isPathStrictlyBetween requires a bounded numeric cfi path`() {
assertTrue(CfiUtils.isPathStrictlyBetween("/4/4", "/4/2:1", "/4/6:1"))
assertFalse(CfiUtils.isPathStrictlyBetween("/4/2", "/4/2:1", "/4/6:1"))
assertFalse(CfiUtils.isPathStrictlyBetween("/4/8", "/4/2:1", "/4/6:1"))
assertFalse(CfiUtils.isPathStrictlyBetween("/4/nav", "/4/2:1", "/4/6:1"))
}
} }

View file

@ -1,9 +1,12 @@
package com.aryan.reader.paginatedreader package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -123,6 +126,47 @@ class ContentStylerTest {
assertEquals("li1", first.elementId) assertEquals("li1", first.elementId)
assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item) assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item)
assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item) assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item)
assertTrue(first.content.spanStyles.any { range ->
range.start <= 0 &&
range.end >= 5 &&
range.item.color.isSpecified &&
range.item.color != Color.Red &&
range.item.background.isSpecified &&
range.item.textDecoration?.contains(TextDecoration.Underline) == true
})
}
@Test
fun `runtime theme reapplies visible link style for cached paginated text`() {
val linkText = "Cached link"
val text = buildAnnotatedString {
append(linkText)
addStringAnnotation("URL", "https://example.org", 0, linkText.length)
}
val page = Page(
content = listOf(
ParagraphBlock(
content = text,
blockIndex = 1
)
)
)
val themed = page.applyReaderThemeForDisplay(
isDarkTheme = true,
themeBackgroundColor = Color(0xFF121212),
themeTextColor = Color(0xFFE0E0E0)
)
val paragraph = themed.content.single() as ParagraphBlock
assertTrue(paragraph.content.spanStyles.any { range ->
range.start == 0 &&
range.end == linkText.length &&
range.item.color.isSpecified &&
range.item.color != Color(0xFFE0E0E0) &&
range.item.background.isSpecified &&
range.item.textDecoration?.contains(TextDecoration.Underline) == true
})
} }
@Test @Test

View file

@ -0,0 +1,83 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.AnnotatedString
import com.aryan.reader.epubreader.HighlightColor
import com.aryan.reader.epubreader.UserHighlight
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PaginatedHighlightMappingTest {
@Test
fun `single cfi highlight does not leak onto later matching block`() {
val block = paragraph(
text = "repeat",
cfi = "/4/4",
startOffset = 20
)
val highlight = highlight(
cfi = "/4/2:0",
text = "repeat"
)
assertNull(getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `multipart highlight can fill strict intermediate block`() {
val block = paragraph(
text = "middle",
cfi = "/4/4",
startOffset = 20
)
val highlight = highlight(
cfi = "/4/2:0|/4/6:10",
text = "start middle end"
)
assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `same path split block outside stored offsets is ignored`() {
val block = paragraph(
text = "repeat",
cfi = "/4/2",
startOffset = 20
)
val highlight = highlight(
cfi = "/4/2:0|/4/2:6",
text = "repeat"
)
assertNull(getHighlightOffsetsInBlock(block, highlight))
}
private fun paragraph(
text: String,
cfi: String,
startOffset: Int
): ParagraphBlock {
return ParagraphBlock(
content = AnnotatedString(text),
cfi = cfi,
startCharOffsetInSource = startOffset,
endCharOffsetInSource = startOffset + text.length,
blockIndex = startOffset
)
}
private fun highlight(
cfi: String,
text: String
): UserHighlight {
return UserHighlight(
id = "highlight",
cfi = cfi,
text = text,
color = HighlightColor.YELLOW,
chapterIndex = 0
)
}
}

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