diff --git a/.gitignore b/.gitignore
index 633a1fb..1984a25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,5 +22,6 @@ google-services.json
third_party/pdfium/
*.tgz
kcef-bundle/
+kcef-bundle-linux-x64/
cache/
worker/
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..b8dcf92
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,594 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
index 7fa5d16..d7fd42a 100644
--- a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
@@ -78,6 +78,19 @@ class AppNavigationTest {
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
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
// Trigger state change
@@ -115,4 +128,19 @@ class AppNavigationTest {
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
}
-}
\ No newline at end of file
+
+ @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)
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
index 25996eb..dcfe42e 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
@@ -352,6 +352,39 @@ class HtmlParserTest {
assertThat(pBlock.text).isEqualTo("Line one.\nLine two.")
}
+ @Test
+ fun htmlToSemanticBlocks_veryLongInlineParagraph_splitsIntoBoundedParagraphs() {
+ val longText = "a".repeat(40_000)
+ val blocks = parse("
$longText
")
+ val paragraphs = blocks.filterIsInstance()
+
+ 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 = """"""
+ val nestedHtml = (1..600).fold(mathPlaceholder) { content, _ ->
+ "$content"
+ }
+
+ val blocks = parse(
+ html = nestedHtml,
+ mathSvgCache = mapOf(mathId to "")
+ )
+
+ assertThat(blocks).hasSize(1)
+ assertThat(blocks.first()).isInstanceOf(SemanticMath::class.java)
+ }
+
@Test
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
// SETUP
@@ -372,4 +405,4 @@ class HtmlParserTest {
val imageBlock = block as SemanticImage
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0578e8c..cb03ebf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -164,6 +164,16 @@
+
+
+
+
+
+
+
+
+
+
@@ -240,4 +250,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js
index b5d78e9..8c7d2e1 100644
--- a/app/src/main/assets/epub_reader.js
+++ b/app/src/main/assets/epub_reader.js
@@ -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;
}
+ #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 {
height: auto !important;
}
@@ -206,6 +234,7 @@
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
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 bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
@@ -221,6 +250,9 @@
:root {
--reader-bg: ${effectiveBg};
--reader-text: ${effectiveText};
+ --reader-link: ${linkPalette.color};
+ --reader-link-decoration: ${linkPalette.color};
+ --reader-link-bg: ${linkPalette.background};
}
html.${themeClassName}, html.${themeClassName} body {
background-color: var(--reader-bg) !important;
@@ -228,21 +260,46 @@
${textureCss}
}
- html.${themeClassName} a {
- color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important;
+ html.${themeClassName} body a[href],
+ 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} a div,
- html.${themeClassName} a span,
- html.${themeClassName} a li,
- html.${themeClassName} a h1,
- html.${themeClassName} a h2,
- html.${themeClassName} a h3,
- html.${themeClassName} a h4,
- html.${themeClassName} a h5,
- html.${themeClassName} a h6 {
- color: var(--reader-text) !important;
+ html.${themeClassName} body a[href] p,
+ html.${themeClassName} body a[href] div,
+ html.${themeClassName} body a[href] span,
+ html.${themeClassName} body a[href] li,
+ html.${themeClassName} body a[href] h1,
+ html.${themeClassName} body a[href] h2,
+ html.${themeClassName} body a[href] h3,
+ html.${themeClassName} body a[href] h4,
+ html.${themeClassName} body a[href] h5,
+ html.${themeClassName} body a[href] h6,
+ 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;
}
@@ -290,6 +347,67 @@
} : {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) {
var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (parts) {
@@ -346,6 +464,7 @@
var elements = document.querySelectorAll('[style*="color"]');
elements.forEach(function(el) {
+ if (el.closest && el.closest('a[href]')) return;
var style = window.getComputedStyle(el);
var colorStr = style.color;
var rgb = rgbStringToRgb(colorStr);
diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp
index e56b5fe..94a881d 100644
--- a/app/src/main/cpp/pdfium_bridge.cpp
+++ b/app/src/main/cpp/pdfium_bridge.cpp
@@ -333,6 +333,58 @@ static bool init_pdfium() {
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
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
std::lock_guard lock(g_pdfium_mutex);
@@ -420,17 +472,18 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas
extern "C" JNIEXPORT jstring JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
std::lock_guard 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(pagePtr);
- void* annot = get_annot_func(page, index);
- if (!annot) return nullptr;
+ ScopedPdfAnnot annot = get_annot_checked(page, index);
+ if (!annot.get()) return nullptr;
const char* nativeKey = env->GetStringUTFChars(key, nullptr);
+ if (!nativeKey) return nullptr;
if (strcmp(nativeKey, "IRT") == 0) {
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) {
unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0);
jstring result = nullptr;
@@ -448,7 +501,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
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) {
env->ReleaseStringUTFChars(key, nativeKey);
@@ -456,7 +509,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
}
std::vector 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(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
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard 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(pagePtr);
- int count = get_annot_count_func(page);
+ int count = get_safe_annot_count(page);
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
- if (get_annot_rect_func(annot, r)) {
+ if (get_annot_rect_func(annot.get(), r)) {
// FIX: Use min/max to handle inverted PDF rectangles
float minX = fmin(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]);
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
- LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, get_annot_subtype_func(annot));
- return get_annot_subtype_func(annot);
+ int subtype = get_annot_subtype_func(annot.get());
+ 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
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard 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(pagePtr);
- int count = get_annot_count_func(page);
+ int count = get_safe_annot_count(page);
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];
- if (get_annot_rect_func(annot, rect)) {
- if (x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]) {
+ if (get_annot_rect_func(annot.get(), rect)) {
+ 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);
env->SetFloatArrayRegion(result, 0, 4, rect);
return result;
@@ -1641,26 +1704,26 @@ extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
std::lock_guard lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
- return get_annot_count_func(reinterpret_cast(pagePtr));
+ return get_safe_annot_count(reinterpret_cast(pagePtr));
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard lock(g_pdfium_mutex);
- if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0;
- void* annot = get_annot_func(reinterpret_cast(pagePtr), index);
- return annot ? get_annot_subtype_func(annot) : 0;
+ if (!init_pdfium() || !get_annot_subtype_func || pagePtr == 0) return 0;
+ ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast(pagePtr), index);
+ return annot.get() ? get_annot_subtype_func(annot.get()) : 0;
}
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard lock(g_pdfium_mutex);
- if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
- void* annot = get_annot_func(reinterpret_cast(pagePtr), index);
- if (!annot) return nullptr;
+ if (!init_pdfium() || !get_annot_rect_func || pagePtr == 0) return nullptr;
+ ScopedPdfAnnot annot = get_annot_checked(reinterpret_cast(pagePtr), index);
+ if (!annot.get()) return nullptr;
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);
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
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard 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(pagePtr);
- int count = get_annot_count_func(page);
- void* hitAnnot = nullptr;
+ int count = get_safe_annot_count(page);
+ int hitSubtype = 0;
LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count);
for (int i = 0; i < count; i++) {
- void* annot = get_annot_func(page, i);
- if (!annot) continue;
+ ScopedPdfAnnot annot = get_annot_checked(page, i);
+ if (!annot.get()) continue;
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 maxX = fmaxf(r[0], r[2]);
float minY = fminf(r[1], r[3]);
float maxY = fmaxf(r[1], r[3]);
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
- hitAnnot = annot;
- int subtype = get_annot_subtype_func(hitAnnot);
- LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, subtype);
+ hitSubtype = get_annot_subtype_func(annot.get());
+ LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, hitSubtype);
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);
}
break;
@@ -1702,25 +1764,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
}
}
- if (hitAnnot) {
- int subtype = get_annot_subtype_func(hitAnnot);
-
- if (subtype == 19 || subtype == 20) {
- LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", subtype);
+ if (hitSubtype != 0) {
+ if (hitSubtype == 19 || hitSubtype == 20) {
+ LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", hitSubtype);
bool anyChanged = false;
for (int j = 0; j < count; j++) {
- void* target = get_annot_func(page, j);
- if (!target || !get_annot_flags_func || !set_annot_flags_func) continue;
+ ScopedPdfAnnot target = get_annot_checked(page, j);
+ 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)
if (flags & (1 | 2 | 32)) {
LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags);
// 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;
}
}
diff --git a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt
new file mode 100644
index 0000000..acd0072
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt
@@ -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
+ )
+}
diff --git a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
new file mode 100644
index 0000000..1539a6f
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
@@ -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,
+ tagEntitiesById: Map
+ ): 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
+ ): 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,
+ val tagEntitiesById: Map,
+ val folderKeys: List,
+ val folderPathResolver: SharedFolderPathResolver
+)
+
+internal data class AndroidSharedFolderProjectionKey(
+ val uriString: String,
+ val name: String
+)
+
+private fun ReaderScreenState.withAndroidFolderFallbacks(books: Collection): 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)
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt
index 1d43227..626dbb8 100644
--- a/app/src/main/java/com/aryan/reader/AppNavigation.kt
+++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt
@@ -65,6 +65,7 @@ object AppDestinations {
const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route"
const val FONTS_SCREEN_ROUTE = "fonts_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 {
@@ -146,7 +147,7 @@ fun AppNavigation(
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
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 (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
@@ -165,6 +166,11 @@ fun AppNavigation(
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()
}
}
+
+ CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
} else if (uiState.isLoading) {
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
@@ -301,6 +309,8 @@ fun AppNavigation(
CircularProgressIndicator()
}
}
+
+ CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
isLoading -> {
@@ -361,5 +371,13 @@ fun AppNavigation(
onBackClick = { navController.popBackStackIfReady() }
)
}
+
+ composable(route = AppDestinations.SETTINGS_SCREEN_ROUTE) {
+ SettingsScreen(
+ viewModel = viewModel,
+ navController = navController,
+ onBackClick = { navController.popBackStackIfReady() }
+ )
+ }
}
}
diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/aryan/reader/AppUiModels.kt
index a5155dc..2907a9c 100644
--- a/app/src/main/java/com/aryan/reader/AppUiModels.kt
+++ b/app/src/main/java/com/aryan/reader/AppUiModels.kt
@@ -8,7 +8,11 @@ import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.Locator
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(
val internalUri: Uri,
@@ -17,37 +21,12 @@ data class ImportResult(
val bundleResult: CalibreBundleResult? = null
)
-data class UserData(
- val uid: String,
- val displayName: String?,
- val photoUrl: String?,
- val email: String?
-)
-
data class NavigationEvent(
val route: String,
val bookId: String? = 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 DeviceLimitReachedState(
@@ -109,7 +88,7 @@ data class ReaderScreenState(
val pinnedLibraryBookIds: Set = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0,
- val isTabsEnabled: Boolean = false,
+ val isTabsEnabled: Boolean = true,
val openTabIds: List = emptyList(),
val openTabs: List = emptyList(),
val activeTabBookId: String? = null,
diff --git a/app/src/main/java/com/aryan/reader/BookImporter.kt b/app/src/main/java/com/aryan/reader/BookImporter.kt
index 1b6d9c0..55714c7 100644
--- a/app/src/main/java/com/aryan/reader/BookImporter.kt
+++ b/app/src/main/java/com/aryan/reader/BookImporter.kt
@@ -109,8 +109,9 @@ class BookImporter(private val context: Context) {
when (context.contentResolver.getType(uri)) {
"application/pdf" -> "pdf"
"application/epub+zip" -> "epub"
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx"
else -> "tmp"
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt
index 98a7f64..df292c2 100644
--- a/app/src/main/java/com/aryan/reader/Common.kt
+++ b/app/src/main/java/com/aryan/reader/Common.kt
@@ -4,6 +4,7 @@
package com.aryan.reader
import android.content.Context
+import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.security.keystore.KeyGenParameterSpec
@@ -209,6 +210,7 @@ import javax.crypto.spec.GCMParameterSpec
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
+import kotlin.math.sqrt
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
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? {
if (textureId == null) return null
return try {
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 {
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
when {
@@ -2500,7 +2566,14 @@ fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap?
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) {
Timber.e(e, "Failed to load reader texture bitmap: $textureId")
null
@@ -2513,8 +2586,24 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
var mimeType = "image/png"
val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX))
- mimeType = imageMimeTypeForExtension(file.extension)
- file.readBytes()
+ mimeType = "image/png"
+ 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 {
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
when {
diff --git a/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt b/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt
new file mode 100644
index 0000000..36c2574
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/EmbeddedEbookMetadataExtractor.kt
@@ -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("""]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
+ .find(containerXml)
+ ?.groupValues
+ ?.get(1)
+ ?.decodeEntities()
+ ?.takeIf { it.isNotBlank() }
+ }
+
+ private fun parseEpubManifest(opf: String): List {
+ return Regex("""- ]*>""", 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? {
+ val coverId = Regex("""]*>""", 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()
+ var inAuthor = false
+ var inBody = false
+ var inCoverPage = false
+ val authorParts = mutableListOf()
+ 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,
+ 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("""]*>""", 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(" ", " ")
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", "\"")
+ .replace("'", "'")
+ .replace(Regex("([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()
+ 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,
+ val headerRecord: ByteArray
+ )
+
+ private data class MobiExthMetadata(
+ val title: String? = null,
+ val author: String? = null,
+ val coverOffset: Int? = null
+ )
+}
diff --git a/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt b/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt
new file mode 100644
index 0000000..f8f1d8e
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/EpubMetadataFileEditor.kt
@@ -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 = 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" }
+}
diff --git a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt
index 272691f..d5bd87b 100644
--- a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt
+++ b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt
@@ -1,130 +1,66 @@
package com.aryan.reader
-private val codeOrDataExtensions = setOf(
- "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"
-)
+import com.aryan.reader.shared.SharedFileCapabilities
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
- val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
- val effectiveName = lowerName.withTransparentTextSuffix()
+ return SharedFileCapabilities.resolveFileTypeForName(fileName)
+}
- return when {
- effectiveName.endsWith(".cbz") -> FileType.CBZ
- effectiveName.endsWith(".cbr") -> FileType.CBR
- effectiveName.endsWith(".cb7") -> FileType.CB7
- effectiveName.endsWith(".pdf") -> FileType.PDF
- effectiveName.endsWith(".epub") -> FileType.EPUB
- effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI
- effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2
- effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD
- effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML
- effectiveName.endsWith(".docx") -> FileType.DOCX
- effectiveName.endsWith(".odt") -> FileType.ODT
- effectiveName.endsWith(".fodt") -> FileType.FODT
- effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
- effectiveName.endsWith(".txt") -> FileType.TXT
- else -> null
+internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? {
+ val normalizedMimeType = mimeType
+ ?.substringBefore(';')
+ ?.trim()
+ ?.lowercase()
+ return when (normalizedMimeType) {
+ "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
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
+ "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
+ when {
+ fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
+ fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
+ 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 {
- return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
+ return SharedFileCapabilities.isCodeOrDataFileName(fileName)
}
internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
- val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false
- return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
+ return SharedFileCapabilities.isManualOnlyReaderFileName(fileName)
}
internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
- val normalized = mimeType?.lowercase() ?: return false
- return normalized in manualOnlyReaderMimeTypes
+ return SharedFileCapabilities.isManualOnlyReaderMimeType(mimeType)
}
internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
- if (isManualOnlyReaderFileName(name)) return false
- if (resolveFileTypeFromName(name) != null) return true
- return !isManualOnlyReaderMimeType(mimeType)
+ return SharedFileCapabilities.isLocalFolderSyncEligibleFile(name, mimeType)
}
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
- val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
- 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.. 0L && size > 0L && existingItem.fileSize != size) {
- Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
+ val modifiedChanged = lastModified > 0L &&
+ 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)
updatedItem = updatedItem.copy(
fileSize = size,
+ fileContentModifiedTimestamp = 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
}
@@ -500,7 +516,7 @@ class FolderSyncWorker(
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
- ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString")
+ ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
val metaRequest = OneTimeWorkRequestBuilder()
.setInputData(
androidx.work.Data.Builder()
@@ -514,7 +530,7 @@ class FolderSyncWorker(
metaRequest
)
} 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? {
- return when (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)
- }
+ return resolveFileTypeFromMetadata(name, mimeType)
}
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt
index dc9912b..b3aeb74 100644
--- a/app/src/main/java/com/aryan/reader/HomeScreen.kt
+++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt
@@ -71,6 +71,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
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.outlined.AccountCircle
import androidx.compose.material.icons.outlined.FavoriteBorder
@@ -140,10 +141,8 @@ import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
-import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
-import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
@@ -171,15 +170,16 @@ fun HomeScreen(
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- val screenModel = remember(uiState) { uiState.toHomeScreenModel() }
- val recentFilesForHome = screenModel.recentFiles
- val openTabs = screenModel.openTabs
- val selectedContextItems = screenModel.selectedItems
- val isContextualModeActive = screenModel.isContextualModeActive
+ val recentFilesForHome = uiState.recentFiles
+ val openTabs = uiState.openTabs
+ val selectedContextItems = uiState.contextualActionItems
+ val isContextualModeActive = selectedContextItems.isNotEmpty()
+ val isHomeEmpty = recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())
+ val isLibraryEmpty = uiState.rawLibraryFiles.isEmpty()
val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val snackbarHostState = remember { SnackbarHostState() }
- val deviceLimitState = screenModel.deviceLimitState
+ val deviceLimitState = uiState.deviceLimitState
var showDeleteConfirmDialog 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) {
uiState.errorMessage?.let { message ->
snackbarHostState.showSnackbar(message)
@@ -319,6 +310,12 @@ fun HomeScreen(
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
}
},
+ onSettingsClick = {
+ scope.launch {
+ drawerState.close()
+ navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
+ }
+ },
navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
)
@@ -353,6 +350,9 @@ fun HomeScreen(
}
},
onAppThemeClick = { showAppThemePanel = true },
+ onSettingsClick = {
+ navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
+ },
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
onLanguageClick = { showLanguageDialog = true },
@@ -394,8 +394,8 @@ fun HomeScreen(
.fillMaxSize()
.padding(paddingValues)
) {
- if (screenModel.isEmpty) {
- if (screenModel.isLibraryEmpty) {
+ if (isHomeEmpty) {
+ if (isLibraryEmpty) {
EmptyState(
title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.your_library_empty_desc),
@@ -502,8 +502,14 @@ fun HomeScreen(
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)) }
)
@@ -793,16 +799,8 @@ fun RecentFileCard(
onLongClick: () -> Unit,
isDownloading: Boolean,
) {
- val context = LocalContext.current
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
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(
modifier = modifier
@@ -827,24 +825,25 @@ fun RecentFileCard(
.fillMaxWidth()
.aspectRatio(0.74f)
) {
- AsyncImage(
- model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
- .fallback(placeholder).crossfade(true).build(),
+ ThemedBookCover(
+ item = item,
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
- Box(
- modifier = Modifier.fillMaxSize().background(
- androidx.compose.ui.graphics.Brush.verticalGradient(
- 0f to Color.Black.copy(alpha = 0.15f),
- 0.3f to Color.Transparent,
- 0.6f to Color.Transparent,
- 1f to Color.Black.copy(alpha = 0.5f)
+ if (!item.coverImagePath.isNullOrBlank()) {
+ Box(
+ modifier = Modifier.fillMaxSize().background(
+ androidx.compose.ui.graphics.Brush.verticalGradient(
+ 0f to Color.Black.copy(alpha = 0.15f),
+ 0.3f to Color.Transparent,
+ 0.6f to Color.Transparent,
+ 1f to Color.Black.copy(alpha = 0.5f)
+ )
)
)
- )
+ }
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
FileStatusBadges(
@@ -1024,6 +1023,7 @@ fun DefaultTopAppBar(
onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit,
+ onSettingsClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit,
onTestSpeechBubbleDetectionClick: () -> Unit,
onLanguageClick: () -> Unit,
@@ -1048,6 +1048,9 @@ fun DefaultTopAppBar(
}
}
}, actions = {
+ IconButton(onClick = onSettingsClick) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
Box {
IconButton(onClick = onAppThemeClick) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme))
@@ -1134,19 +1137,21 @@ fun DefaultTopAppBar(
showOptionsMenu = false
})
- DropdownMenuItem(
- text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
- onClick = {
- onToggleHideReaderAi()
- hideReaderAiFeatures = !hideReaderAiFeatures
- showOptionsMenu = false
- },
- trailingIcon = {
- if (hideReaderAiFeatures) {
- Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
+ if (!BuildConfig.IS_OFFLINE) {
+ DropdownMenuItem(
+ text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
+ onClick = {
+ onToggleHideReaderAi()
+ hideReaderAiFeatures = !hideReaderAiFeatures
+ showOptionsMenu = false
+ },
+ trailingIcon = {
+ if (hideReaderAiFeatures) {
+ Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
+ }
}
- }
- )
+ )
+ }
HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
@@ -1204,6 +1209,7 @@ private fun AppDrawerContent(
onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit,
onAiSettingsClick: () -> Unit,
+ onSettingsClick: () -> Unit,
navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit
) {
@@ -1366,6 +1372,14 @@ private fun AppDrawerContent(
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(
icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_custom_fonts)) },
diff --git a/app/src/main/java/com/aryan/reader/LibraryModels.kt b/app/src/main/java/com/aryan/reader/LibraryModels.kt
index f2bd2b4..627e57e 100644
--- a/app/src/main/java/com/aryan/reader/LibraryModels.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryModels.kt
@@ -1,40 +1,21 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
+import com.aryan.reader.shared.ReaderPlatform
+import com.aryan.reader.shared.SharedFileCapabilities
-enum class AddBooksSource {
- UNSHELVED,
- ALL_BOOKS
-}
+typealias AddBooksSource = com.aryan.reader.shared.AddBooksSource
+typealias FileType = com.aryan.reader.shared.FileType
+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 {
- PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
-}
-
-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.entries.toSet()
-)
+internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
+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
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
@@ -54,33 +35,3 @@ data class Shelf(
val directBookCount: Int get() = directBooks.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 = emptySet(),
- val sourceFolders: Set = emptySet(),
- val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
- val tagIds: Set = emptySet()
-) {
- val isActive: Boolean
- get() = fileTypes.isNotEmpty() ||
- sourceFolders.isNotEmpty() ||
- readStatus != ReadStatusFilter.ALL ||
- tagIds.isNotEmpty()
-}
diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
index 0b619e2..5559eff 100644
--- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
@@ -79,6 +79,7 @@ import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
@@ -133,6 +134,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
+import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
@@ -148,7 +150,6 @@ import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import timber.log.Timber
-import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -163,6 +164,7 @@ private fun getBookCountString(count: Int): String {
@Composable
fun LibraryScreen(
viewModel: MainViewModel,
+ navController: NavHostController,
) {
val compStart = remember { System.currentTimeMillis() }
LaunchedEffect(Unit) {
@@ -170,14 +172,13 @@ fun LibraryScreen(
}
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- val screenModel = remember(uiState) { uiState.toLibraryScreenModel() }
- val selectedItems = screenModel.selectedItems
- val isContextualModeActive = screenModel.isContextualModeActive
- val selectedShelves = screenModel.selectedShelves
- val isShelfContextualModeActive = screenModel.isShelfContextualModeActive
- val sortOrder = screenModel.sortOrder
- val shelves = screenModel.shelves
- val rawLibraryFiles = screenModel.rawLibraryFiles
+ val selectedItems = uiState.contextualActionItems
+ val isContextualModeActive = selectedItems.isNotEmpty()
+ val selectedShelves = uiState.contextualActionShelfIds
+ val isShelfContextualModeActive = selectedShelves.isNotEmpty()
+ val sortOrder = uiState.sortOrder
+ val shelves = uiState.shelves
+ val rawLibraryFiles = uiState.rawLibraryFiles
val tabTitles = remember {
buildList {
add(context.getString(R.string.tab_all_books))
@@ -193,13 +194,13 @@ fun LibraryScreen(
pageCount = { tabTitles.size }
)
- val containsFolderItems = screenModel.containsFolderItemsInSelection
+ val containsFolderItems = selectedItems.any { it.sourceFolderUri != null }
val scope = rememberCoroutineScope()
var showFilterSheet by remember { mutableStateOf(false) }
- val isSearchActive = screenModel.isSearchActive
- val searchQuery = screenModel.searchQuery
+ val isSearchActive = uiState.isSearchActive
+ val searchQuery = uiState.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
@@ -341,7 +342,8 @@ fun LibraryScreen(
catalogId = catalog?.id
)
},
- onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog
+ onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
+ onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }
)
@@ -394,8 +396,14 @@ fun LibraryScreen(
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)) }
)
@@ -516,7 +524,9 @@ fun ShelfScreen(
FileInfoDialog(
item = item,
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)) }
)
}
@@ -577,6 +587,7 @@ fun LibraryScreenContent(
onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
+ onSettingsClick: () -> Unit,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
@@ -711,6 +722,9 @@ fun LibraryScreenContent(
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
}
+ IconButton(onClick = onSettingsClick) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
}
)
TabRow(selectedTabIndex = pagerState.currentPage) {
@@ -1442,8 +1456,6 @@ private fun AddBooksModeScreen(
@Composable
private fun ShelfCover(shelf: Shelf) {
- val context = LocalContext.current
- val placeholder = R.drawable.epub_placeholder
val booksForCovers = shelf.books.take(4).reversed()
val coverWidth = 52.dp
val coverHeight = 75.dp
@@ -1457,22 +1469,24 @@ private fun ShelfCover(shelf: Shelf) {
contentAlignment = Alignment.CenterStart
) {
if (booksForCovers.size <= 1) {
- val imageModel = remember(shelf.topBook?.coverImagePath) {
- shelf.topBook?.coverImagePath?.let { File(it) } ?: placeholder
+ val topBook = shelf.topBook
+ if (topBook != null) {
+ ThemedBookCover(
+ item = topBook,
+ contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(width = coverWidth, height = coverHeight)
+ .clip(MaterialTheme.shapes.small)
+ )
+ } else {
+ EmptyShelfCover(
+ shelfName = shelf.name,
+ modifier = Modifier
+ .size(width = coverWidth, height = coverHeight)
+ .clip(MaterialTheme.shapes.small)
+ )
}
- AsyncImage(
- model = ImageRequest.Builder(context)
- .data(imageModel)
- .error(placeholder)
- .fallback(placeholder)
- .crossfade(true)
- .build(),
- contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .size(width = coverWidth, height = coverHeight)
- .clip(MaterialTheme.shapes.small)
- )
} else {
Box(
modifier = Modifier
@@ -1480,9 +1494,6 @@ private fun ShelfCover(shelf: Shelf) {
.height(coverHeight)
) {
booksForCovers.forEachIndexed { index, book ->
- val imageModel = remember(book.coverImagePath) {
- book.coverImagePath?.let { File(it) } ?: placeholder
- }
Surface(
shape = MaterialTheme.shapes.small,
shadowElevation = 4.dp,
@@ -1491,13 +1502,8 @@ private fun ShelfCover(shelf: Shelf) {
.align(Alignment.CenterEnd)
.offset(x = -horizontalOffset * index)
) {
- AsyncImage(
- model = ImageRequest.Builder(context)
- .data(imageModel)
- .error(placeholder)
- .fallback(placeholder)
- .crossfade(true)
- .build(),
+ ThemedBookCover(
+ item = book,
contentDescription = null,
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)
@Composable
private fun ShelfListItem(
@@ -1596,15 +1632,6 @@ private fun LibraryListItem(
onItemLongClick: () -> Unit,
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(
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@@ -1641,13 +1668,8 @@ private fun LibraryListItem(
.clip(MaterialTheme.shapes.medium)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
) {
- AsyncImage(
- model = ImageRequest.Builder(context)
- .data(imageModel)
- .error(placeholder)
- .fallback(placeholder)
- .crossfade(true)
- .build(),
+ ThemedBookCover(
+ item = item,
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
@@ -2148,7 +2170,7 @@ private fun EditFolderFiltersDialog(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- FileType.entries.forEach { type ->
+ ANDROID_SYNCABLE_FILE_TYPES.forEach { type ->
val isSelected = type in selectedTypes
FilterChip(
selected = isSelected,
@@ -2226,7 +2248,7 @@ fun LibraryFilterSheet(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- FileType.entries.forEach { type ->
+ ANDROID_READABLE_FILE_TYPES.forEach { type ->
FilterChip(
selected = type in currentFilters.fileTypes,
onClick = {
diff --git a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
index 323fb80..004dbde 100644
--- a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
@@ -5,7 +5,10 @@ import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
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 relativeFolderSegments(item: RecentFileItem): List
@@ -32,17 +35,28 @@ class LibraryStateProjector(
fun project(input: LibraryProjectionInput): ReaderScreenState {
val start = ReaderPerfLog.nowNanos()
val internalState = input.state
+ val bridgeContext = AndroidSharedStateBridge.prepareLibraryProjection(input, folderPathResolver)
val cacheKey = ProjectionCacheKey(
recentFilesFromDb = input.recentFilesFromDb,
dbShelves = input.dbShelves,
shelfRefs = input.shelfRefs,
dbTags = input.dbTags,
tagRefs = input.tagRefs,
- folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) },
+ folderKeys = bridgeContext.folderKeys,
sortOrder = internalState.sortOrder,
searchQuery = internalState.searchQuery,
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 ->
@@ -50,71 +64,26 @@ class LibraryStateProjector(
val elapsed = ReaderPerfLog.elapsedMs(start)
if (elapsed >= 8L) {
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
}
- val tagsById = input.dbTags.associateBy { it.id }
- 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 projected = AndroidSharedStateBridge.projectLibrary(bridgeContext)
val cache = CachedProjection(
key = cacheKey,
- allLibraryFiles = allLibraryFiles,
- allLibraryFilesById = allLibraryFilesById,
- sortedLibraryFiles = sortedLibraryFiles,
- visibleRecentFiles = visibleRecentFiles,
- shelfProjection = shelfProjection,
- validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id },
- dbTags = input.dbTags
+ projected = projected,
+ androidBooksById = bridgeContext.androidBooksById,
+ tagEntitiesById = bridgeContext.tagEntitiesById
)
cachedProjection = cache
val elapsed = ReaderPerfLog.elapsedMs(start)
- if (elapsed >= 16L || allLibraryFiles.size >= 500) {
+ if (elapsed >= 16L || bridgeContext.androidBooksById.size >= 500) {
ReaderPerfLog.d(
- "LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " +
- "visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " +
+ "LibraryProject shared recompute took ${elapsed}ms books=${bridgeContext.androidBooksById.size} " +
+ "visible=${projected.libraryBooks.size} shelves=${projected.shelves.size} " +
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
)
}
@@ -126,310 +95,63 @@ class LibraryStateProjector(
internalState: ReaderScreenState,
cache: CachedProjection
): ReaderScreenState {
- val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds }
- val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds }
- val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
- val currentShelfBookIds = cache.shelfProjection.shelves
- .find { it.id == viewingShelfId }
- ?.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
+ return AndroidSharedStateBridge.toAndroidState(
+ base = internalState,
+ sharedState = cache.projected,
+ androidBooksById = cache.androidBooksById,
+ tagEntitiesById = cache.tagEntitiesById
)
}
- private fun buildShelves(
- allLibraryFiles: List,
- dbShelves: List,
- shelfRefs: List,
- dbTags: List,
- sortOrder: SortOrder,
- syncedFolders: List
- ): ShelfProjection {
- val allShelves = mutableListOf()
- val shelvedBookIds = mutableSetOf()
- val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
- val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId }
- val taggedBookIdsByTagId = mutableMapOf>()
-
- 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,
- syncedFolders: List,
- sortOrder: SortOrder
- ): List {
- 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()
- val nestedShelvesById = mutableMapOf()
- 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 = mutableListOf(),
- val directBooks: MutableList = mutableListOf(),
- val childShelfIds: MutableList = mutableListOf()
- )
-
- private data class ShelfProjection(
- val shelves: List,
- val unshelvedBooks: List
- )
-
private data class ProjectionCacheKey(
val recentFilesFromDb: List,
val dbShelves: List,
val shelfRefs: List,
val dbTags: List,
val tagRefs: List,
- val folderKeys: List,
+ val folderKeys: List,
val sortOrder: SortOrder,
val searchQuery: String,
val libraryFilters: LibraryFilters,
- val recentFilesLimit: Int
- )
-
- private data class SyncedFolderProjectionKey(
- val uriString: String,
- val name: String
+ val recentFilesLimit: Int,
+ val openTabIds: List,
+ val activeTabBookId: String?,
+ val selectedBookIds: Set,
+ val selectedShelfIds: Set,
+ val viewingShelfId: String?,
+ val isAddingBooksToShelf: Boolean,
+ val addBooksSource: AddBooksSource,
+ val booksSelectedForAdding: Set,
+ val pinnedHomeBookIds: Set,
+ val pinnedLibraryBookIds: Set
)
private data class CachedProjection(
val key: ProjectionCacheKey,
- val allLibraryFiles: List,
- val allLibraryFilesById: Map,
- val sortedLibraryFiles: List,
- val visibleRecentFiles: List,
- val shelfProjection: ShelfProjection,
- val validShelfIds: Set,
- val dbTags: List
+ val projected: SharedReaderScreenState,
+ val androidBooksById: Map,
+ val tagEntitiesById: Map
)
}
fun filterBySearch(files: List, searchQuery: String): List {
- val query = searchQuery.trim()
- 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) }
- }
- }
+ return files.mapSharedResults(sharedFilterBySearch(files.map { it.toSharedProjectionBookItem() }, searchQuery))
}
fun applyLibraryFilters(files: List, filters: LibraryFilters): List {
- return files.filter { item ->
- val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
- val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
- val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") &&
- 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
- }
+ return files.mapSharedResults(
+ sharedApplyLibraryFilters(
+ books = files.map { it.toSharedProjectionBookItem() },
+ filters = filters.toSharedLibraryFilters()
+ )
+ )
}
fun sortFiles(files: List, sortOrder: SortOrder): List {
- return when (sortOrder) {
- 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() })
- SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
- SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
- SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
- SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
- }
+ return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder()))
+}
+
+private fun List.mapSharedResults(sharedBooks: List): List {
+ val byId = associateBy { it.bookId }
+ return sharedBooks.mapNotNull { byId[it.id] }
}
diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt
index 3033ca8..da7e01e 100644
--- a/app/src/main/java/com/aryan/reader/MainActivity.kt
+++ b/app/src/main/java/com/aryan/reader/MainActivity.kt
@@ -121,9 +121,6 @@ class MainActivity : AppCompatActivity() {
}
}
}
- if (BuildConfig.DEBUG) {
- WebView.setWebContentsDebuggingEnabled(true)
- }
}
override fun onNewIntent(intent: Intent) {
diff --git a/app/src/main/java/com/aryan/reader/MainScreen.kt b/app/src/main/java/com/aryan/reader/MainScreen.kt
index 7e5a4ff..c0c933d 100644
--- a/app/src/main/java/com/aryan/reader/MainScreen.kt
+++ b/app/src/main/java/com/aryan/reader/MainScreen.kt
@@ -110,7 +110,10 @@ fun MainScreen(
windowSizeClass = windowSizeClass,
navController = navController
)
- 1 -> LibraryScreen(viewModel = viewModel)
+ 1 -> LibraryScreen(
+ viewModel = viewModel,
+ navController = navController
+ )
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt
index dd58c74..9194d90 100644
--- a/app/src/main/java/com/aryan/reader/MainViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt
@@ -54,6 +54,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.aryan.reader.data.BookMetadata
+import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.CloudflareRepository
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.data.FeedbackRepository
@@ -80,6 +81,7 @@ import com.aryan.reader.epub.SingleFileImporter
import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.ml.ISpeechBubbleDetector
import com.aryan.reader.ml.SpeechBubble
+import com.aryan.reader.ml.SpeechBubbleDetector
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
@@ -95,9 +97,13 @@ import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.VirtualPage
+import com.aryan.reader.pptx.PptxCoverGenerator
import com.aryan.reader.shared.SharedLibraryEditor
-import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
+import com.aryan.reader.shared.SharedImportOutcomeCounts
+import com.aryan.reader.shared.SharedImportPlanner
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
+import com.aryan.reader.shared.AppAction as SharedAppAction
+import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@@ -107,10 +113,10 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@@ -150,6 +156,8 @@ private data class CachedSpeechBubble(
val maskBitmap: Bitmap?
)
+private const val BANNER_AUTO_DISMISS_MILLIS = 3_000L
+
@kotlin.OptIn(ExperimentalSerializationApi::class)
@UnstableApi
open class MainViewModel(application: Application) : AndroidViewModel(application) {
@@ -165,6 +173,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) }
private val singleFileImporter by lazy { SingleFileImporter(appContext) }
private val bookImporter by lazy { BookImporter(appContext) }
+ private val epubMetadataFileEditor by lazy { EpubMetadataFileEditor(appContext) }
private val pageLayoutRepository by lazy { PageLayoutRepository(appContext) }
private val pdfRichTextRepository by lazy { com.aryan.reader.pdf.PdfRichTextRepository(appContext) }
private val pdfTextBoxRepository by lazy { PdfTextBoxRepository(appContext) }
@@ -192,6 +201,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val _navigationEvent = Channel(Channel.BUFFERED)
@Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow()
+ private var bannerDismissJob: Job? = null
+ private var bannerDismissGeneration = 0L
private var pendingSwitchDeferred: CompletableDeferred? = null
private var externalOpenedBookId: String? = null
@@ -208,7 +219,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite")
if (modelFile.exists()) {
try {
- val clazz = Class.forName("com.aryan.reader.ml.ComicPanelDetector")
+ val clazz = Class.forName(
+ "com.aryan.reader.ml.ComicPanelDetector",
+ false,
+ context.classLoader
+ )
panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as com.aryan.reader.ml.IPanelDetector
} catch (e: Exception) {
Timber.e(e, "Failed to instantiate ComicPanelDetector via reflection")
@@ -225,10 +240,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort")
if (modelFile.exists()) {
try {
- val clazz = Class.forName("com.aryan.reader.ml.SpeechBubbleDetector")
- speechBubbleDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as ISpeechBubbleDetector
+ speechBubbleDetector = SpeechBubbleDetector(modelFile)
} catch (t: Throwable) {
- Timber.e(t, "Failed to instantiate SpeechBubbleDetector via reflection. Deleting corrupted model.")
+ Timber.e(t, "Failed to instantiate SpeechBubbleDetector. Deleting corrupted model.")
modelFile.delete()
}
} else {
@@ -519,7 +533,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
libraryFilters = LibraryFilters(
fileTypes = prefs.getStringSet(KEY_FILTER_FILE_TYPES, emptySet())?.mapNotNull {
runCatching { FileType.valueOf(it) }.getOrNull()
- }?.toSet() ?: emptySet(),
+ }?.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES } ?: emptySet(),
sourceFolders = prefs.getStringSet(KEY_FILTER_FOLDERS, emptySet()) ?: emptySet(),
readStatus = runCatching {
ReadStatusFilter.valueOf(prefs.getString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) ?: ReadStatusFilter.ALL.name)
@@ -534,7 +548,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(),
pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet(),
recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0),
- isTabsEnabled = prefs.getBoolean(KEY_TABS_ENABLED, false),
+ isTabsEnabled = prefs.getBoolean(KEY_TABS_ENABLED, true),
openTabIds = prefs.getString(KEY_OPEN_TAB_IDS, null)?.let {
try {
val arr = JSONArray(it)
@@ -634,14 +648,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
initialValue = _internalState.value
)
+ private fun ReaderScreenState.withSharedLibraryAction(action: SharedLibraryAction): ReaderScreenState {
+ return AndroidSharedStateBridge.reduceLibraryAction(
+ current = this,
+ projectedState = uiState.value,
+ action = action
+ )
+ }
+
+ private fun ReaderScreenState.withSharedAppAction(action: SharedAppAction): ReaderScreenState {
+ return AndroidSharedStateBridge.reduceAppAction(
+ current = this,
+ projectedState = uiState.value,
+ action = action
+ )
+ }
+
fun setTabsEnabled(enabled: Boolean) {
+ val projectedState = uiState.value
prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) }
- _internalState.update { it.copy(isTabsEnabled = enabled) }
+ _internalState.update {
+ AndroidSharedStateBridge.setTabsEnabled(
+ current = it,
+ projectedState = projectedState,
+ enabled = enabled
+ )
+ }
if (!enabled) {
- val active = _internalState.value.activeTabBookId
- val newTabs = if (active != null) listOf(active) else emptyList()
- prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(newTabs).toString()) }
- _internalState.update { it.copy(openTabIds = newTabs) }
+ persistTabState(_internalState.value.openTabIds, _internalState.value.activeTabBookId)
}
}
@@ -652,21 +686,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return
}
- val currentTabs = _internalState.value.openTabIds.toMutableList()
- if (!currentTabs.contains(bookId)) {
- if (currentTabs.size >= 20) {
+ val currentState = _internalState.value
+ if (bookId !in currentState.openTabIds) {
+ if (currentState.openTabIds.size >= 20) {
viewModelScope.launch(Dispatchers.Main) {
showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true)
}
return
}
- currentTabs.add(bookId)
}
+ val tabState = AndroidSharedStateBridge.openBookTab(
+ current = currentState,
+ projectedState = uiState.value,
+ bookId = bookId
+ )
- prefs.edit {
- putString(KEY_ACTIVE_TAB, bookId)
- putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString())
- }
+ persistTabState(tabState.openTabIds, tabState.activeTabBookId)
val uri = item.getUri()
Timber.tag("PdfTabSync").d("ViewModel: ActiveTab updated to $bookId. URI found: ${uri != null}")
@@ -676,8 +711,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("PdfTabSync").d("ViewModel: Setting new URI directly: $it")
_internalState.update { state ->
state.copy(
- openTabIds = currentTabs,
- activeTabBookId = bookId,
+ isTabsEnabled = tabState.isTabsEnabled,
+ openTabIds = tabState.openTabIds,
+ activeTabBookId = tabState.activeTabBookId,
selectedPdfUri = it,
selectedBookId = bookId,
selectedFileType = item.type,
@@ -699,7 +735,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
} ?: run {
- _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) }
+ _internalState.update {
+ it.copy(
+ openTabIds = tabState.openTabIds,
+ activeTabBookId = tabState.activeTabBookId,
+ isTabsEnabled = tabState.isTabsEnabled
+ )
+ }
+ }
+ }
+
+ private fun persistTabState(openTabIds: List, activeTabBookId: String?) {
+ prefs.edit {
+ if (openTabIds.isEmpty()) {
+ remove(KEY_OPEN_TAB_IDS)
+ } else {
+ putString(KEY_OPEN_TAB_IDS, JSONArray(openTabIds).toString())
+ }
+ if (activeTabBookId == null) {
+ remove(KEY_ACTIVE_TAB)
+ } else {
+ putString(KEY_ACTIVE_TAB, activeTabBookId)
+ }
}
}
@@ -840,29 +897,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun closeTab(bookId: String) {
Timber.tag("PdfTabSync").i("ViewModel: closeTab called for $bookId")
- val currentTabs = _internalState.value.openTabIds.toMutableList()
- currentTabs.remove(bookId)
+ val currentState = _internalState.value
+ val tabState = AndroidSharedStateBridge.closeBookTab(
+ current = currentState,
+ projectedState = uiState.value,
+ bookId = bookId
+ )
- if (currentTabs.isEmpty()) {
- prefs.edit {
- remove(KEY_OPEN_TAB_IDS)
- remove(KEY_ACTIVE_TAB)
+ if (tabState.openTabIds.isEmpty()) {
+ persistTabState(tabState.openTabIds, tabState.activeTabBookId)
+ _internalState.update {
+ it.copy(
+ isTabsEnabled = tabState.isTabsEnabled,
+ openTabIds = tabState.openTabIds,
+ activeTabBookId = tabState.activeTabBookId
+ )
}
- _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) }
clearSelectedFile()
} else {
- val activeTab = _internalState.value.activeTabBookId
+ val activeTab = currentState.activeTabBookId
if (activeTab == bookId) {
- val nextTabId = currentTabs.last()
- prefs.edit {
- putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString())
- putString(KEY_ACTIVE_TAB, nextTabId)
+ val nextTabId = tabState.activeTabBookId ?: tabState.openTabIds.last()
+ persistTabState(tabState.openTabIds, nextTabId)
+ _internalState.update {
+ it.copy(
+ isTabsEnabled = tabState.isTabsEnabled,
+ openTabIds = tabState.openTabIds,
+ activeTabBookId = nextTabId
+ )
}
- _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = nextTabId) }
switchTab(nextTabId)
} else {
- prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) }
- _internalState.update { it.copy(openTabIds = currentTabs) }
+ persistTabState(tabState.openTabIds, tabState.activeTabBookId)
+ _internalState.update {
+ it.copy(
+ isTabsEnabled = tabState.isTabsEnabled,
+ openTabIds = tabState.openTabIds,
+ activeTabBookId = tabState.activeTabBookId
+ )
+ }
}
}
}
@@ -870,7 +943,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun onSearchQueryChange(newQuery: String) {
_internalState.update {
if (it.isSearchActive) {
- it.copy(searchQuery = newQuery)
+ it.withSharedLibraryAction(SharedLibraryAction.SearchChanged(newQuery))
} else {
it
}
@@ -1061,7 +1134,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
init {
Timber.d("ViewModel instance created.")
- WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
+ WorkManager.getInstance(application).apply {
+ cancelUniqueWork(FolderSyncWorker.WORK_NAME)
+ pruneWork()
+ }
val locatorConverter = LocatorConverter(
bookCacheDao,
@@ -1112,6 +1188,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
remoteConfigRepository.init()
+ viewModelScope.launch {
+ _internalState
+ .map { it.bannerMessage }
+ .distinctUntilChanged()
+ .collect { banner ->
+ scheduleBannerAutoDismiss(banner)
+ }
+ }
+
if (_internalState.value.syncedFolders.isNotEmpty()) {
triggerFolderSyncWorker(metadataOnly = false, showFeedback = false)
}
@@ -1337,7 +1422,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
FileType.EPUB -> epubParser.createEpubBook(
inputStream = inputStream,
bookId = bookId,
- originalBookNameHint = displayName
+ originalBookNameHint = displayName,
+ sourceFingerprint = epubSourceFingerprint(uri)
)
FileType.MOBI -> mobiParser.createMobiBook(
@@ -1627,10 +1713,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return@launch
}
+ val tokenHash = PurchaseAccountObfuscator.purchaseTokenHash(purchase.purchaseToken)
+ Timber.i(
+ "Verifying purchase. productId=$productId tokenHash=$tokenHash orderId=${purchase.orderId} " +
+ "obfuscatedAccountId=${purchase.obfuscatedAccountId} uid=${_internalState.value.currentUser?.uid} " +
+ "silent=$isSilentMigrationCheck"
+ )
+
val result = cloudflareRepository.verifyPurchase(purchase.purchaseToken, productId)
if (result.isSuccess) {
Timber.i("Backend verification successful. Firestore will update the app.")
+ billingClientWrapper.clearAccountConflict()
if (productId.startsWith("credits_")) {
billingClientWrapper.consumePurchase(purchase.purchaseToken)
@@ -1649,6 +1743,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("Migration/Refresh check: Purchase token is already claimed. Silently ignoring.")
if (productId.startsWith("credits_")) {
billingClientWrapper.consumePurchase(purchase.purchaseToken)
+ } else {
+ billingClientWrapper.markAccountConflict()
}
} else {
val errorMessage = appContext.getString(R.string.error_purchase_verification)
@@ -1827,36 +1923,36 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun togglePinForContextualItems(isHome: Boolean) {
- val selectedIds = _internalState.value.contextualActionItems.map { it.bookId }.toSet()
- if (selectedIds.isEmpty()) return
+ if (_internalState.value.contextualActionItems.isEmpty()) return
+ var pinsToPersist: Set = emptySet()
+ val projectedState = uiState.value
_internalState.update { state ->
- val currentPins = if (isHome) state.pinnedHomeBookIds else state.pinnedLibraryBookIds
- val allPinned = selectedIds.all { it in currentPins }
-
- val newPins = if (allPinned) currentPins - selectedIds else currentPins + selectedIds
-
- prefs.edit { putStringSet(if (isHome) KEY_PINNED_HOME else KEY_PINNED_LIBRARY, newPins) }
-
- if (isHome) {
- state.copy(pinnedHomeBookIds = newPins, contextualActionItems = emptySet())
- } else {
- state.copy(pinnedLibraryBookIds = newPins, contextualActionItems = emptySet())
- }
+ val updated = AndroidSharedStateBridge.togglePinsForSelectedBooks(
+ current = state,
+ projectedState = projectedState,
+ isHome = isHome
+ )
+ pinsToPersist = if (isHome) updated.pinnedHomeBookIds else updated.pinnedLibraryBookIds
+ updated
}
+ prefs.edit { putStringSet(if (isHome) KEY_PINNED_HOME else KEY_PINNED_LIBRARY, pinsToPersist) }
}
fun updateLibraryFilters(filters: LibraryFilters) {
- _internalState.update { it.copy(libraryFilters = filters) }
+ val sanitizedFilters = filters.copy(
+ fileTypes = filters.fileTypes.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES }
+ )
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters.toSharedLibraryFilters())) }
prefs.edit {
- putStringSet(KEY_FILTER_FILE_TYPES, filters.fileTypes.map { it.name }.toSet())
- putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders)
- putString(KEY_FILTER_READ_STATUS, filters.readStatus.name)
- putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds)
+ putStringSet(KEY_FILTER_FILE_TYPES, sanitizedFilters.fileTypes.map { it.name }.toSet())
+ putStringSet(KEY_FILTER_FOLDERS, sanitizedFilters.sourceFolders)
+ putString(KEY_FILTER_READ_STATUS, sanitizedFilters.readStatus.name)
+ putStringSet(KEY_FILTER_TAG_IDS, sanitizedFilters.tagIds)
}
- Timber.d("Library filters updated and persisted: $filters")
+ Timber.d("Library filters updated and persisted: $sanitizedFilters")
}
suspend fun sharePdf(
@@ -1979,7 +2075,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val hasTextBoxes = textBoxFile.exists()
val hasHighlights = highlightFile.exists()
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " +
"richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData"
)
@@ -1997,7 +2093,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
try {
val content = file.readText().trim()
if (key == "text") {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.cloud.export.readRichText book=${book.bookId} rawLen=${content.length} " +
"file=${file.absolutePath}"
)
@@ -2009,8 +2105,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
} catch (e: Exception) {
if (key == "text") {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
- .e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}")
+ Timber.e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}")
}
Timber.e(e, "Failed to parse local $key file")
}
@@ -2027,7 +2122,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
bundleFile.writeText(canonicalBundle)
if (hasRichText) {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " +
"bundleFile=${bundleFile.absolutePath}"
)
@@ -2040,14 +2135,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (uploaded != null) {
if (hasRichText) {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
+ Timber
.d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}")
}
Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else {
if (hasRichText) {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
+ Timber
.e("android.cloud.export.uploadFailed book=${book.bookId}")
}
Timber.tag("AnnotationSync")
@@ -2299,7 +2394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
if (oldUri != null) {
val name = getDisplayPathFromUri(appContext, oldUri)
- val migrated = SyncedFolder(oldUri, name, oldTime, FileType.entries.toSet())
+ val migrated = SyncedFolder(oldUri, name, oldTime, ANDROID_SYNCABLE_FILE_TYPES)
folders.add(migrated)
saveSyncedFoldersToPrefs(folders)
@@ -2323,7 +2418,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (_: Exception) {}
}
} else {
- allowedFileTypes.addAll(FileType.entries)
+ allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
}
folders.add(
@@ -2331,7 +2426,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uriString = obj.getString("uri"),
name = obj.getString("name"),
lastScanTime = obj.optLong("lastScanTime", 0L),
- allowedFileTypes = allowedFileTypes
+ allowedFileTypes = allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }
)
)
}
@@ -2350,7 +2445,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
obj.put("name", folder.name)
obj.put("lastScanTime", folder.lastScanTime)
val typesArray = JSONArray()
- folder.allowedFileTypes.forEach { typesArray.put(it.name) }
+ folder.allowedFileTypes
+ .filter { it in ANDROID_SYNCABLE_FILE_TYPES }
+ .forEach { typesArray.put(it.name) }
obj.put("allowedFileTypes", typesArray)
jsonArray.put(obj)
}
@@ -2378,7 +2475,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
val name = getDisplayPathFromUri(appContext, folderUri.toString())
- val newFolder = SyncedFolder(folderUri.toString(), name, 0L, FileType.entries.toSet())
+ val newFolder = SyncedFolder(folderUri.toString(), name, 0L, ANDROID_SYNCABLE_FILE_TYPES)
val newStats = currentFolders + newFolder
saveSyncedFoldersToPrefs(newStats)
@@ -2480,48 +2577,51 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
viewModelScope.launch {
- workManager.getWorkInfoByIdFlow(request.id).collect { workInfo ->
- if (workInfo != null) {
- when (workInfo.state) {
- WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
- if (showFeedback) {
- val msg = if (metadataOnly) appContext.getString(R.string.banner_folder_sync_updating) else appContext.getString(R.string.banner_folder_sync_scanning)
- _internalState.update {
- it.copy(
- isLoading = false,
- isRefreshing = true,
- bannerMessage = BannerMessage(msg, isPersistent = true)
- )
- }
- }
- }
-
- WorkInfo.State.SUCCEEDED -> {
+ workManager.getWorkInfoByIdFlow(request.id).filterNotNull().first { workInfo ->
+ when (workInfo.state) {
+ WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
+ if (showFeedback) {
+ val msg = if (metadataOnly) appContext.getString(R.string.banner_folder_sync_updating) else appContext.getString(R.string.banner_folder_sync_scanning)
_internalState.update {
it.copy(
isLoading = false,
- isRefreshing = false,
- bannerMessage = if (showFeedback) BannerMessage(appContext.getString(R.string.banner_folder_sync_complete)) else it.bannerMessage,
- lastFolderScanTime = System.currentTimeMillis(),
- syncedFolders = loadSyncedFoldersFromPrefs()
+ isRefreshing = true,
+ bannerMessage = BannerMessage(msg, isPersistent = true)
)
}
}
-
- WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
- _internalState.update {
- it.copy(
- isLoading = false,
- isRefreshing = false,
- errorMessage = if (showFeedback) appContext.getString(R.string.error_sync_failed) else it.errorMessage,
- bannerMessage = null
- )
- }
- }
-
- else -> Unit
}
+
+ WorkInfo.State.SUCCEEDED -> {
+ _internalState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ bannerMessage = if (showFeedback) BannerMessage(appContext.getString(R.string.banner_folder_sync_complete)) else it.bannerMessage,
+ lastFolderScanTime = System.currentTimeMillis(),
+ syncedFolders = loadSyncedFoldersFromPrefs()
+ )
+ }
+ }
+
+ WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
+ _internalState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ errorMessage = if (showFeedback) appContext.getString(R.string.error_sync_failed) else it.errorMessage,
+ bannerMessage = null
+ )
+ }
+ }
+
+ else -> Unit
}
+
+ if (workInfo.state.isFinished) {
+ workManager.pruneWork()
+ }
+ workInfo.state.isFinished
}
}
}
@@ -2531,13 +2631,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val currentFolders = _internalState.value.syncedFolders.toMutableList()
val index = currentFolders.indexOfFirst { it.uriString == folder.uriString }
if (index != -1) {
- val updatedFolder = folder.copy(allowedFileTypes = newFilters)
+ val sanitizedFilters = newFilters.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }
+ val updatedFolder = folder.copy(allowedFileTypes = sanitizedFilters)
currentFolders[index] = updatedFolder
saveSyncedFoldersToPrefs(currentFolders)
_internalState.update { it.copy(syncedFolders = currentFolders) }
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
- .filter { it.type !in newFilters }
+ .filter { it.type !in sanitizedFilters }
if (filesToRemove.isNotEmpty()) {
Timber.d("Removing ${filesToRemove.size} files that no longer match the filter for folder ${folder.name}")
@@ -2836,7 +2937,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun launchPurchaseFlow(activity: android.app.Activity, productId: String = BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID) {
Timber.d("Attempting to launch purchase flow for $productId. Pro state is: ${proUpgradeState.value}")
- billingClientWrapper.launchPurchaseFlow(activity, productId)
+ val currentUser = uiState.value.currentUser
+ if (currentUser == null) {
+ _internalState.update {
+ it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.sign_in_to_purchase), isError = true))
+ }
+ return
+ }
+
+ billingClientWrapper.clearAccountConflict()
+ billingClientWrapper.launchPurchaseFlow(
+ activity = activity,
+ productId = productId,
+ obfuscatedAccountId = PurchaseAccountObfuscator.obfuscatedAccountId(currentUser.uid)
+ )
}
fun clearBillingError() {
@@ -2865,6 +2979,54 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ private fun shouldDownloadRemoteBookContent(local: RecentFileItem, remote: RecentFileItem): Boolean {
+ return local.sourceFolderUri == null &&
+ !local.isDeleted &&
+ local.type == FileType.EPUB &&
+ remote.type == FileType.EPUB &&
+ !remote.isDeleted &&
+ remote.fileContentModifiedTimestamp > 0L &&
+ remote.fileContentModifiedTimestamp > local.fileContentModifiedTimestamp
+ }
+
+ private fun shouldUploadLocalBookContent(local: RecentFileItem, remote: RecentFileItem?): Boolean {
+ return local.sourceFolderUri == null &&
+ local.type == FileType.EPUB &&
+ local.fileContentModifiedTimestamp > 0L &&
+ local.fileContentModifiedTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L)
+ }
+
+ private suspend fun downloadCloudBookFile(accessToken: String, remote: RecentFileItem): Boolean {
+ val fileExtension = remote.type.name.lowercase()
+ val fileName = "${remote.bookId}.$fileExtension"
+ val driveFileId = googleDriveRepository.getFiles(accessToken)
+ ?.files
+ .orEmpty()
+ .firstOrNull { it.name == fileName }
+ ?.id
+ ?: return false
+
+ val destinationFile = bookImporter.createBookFile(fileName)
+ if (!googleDriveRepository.downloadFile(accessToken, driveFileId, destinationFile)) {
+ destinationFile.delete()
+ return false
+ }
+
+ if (remote.fileContentModifiedTimestamp > 0L) {
+ destinationFile.setLastModified(remote.fileContentModifiedTimestamp)
+ }
+ cleanupBookDataLocally(remote.bookId)
+ addFileToRecent(
+ destinationFile.toUri(),
+ remote.type,
+ remote.bookId,
+ customDisplayName = remote.displayName,
+ isRecent = remote.isRecent,
+ sourceFolderUri = null
+ )
+ return true
+ }
+
fun setFolderSyncEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) }
_internalState.update { it.copy(isFolderSyncEnabled = enabled) }
@@ -2939,6 +3101,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val local = localBooksMap[bookId]
val remote = remoteBooksMap[bookId]
+ if (local?.sourceFolderUri != null) {
+ Timber.d("Skipping cloud book metadata merge for local folder book: ${local.displayName}")
+ return@forEach
+ }
+
if (local != null && remote != null) {
Timber.tag("AnnotationSync").d(
"Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}"
@@ -2947,7 +3114,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when {
local != null && remote == null -> {
- uploadSingleBookMetadata(local)
+ if (local.isDeleted) {
+ uploadSingleBookMetadata(local)
+ } else {
+ uploadNewBookAndMetadata(local)
+ }
}
local == null && remote != null -> {
@@ -2958,15 +3129,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
local != null && remote != null -> {
+ val remoteItem = remote.toRecentFileItem()
+ val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remoteItem)
+ val downloadedRemoteContent = if (shouldDownloadContent) {
+ downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { local.displayName }))
+ } else {
+ false
+ }
+
if (local.lastModifiedTimestamp > remote.lastModifiedTimestamp) {
- uploadSingleBookMetadata(local)
+ if (shouldUploadLocalBookContent(local, remoteItem)) {
+ uploadNewBookAndMetadata(local)
+ } else {
+ uploadSingleBookMetadata(local)
+ }
} else {
val isMetadataNewer =
remote.lastModifiedTimestamp > local.lastModifiedTimestamp
if (isMetadataNewer) {
- recentFilesRepository.addRecentFile(
+ val remoteForLocalDb = if (shouldDownloadContent && !downloadedRemoteContent) {
+ remote.toRecentFileItem().copy(
+ fileContentModifiedTimestamp = local.fileContentModifiedTimestamp
+ )
+ } else {
remote.toRecentFileItem()
+ }
+ recentFilesRepository.addRecentFile(
+ remoteForLocalDb
)
}
@@ -3080,6 +3270,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val downloadJobs = mutableListOf()
finalMergedBooks.forEach { book ->
+ if (book.sourceFolderUri != null) return@forEach
val fileExtension = book.type.name.lowercase()
val fileName = "${book.bookId}.$fileExtension"
if (book.isDeleted) {
@@ -3088,7 +3279,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
googleDriveRepository.deleteDriveFile(accessToken, fileId)
}
recentFilesRepository.deleteFilePermanently(listOf(book.bookId))
- } else if (book.isAvailable && !remoteFiles.containsKey(fileName)) {
+ } else if (
+ book.sourceFolderUri == null &&
+ book.isAvailable &&
+ !remoteFiles.containsKey(fileName)
+ ) {
book.getUri()?.path?.let { path ->
val file = File(path)
if (file.exists()) {
@@ -3139,7 +3334,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
try {
val jsonString = tempDownloadFile.readText()
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}"
)
@@ -3175,7 +3370,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
)
@@ -3185,13 +3380,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val content = bundle.get(key).toString()
file.writeText(content)
if (key == "text") {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
)
}
} else {
if (key == "text" && file.exists()) {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}"
)
}
@@ -3255,6 +3450,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
0L
}
}
+ val fileContentModifiedTimestamp = withContext(Dispatchers.IO) {
+ try {
+ if (uri.scheme == "file") {
+ uri.path?.let { File(it).lastModified() } ?: 0L
+ } else {
+ DocumentFile.fromSingleUri(appContext, uri)?.lastModified() ?: 0L
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to get file modified time for $uri")
+ 0L
+ }
+ }
val existingItem = recentFilesRepository.getFileByBookId(bookId)
val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri(
@@ -3356,7 +3563,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
seriesName = seriesName ?: finalBookMetadata.seriesName
seriesIndex = seriesIndex ?: finalBookMetadata.seriesIndex
description = description ?: finalBookMetadata.description
- } else if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
+ } else if (type in PDF_VIEWER_FILE_TYPES) {
title = title ?: displayName
if (type == FileType.PDF) {
@@ -3389,6 +3596,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
}
+ } else if (type == FileType.PPTX) {
+ if (coverPath == null) {
+ val pptxCoverGenerator = PptxCoverGenerator(appContext)
+ val coverBitmap = pptxCoverGenerator.generateCover(uri)
+ if (coverBitmap != null) {
+ coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
+ }
+ }
} else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) {
if (coverPath == null) {
var cacheFile: File? = null
@@ -3452,6 +3667,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isRecent = isRecent,
sourceFolderUri = sourceFolderUri,
fileSize = fileSize,
+ fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
description = description
@@ -3465,21 +3681,43 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun setRecentFilesLimit(limit: Int) {
- _internalState.update { it.copy(recentFilesLimit = limit) }
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.RecentLimitChanged(limit)) }
prefs.edit { putInt(KEY_RECENT_FILES_LIMIT, limit) }
}
fun setSortOrder(sortOrder: SortOrder) {
- _internalState.update { it.copy(sortOrder = sortOrder) }
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder.toSharedSortOrder())) }
prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) }
}
fun bannerMessageShown() {
_internalState.update { it.copy(bannerMessage = null) }
+ scheduleBannerAutoDismiss(null)
}
- fun showBanner(message: String, isError: Boolean = false) {
- _internalState.update { it.copy(bannerMessage = BannerMessage(message, isError)) }
+ fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
+ val banner = BannerMessage(message, isError, isPersistent)
+ _internalState.update { it.copy(bannerMessage = banner) }
+ scheduleBannerAutoDismiss(banner)
+ }
+
+ private fun scheduleBannerAutoDismiss(banner: BannerMessage?) {
+ if (_internalState.value.bannerMessage != banner) return
+ bannerDismissJob?.cancel()
+ bannerDismissJob = null
+ val generation = ++bannerDismissGeneration
+ if (banner == null || banner.isPersistent) return
+
+ bannerDismissJob = viewModelScope.launch {
+ delay(BANNER_AUTO_DISMISS_MILLIS)
+ _internalState.update { state ->
+ if (generation == bannerDismissGeneration && state.bannerMessage == banner) {
+ state.copy(bannerMessage = null)
+ } else {
+ state
+ }
+ }
+ }
}
fun errorMessageShown() {
@@ -3584,6 +3822,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
var importedCount = 0
+ var duplicateCount = 0
+ var unsupportedCount = 0
+ var failedCount = 0
withContext(Dispatchers.IO) {
for (externalUri in uris) {
@@ -3607,22 +3848,39 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
appContext.contentResolver.openInputStream(externalUri)
}
if (hash != null && recentFilesRepository.getFileByBookId(hash) != null) {
- importedCount++
+ duplicateCount++
+ } else if (getFileTypeFromUri(externalUri, appContext) == null) {
+ unsupportedCount++
+ } else {
+ failedCount++
}
}
}
}
_internalState.update {
+ val feedback = SharedImportPlanner.feedbackForCounts(
+ counts = SharedImportOutcomeCounts(
+ addedCount = importedCount,
+ duplicateCount = duplicateCount,
+ unsupportedCount = unsupportedCount,
+ failedCount = failedCount
+ ),
+ importedMessage = "Imported $importedCount books. You can find them in the Library tab.",
+ duplicateMessage = "Those files are already in the library.",
+ unsupportedMessage = appContext.getString(R.string.error_unsupported_file_type),
+ failedMessage = appContext.getString(R.string.error_import_file_failed)
+ )
it.copy(
bannerMessage = BannerMessage(
- message = "Imported $importedCount books. You can find them in the Library tab.",
+ message = feedback.message,
+ isError = feedback.isError,
isPersistent = false
)
)
}
- Timber.tag("BulkImport").i("Bulk import complete. $importedCount files processed.")
+ Timber.tag("BulkImport").i("Bulk import complete. $importedCount new files, $duplicateCount duplicates, $unsupportedCount unsupported, $failedCount failed.")
}
}
@@ -3675,8 +3933,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return@launch
}
}
+ val messageRes = if (getFileTypeFromUri(externalUri, appContext) == null) {
+ R.string.error_unsupported_file_type
+ } else {
+ R.string.error_import_file_failed
+ }
_internalState.update {
- it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed))
+ it.copy(isLoading = false, errorMessage = appContext.getString(messageRes))
}
}
} catch (e: SecurityException) {
@@ -3711,11 +3974,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- val reflowWorkInfo: Flow =
- WorkManager.getInstance(appContext).getWorkInfosByTagFlow(ReflowWorker.WORK_NAME)
- .map { list ->
- list.find { !it.state.isFinished } ?: list.firstOrNull()
- }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
+ private val _reflowWorkInfo = MutableStateFlow(null)
+ val reflowWorkInfo: StateFlow = _reflowWorkInfo.asStateFlow()
fun switchToFileSeamlessly(item: RecentFileItem, syncPosition: Int) {
viewModelScope.launch {
@@ -3741,7 +4001,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val type = item.type
val bookId = item.bookId
- if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
+ if (type in PDF_VIEWER_FILE_TYPES) {
persistReaderSession(bookId, type)
_internalState.update {
it.copy(
@@ -3771,7 +4031,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_navigationEvent.send(NavigationEvent("pdf_viewer", bookId, uri))
stateUpdateDeferred.complete(true)
- } else {
+ } else if (type in EPUB_READER_FILE_TYPES) {
persistReaderSession(bookId, type)
try {
val epubBook = restoreEpubReaderBook(type, bookId, item.displayName, uri)
@@ -3818,6 +4078,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
stateUpdateDeferred.complete(false)
}
+ } else {
+ _internalState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = appContext.getString(R.string.error_unsupported_file_type),
+ selectedFileType = null
+ )
+ }
+ stateUpdateDeferred.complete(false)
}
}
}
@@ -3857,11 +4126,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"reflow_$pdfBookId", ExistingWorkPolicy.KEEP, request
)
+ val finalWorkInfo = CompletableDeferred()
+
+ launch {
+ workManager.getWorkInfoByIdFlow(request.id).filterNotNull().first { workInfo ->
+ _reflowWorkInfo.value = workInfo
+ if (workInfo.state.isFinished) {
+ finalWorkInfo.complete(workInfo)
+ workManager.pruneWork()
+ }
+ workInfo.state.isFinished
+ }
+ }
+
if (autoOpenPage != null) {
launch {
importMutex.withLock {
- val finalInfo = workManager.getWorkInfoByIdFlow(request.id).filterNotNull()
- .first { it.state.isFinished }
+ val finalInfo = finalWorkInfo.await()
if (finalInfo.state == WorkInfo.State.SUCCEEDED) {
var retries = 0
@@ -3900,6 +4181,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ private fun epubSourceFingerprint(uri: Uri): String? {
+ return try {
+ if (uri.scheme == "file") {
+ val path = uri.path ?: return null
+ val file = File(path)
+ if (!file.isFile) return null
+ "${file.length()}:${file.lastModified()}"
+ } else {
+ val document = DocumentFile.fromSingleUri(appContext, uri) ?: return null
+ val length = document.length()
+ val modified = document.lastModified()
+ if (length <= 0L && modified <= 0L) null else "$length:$modified"
+ }
+ } catch (e: Exception) {
+ Timber.w(e, "Failed to compute EPUB source fingerprint for $uri")
+ null
+ }
+ }
+
private fun openBook(
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null
) {
@@ -3908,22 +4208,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
- if (_internalState.value.isTabsEnabled && type == FileType.PDF) {
- val currentTabs = _internalState.value.openTabIds.toMutableList()
- if (!currentTabs.contains(bookId)) {
- if (currentTabs.size >= 20) {
+ val currentTabState = _internalState.value
+ if (currentTabState.isTabsEnabled && type == FileType.PDF) {
+ if (bookId !in currentTabState.openTabIds) {
+ if (currentTabState.openTabIds.size >= 20) {
viewModelScope.launch(Dispatchers.Main) {
showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true)
}
return
}
- currentTabs.add(bookId)
}
- prefs.edit {
- putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString())
- putString(KEY_ACTIVE_TAB, bookId)
+ val tabState = AndroidSharedStateBridge.openBookTab(
+ current = currentTabState,
+ projectedState = uiState.value,
+ bookId = bookId
+ )
+ persistTabState(tabState.openTabIds, tabState.activeTabBookId)
+ _internalState.update {
+ it.copy(
+ isTabsEnabled = tabState.isTabsEnabled,
+ openTabIds = tabState.openTabIds,
+ activeTabBookId = tabState.activeTabBookId
+ )
}
- _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) }
}
if (uri.scheme != "opds-pse") {
@@ -3968,7 +4275,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
- if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
+ if (type in PDF_VIEWER_FILE_TYPES) {
viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId)
@@ -4056,6 +4363,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
}
+ } else {
+ _internalState.update {
+ it.copy(
+ selectedPdfUri = null,
+ selectedEpubUri = null,
+ selectedEpubBook = null,
+ selectedFileType = null,
+ selectedBookId = null,
+ isLoading = false,
+ errorMessage = appContext.getString(R.string.error_unsupported_file_type)
+ )
+ }
}
}
}
@@ -4143,6 +4462,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
viewModelScope.launch {
+ if (type !in EPUB_READER_FILE_TYPES) {
+ _internalState.update {
+ it.copy(
+ errorMessage = appContext.getString(R.string.error_unsupported_file_type),
+ isLoading = false
+ )
+ }
+ return@launch
+ }
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
}
@@ -4212,40 +4540,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName")
- return when (mimeType) {
- "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
- "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
- if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ 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)
- }
- }
+ return resolveFileTypeFromMetadata(fileName, mimeType)
}
private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
@@ -4318,7 +4613,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
epubParser.createEpubBook(
inputStream = inputStream,
bookId = bookId,
- originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.epub"
+ originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.epub",
+ sourceFingerprint = epubSourceFingerprint(uri)
)
}
}
@@ -4499,13 +4795,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val currentSelection = _internalState.value.contextualActionItems
if (currentSelection.isNotEmpty()) {
Timber.d("Toggling selection for: ${item.displayName}")
- val newSelection = if (currentSelection.any { it.bookId == item.bookId }) {
- currentSelection.filterNot { it.bookId == item.bookId }.toSet()
- } else {
- currentSelection + item
- }
- _internalState.update { it.copy(contextualActionItems = newSelection) }
- Timber.d("New selection size: ${newSelection.size}")
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.BookSelectionToggled(item.bookId)) }
+ Timber.d("New selection size: ${_internalState.value.contextualActionItems.size}")
} else {
if (item.sourceFolderUri != null && item.uriString != null) {
viewModelScope.launch {
@@ -4555,6 +4846,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun uploadNewBookAndMetadata(book: RecentFileItem) {
if (!uiState.value.isSyncEnabled) return
+ if (book.uriString?.startsWith("opds-pse") == true) {
+ Timber.d("Skipping book content sync for OPDS stream book: ${book.displayName}")
+ return
+ }
+
+ if (book.sourceFolderUri != null) {
+ Timber.d("Skipping book content sync for local folder book: ${book.displayName}")
+ return
+ }
+
+ if (book.isManualOnlyReaderFile()) {
+ Timber.d("Skipping book content sync for manual-only reader file: ${book.displayName}")
+ return
+ }
+
viewModelScope.launch {
_internalState.update { it.copy(uploadingBookIds = it.uploadingBookIds + book.bookId) }
try {
@@ -4598,37 +4904,39 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"Long press on: ${item.displayName}. Current selection size: ${currentSelection.size}"
)
if (currentSelection.none { it.bookId == item.bookId }) {
- _internalState.update { it.copy(contextualActionItems = currentSelection + item) }
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.BookSelectionToggled(item.bookId)) }
}
Timber.d("New selection size: ${_internalState.value.contextualActionItems.size}")
}
fun selectAllRecentFiles() {
- val currentVisible = uiState.value.recentFiles.filter { it.isRecent }.toSet()
+ val projectedState = uiState.value
+ val currentVisible = projectedState.recentFiles.filter { it.isRecent }
_internalState.update { state ->
- if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) {
- state.copy(contextualActionItems = emptySet())
- } else {
- state.copy(contextualActionItems = currentVisible)
- }
+ AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
+ current = state,
+ projectedState = projectedState,
+ visibleBooks = currentVisible
+ )
}
}
fun selectAllLibraryFiles() {
- val currentVisible = uiState.value.allRecentFiles.toSet()
+ val projectedState = uiState.value
+ val currentVisible = projectedState.allRecentFiles
_internalState.update { state ->
- if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) {
- state.copy(contextualActionItems = emptySet())
- } else {
- state.copy(contextualActionItems = currentVisible)
- }
+ AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
+ current = state,
+ projectedState = projectedState,
+ visibleBooks = currentVisible
+ )
}
}
fun clearContextualAction() {
Timber.d("Clearing contextual action mode.")
if (_internalState.value.contextualActionItems.isNotEmpty()) {
- _internalState.update { it.copy(contextualActionItems = emptySet()) }
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SelectionCleared) }
}
}
@@ -4784,30 +5092,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun toggleShelfSelection(shelf: Shelf) {
if (shelf.type != ShelfType.MANUAL) return
- _internalState.update { state ->
- val currentSelection = state.contextualActionShelfIds
- val newSelection = if (shelf.id in currentSelection) {
- currentSelection - shelf.id
- } else {
- currentSelection + shelf.id
- }
- state.copy(contextualActionShelfIds = newSelection)
- }
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.ShelfSelectionToggled(shelf.id)) }
}
fun onShelfLongPress(shelf: Shelf) {
if (shelf.type != ShelfType.MANUAL || shelf.id == "unshelved") return
val currentSelection = _internalState.value.contextualActionShelfIds
if (shelf.id !in currentSelection) {
- _internalState.update {
- it.copy(contextualActionShelfIds = currentSelection + shelf.id)
- }
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.ShelfSelectionToggled(shelf.id)) }
}
}
fun clearShelfContextualAction() {
if (_internalState.value.contextualActionShelfIds.isNotEmpty()) {
- _internalState.update { it.copy(contextualActionShelfIds = emptySet()) }
+ _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.ShelfSelectionCleared) }
}
}
@@ -5087,7 +5385,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
speechBubbleDetector?.close()
speechBubbleDetector = null
speechBubbleCache.clear()
+ speechBubbleDetectionJobs.values.forEach { it.cancel() }
speechBubbleDetectionJobs.clear()
+ mlDispatcher.close()
ttsController.release()
@@ -5206,20 +5506,108 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (updatedItem.sourceFolderUri != null) {
launch(Dispatchers.IO) {
- recentFilesRepository.syncLocalMetadataToFolder(bookId)
+ recentFilesRepository.syncLocalMetadataToFolder(bookId, force = true)
}
}
}
}
}
+ fun updateBookMetadata(bookId: String, metadata: BookMetadataEdit) {
+ viewModelScope.launch {
+ val currentItem = recentFilesRepository.getFileByBookId(bookId) ?: return@launch
+ if (currentItem.type != FileType.EPUB) {
+ showBanner("Only EPUB files support embedded metadata editing right now.", isError = true)
+ return@launch
+ }
+
+ val editResult = epubMetadataFileEditor.writeMetadata(currentItem, metadata)
+ editResult.onFailure { error ->
+ Timber.e(error, "Failed to update EPUB metadata for $bookId")
+ showBanner("Could not update EPUB metadata.", isError = true)
+ }.onSuccess { result ->
+ cleanupBookDataLocally(bookId)
+ val savedMetadata = BookMetadataEdit(
+ title = result.metadata.title ?: metadata.title,
+ author = result.metadata.author,
+ seriesName = result.metadata.seriesName,
+ seriesIndex = result.metadata.seriesIndex,
+ description = result.metadata.description
+ )
+ recentFilesRepository.updateUserEditableMetadata(
+ bookId = bookId,
+ metadata = savedMetadata,
+ fileSize = result.fileSize,
+ fileContentModifiedTimestamp = result.fileContentModifiedTimestamp
+ )
+ val updatedItem = recentFilesRepository.getFileByBookId(bookId)
+ if (updatedItem != null && uiState.value.isSyncEnabled && updatedItem.sourceFolderUri == null) {
+ uploadNewBookAndMetadata(updatedItem)
+ }
+ showBanner("EPUB metadata updated.")
+ }
+ }
+ }
+
+ fun restoreOriginalBookMetadata(bookId: String) {
+ viewModelScope.launch {
+ val currentItem = recentFilesRepository.getFileByBookId(bookId) ?: return@launch
+ if (currentItem.type != FileType.EPUB) {
+ showBanner("Only EPUB files support embedded metadata restore right now.", isError = true)
+ return@launch
+ }
+ val originalTitle = currentItem.originalTitle ?: currentItem.title
+ if (originalTitle.isNullOrBlank() &&
+ currentItem.originalAuthor.isNullOrBlank() &&
+ currentItem.originalSeriesName.isNullOrBlank() &&
+ currentItem.originalDescription.isNullOrBlank() &&
+ currentItem.originalSeriesIndex == null
+ ) {
+ showBanner("No original EPUB metadata is available.", isError = true)
+ return@launch
+ }
+
+ val metadata = BookMetadataEdit(
+ title = originalTitle ?: currentItem.displayName.substringBeforeLast('.', currentItem.displayName),
+ author = currentItem.originalAuthor,
+ seriesName = currentItem.originalSeriesName,
+ seriesIndex = currentItem.originalSeriesIndex,
+ description = currentItem.originalDescription
+ )
+ val editResult = epubMetadataFileEditor.writeMetadata(currentItem, metadata)
+ editResult.onFailure { error ->
+ Timber.e(error, "Failed to restore EPUB metadata for $bookId")
+ showBanner("Could not restore EPUB metadata.", isError = true)
+ }.onSuccess { result ->
+ cleanupBookDataLocally(bookId)
+ recentFilesRepository.restoreOriginalMetadata(
+ bookId = bookId,
+ fileSize = result.fileSize,
+ fileContentModifiedTimestamp = result.fileContentModifiedTimestamp
+ )
+ val restoredItem = recentFilesRepository.getFileByBookId(bookId)
+ if (restoredItem != null && uiState.value.isSyncEnabled && restoredItem.sourceFolderUri == null) {
+ uploadNewBookAndMetadata(restoredItem)
+ }
+ showBanner("Original EPUB metadata restored.")
+ }
+ }
+ }
+
fun closeAllTabs() {
Timber.tag("PdfTabSync").i("ViewModel: closeAllTabs called")
- prefs.edit {
- remove(KEY_OPEN_TAB_IDS)
- remove(KEY_ACTIVE_TAB)
+ val tabState = AndroidSharedStateBridge.closeAllTabs(
+ current = _internalState.value,
+ projectedState = uiState.value
+ )
+ persistTabState(tabState.openTabIds, tabState.activeTabBookId)
+ _internalState.update {
+ it.copy(
+ isTabsEnabled = tabState.isTabsEnabled,
+ openTabIds = tabState.openTabIds,
+ activeTabBookId = tabState.activeTabBookId
+ )
}
- _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) }
clearSelectedFile()
}
@@ -5255,27 +5643,27 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun setAppThemeMode(mode: AppThemeMode) {
- _internalState.update { it.copy(appThemeMode = mode) }
+ _internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode.toSharedAppThemeMode())) }
prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) }
}
fun setAppContrastOption(option: AppContrastOption) {
- _internalState.update { it.copy(appContrastOption = option) }
+ _internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option.toSharedAppContrastOption())) }
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
}
fun setAppTextDimFactorLight(factor: Float) {
- _internalState.update { it.copy(appTextDimFactorLight = factor) }
- prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, factor) }
+ _internalState.update { it.withSharedAppAction(SharedAppAction.AppTextDimFactorLightChanged(factor)) }
+ prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, _internalState.value.appTextDimFactorLight) }
}
fun setAppTextDimFactorDark(factor: Float) {
- _internalState.update { it.copy(appTextDimFactorDark = factor) }
- prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, factor) }
+ _internalState.update { it.withSharedAppAction(SharedAppAction.AppTextDimFactorDarkChanged(factor)) }
+ prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, _internalState.value.appTextDimFactorDark) }
}
fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) {
- _internalState.update { it.copy(appSeedColor = color) }
+ _internalState.update { it.withSharedAppAction(SharedAppAction.AppSeedColorChanged(color)) }
prefs.edit {
if (color == null) {
remove(KEY_APP_SEED_COLOR)
@@ -5286,18 +5674,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun addCustomAppTheme(theme: CustomAppTheme) {
- val current = _internalState.value.customAppThemes.filter { it.id != theme.id } + theme
- _internalState.update { it.copy(customAppThemes = current) }
+ _internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme.toSharedCustomAppTheme())) }
+ val current = _internalState.value.customAppThemes
saveCustomAppThemes(current)
- setAppSeedColor(theme.seedColor)
+ prefs.edit { putInt(KEY_APP_SEED_COLOR, theme.seedColor.toArgb()) }
}
fun deleteCustomAppTheme(themeId: String) {
- val current = _internalState.value.customAppThemes.filter { it.id != themeId }
- _internalState.update { it.copy(customAppThemes = current) }
+ _internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeDeleted(themeId)) }
+ val current = _internalState.value.customAppThemes
saveCustomAppThemes(current)
- if (_internalState.value.appSeedColor != null && !current.any { it.seedColor == _internalState.value.appSeedColor }) {
- setAppSeedColor(null)
+ prefs.edit {
+ val seed = _internalState.value.appSeedColor
+ if (seed == null) {
+ remove(KEY_APP_SEED_COLOR)
+ } else {
+ putInt(KEY_APP_SEED_COLOR, seed.toArgb())
+ }
}
}
@@ -5462,7 +5855,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (cachedInsideLock != null) {
detectionJob = CompletableDeferred(cachedInsideLock)
} else {
- detectionJob = speechBubbleDetectionJobs[key] ?: viewModelScope.async {
+ detectionJob = speechBubbleDetectionJobs[key] ?: viewModelScope.async(mlDispatcher) {
val detected = runSpeechBubbleDetection(bitmap, context)
val normalized = normalizeSpeechBubbles(detected, bitmap.width, bitmap.height)
speechBubbleCache[key] = normalized
@@ -5529,6 +5922,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"application/vnd.comicbook+zip", "application/x-cbz", "application/vnd.comicbook-rar",
"application/x-cbr", "application/x-rar-compressed", "application/x-cb7",
"application/x-7z-compressed", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text", "application/x-vnd.oasis.opendocument.text-flat-xml",
"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",
diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
index c30ca88..fe5db42 100644
--- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
+++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
@@ -6,7 +6,10 @@ import android.provider.OpenableColumns
import android.util.Xml
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
+import androidx.work.WorkManager
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import io.legere.pdfiumandroid.PdfiumCore
@@ -28,7 +31,17 @@ class MetadataExtractionWorker(
const val WORK_NAME = "MetadataExtractionWorker"
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
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 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) {
@@ -45,20 +58,25 @@ class MetadataExtractionWorker(
}
try {
- val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
+ val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
+ sourceFolderUri = sourceFolderUri,
+ limit = METADATA_WORKER_BOOK_BATCH_SIZE
+ )
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()
}
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()
var processed = 0
var updated = 0
+ var coversUpdated = 0
var failed = 0
suspend fun flushUpdates() {
@@ -76,36 +94,80 @@ class MetadataExtractionWorker(
if (item.sourceFolderUri == null) return@forEach
+ var needsTextMetadata = item.type in TEXT_METADATA_TYPES && !item.folderTextMetadataParsed
+ var needsEmbeddedCover = false
+
try {
val uri = item.uriString?.toUri() ?: return@forEach
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) {
- 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.ODT -> parseZipTextMetadata(uri, "meta.xml")
FileType.FODT -> parseFlatXmlTextMetadata(uri)
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
+ FileType.PPTX -> parseZipTextMetadata(uri, "docProps/core.xml")
else -> TextMetadata()
}
val title = sanitizeTitle(metadata.title)
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 titleChanged = title != null && title != item.title
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(
item.copy(
+ coverImagePath = coverPath ?: item.coverImagePath,
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
+ description = description ?: item.description,
+ seriesName = seriesName ?: item.seriesName,
+ seriesIndex = seriesIndex ?: item.seriesIndex,
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++
}
+ if (coverChanged) coversUpdated++
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
@@ -114,29 +176,64 @@ class MetadataExtractionWorker(
processed++
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
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) {
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()
+ val nextBatchEnqueued = !isStopped &&
+ filesToProcess.size >= METADATA_WORKER_BOOK_BATCH_SIZE &&
+ recentFilesRepository.hasFolderBooksNeedingTextMetadata(sourceFolderUri)
+ if (nextBatchEnqueued) {
+ enqueueNextBatch(sourceFolderUri)
+ }
+
ReaderPerfLog.i(
- "MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
- "elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
+ "MetadataWorker finished mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed " +
+ "nextBatch=$nextBatchEnqueued elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
)
return@withContext Result.success()
} catch (e: Exception) {
- Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
+ Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
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()
+ .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 {
return try {
if (uri.scheme == "file") {
@@ -157,30 +254,6 @@ class MetadataExtractionWorker(
}
}
- private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
- val opfEntries = linkedMapOf()
- 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 {
appContext.contentResolver.openInputStream(uri)?.use { input ->
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 {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
@@ -286,8 +344,23 @@ class MetadataExtractionWorker(
?.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(
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
)
}
diff --git a/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt b/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt
deleted file mode 100644
index 116870e..0000000
--- a/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.aryan.reader
-
-import com.aryan.reader.data.RecentFileItem
-
-data class HomeScreenModel(
- val recentFiles: List,
- val openTabs: List,
- val selectedItems: Set,
- 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,
- val isContextualModeActive: Boolean,
- val selectedShelves: Set,
- val isShelfContextualModeActive: Boolean,
- val sortOrder: SortOrder,
- val shelves: List,
- val rawLibraryFiles: List,
- 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
- )
-}
diff --git a/app/src/main/java/com/aryan/reader/ProScreen.kt b/app/src/main/java/com/aryan/reader/ProScreen.kt
index c52c3ce..c92ac8b 100644
--- a/app/src/main/java/com/aryan/reader/ProScreen.kt
+++ b/app/src/main/java/com/aryan/reader/ProScreen.kt
@@ -274,7 +274,7 @@ private fun ProTierCard(
) {
val productDetails = proUpgradeState.productDetails
val billingClientReady = proUpgradeState.billingClientReady
- val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasValidPurchase
+ val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasAccountConflict
var originalFormattedPrice by remember { mutableStateOf("$9.99") }
@@ -467,23 +467,6 @@ private fun ProTierCard(
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 -> {
Button(
onClick = {
@@ -538,6 +521,17 @@ private fun ProTierCard(
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 -> {
LegalText(prefixText = stringResource(R.string.legal_by_purchasing))
}
diff --git a/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt b/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt
new file mode 100644
index 0000000..9604ae6
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/PurchaseAccountObfuscator.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt b/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt
new file mode 100644
index 0000000..348153b
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/ReaderPaginationPreferences.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt b/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt
new file mode 100644
index 0000000..ccb4f05
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/ReaderScreenOrientation.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/SettingsScreen.kt b/app/src/main/java/com/aryan/reader/SettingsScreen.kt
new file mode 100644
index 0000000..991530a
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/SettingsScreen.kt
@@ -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.toSharedCustomFontItems(): List {
+ 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)
+}
diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt
index 2498200..961b42d 100644
--- a/app/src/main/java/com/aryan/reader/SharedComposables.kt
+++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt
@@ -23,6 +23,9 @@ package com.aryan.reader
import android.content.Context
import android.content.Intent
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.rememberLauncherForActivityResult
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.height
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.size
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.ArrowForward
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.ContentCopy
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.Info
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.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
@@ -88,6 +100,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.ProvideTextStyle
@@ -101,7 +114,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
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.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.foundation.text.KeyboardOptions
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.sp
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.text.HtmlCompat
+import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem
+import com.aryan.reader.shared.ui.SharedMarkdownText
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Date
@@ -359,194 +381,493 @@ fun DeleteConfirmationDialog(
}
@Composable
-fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit, onOpenTags: () -> Unit) {
- LocalContext.current
+fun FileInfoDialog(
+ item: RecentFileItem,
+ onDismiss: () -> Unit,
+ onSaveMetadata: (BookMetadataEdit) -> Unit,
+ onSaveDisplayName: (String?) -> Unit,
+ onRestoreMetadata: () -> Unit,
+ onOpenTags: () -> Unit
+) {
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
-
- val originalName = item.title ?: item.displayName
- var editingName by remember { mutableStateOf(item.customName ?: originalName) }
- val hasCustomName = item.customName != null
+ val context = LocalContext.current
+ var isEditing by remember(item.bookId) { mutableStateOf(false) }
+ var titleInput by remember(item.bookId, item.title) { mutableStateOf(item.title.orEmpty()) }
+ 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) {
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
}
-
- val context = LocalContext.current
+ val lastModifiedDate = remember(item.lastModifiedTimestamp) {
+ 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 pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) {
- if (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())
- }
+ item.resolveDisplayPath(context, isOpdsStream)
+ }
+ val pathTextFinal = if (isOpdsStream) {
+ stringResource(R.string.source_opds)
+ } else if (pathText == "In-App Storage") {
+ stringResource(R.string.source_in_app)
+ } else {
+ 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
- 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
- }
+ Dialog(
+ onDismissRequest = {
+ if (isEditing) {
+ isEditing = false
+ } else {
+ onDismiss()
+ }
+ },
+ 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 }
+ )
}
- } 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)
- ) {
+ if (showRestoreConfirmation) {
+ AlertDialog(
+ onDismissRequest = { showRestoreConfirmation = false },
+ icon = { Icon(Icons.Default.Restore, contentDescription = null) },
+ title = { Text("Restore original metadata?") },
+ text = {
Text(
- stringResource(R.string.file_information),
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold
+ "This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
)
-
- 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) {
- stringResource(R.string.source_opds)
- } else if (pathText == "In-App Storage") {
- stringResource(R.string.source_in_app)
- } else {
- pathText.replace("Internal storage", stringResource(R.string.internal_storage))
- }
-
- InfoRowDetailed(
- label = stringResource(R.string.location),
- value = pathTextFinal,
- maxLines = 4,
- isScrollable = true,
- onCopy = {
- clipboardManager.setText(AnnotatedString(pathTextFinal))
- }
- )
- }
-
- HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
-
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.section_tags), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
- TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) }
- }
-
- if (item.tags.isNotEmpty()) {
- BookTagChipsRow(tags = item.tags, compact = false)
- } else {
- Text(stringResource(R.string.msg_no_tags_assigned), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 8.dp),
- horizontalArrangement = Arrangement.End
- ) {
- TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
- Spacer(modifier = Modifier.width(8.dp))
- androidx.compose.material3.Button(onClick = {
- val finalName = editingName.trim()
- if (finalName != (item.customName ?: originalName)) {
- if (finalName == originalName || finalName.isEmpty()) {
- onUpdateName(null)
- } else {
- onUpdateName(finalName)
- }
- }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ showRestoreConfirmation = false
+ onRestoreMetadata()
onDismiss()
- }) { Text(stringResource(R.string.action_save)) }
+ }
+ ) {
+ Text("Restore")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showRestoreConfirmation = false }) {
+ Text(stringResource(R.string.action_cancel))
}
}
+ )
+ }
+}
+
+@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
+ )
+ }
+ }
+}
+
+@Composable
+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)) }
+ }
+
+ if (item.tags.isNotEmpty()) {
+ BookTagChipsRow(tags = item.tags, compact = false)
+ } else {
+ 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(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ 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))
+ Text("Restore")
+ }
+ }
+ TextButton(onClick = onCancel) {
+ 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 {
+ Button(onClick = onEdit) {
+ Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(editLabel)
+ }
+ }
+ }
+}
+
+@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,
value: String,
maxLines: Int = 1,
- isScrollable: Boolean = false,
onCopy: (() -> Unit)? = null
) {
Row(
@@ -569,32 +889,17 @@ private fun InfoRowDetailed(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
- .width(85.dp)
+ .width(104.dp)
.padding(top = 2.dp)
)
-
- val scrollModifier = if (isScrollable) {
- 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)
- )
+ Column(modifier = Modifier.weight(1f)) {
+ ExpandableValueText(value, collapsedMaxLines = maxLines)
+ }
if (onCopy != null) {
IconButton(
onClick = onCopy,
modifier = Modifier
- .size(24.dp)
+ .size(28.dp)
.padding(start = 4.dp)
) {
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
fun CustomTopBanner(bannerMessage: BannerMessage?) {
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
) {
Text(
- text = type.name.uppercase(),
+ text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
fontWeight = FontWeight.ExtraBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
index 4ed229f..abc0c11 100644
--- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
+++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
@@ -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.ReadStatusFilter as SharedReadStatusFilter
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.Shelf as SharedShelf
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.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag
@@ -34,18 +35,88 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
coverImagePath = coverImagePath,
title = title,
author = author,
+ description = description,
+ originalTitle = originalTitle,
+ originalAuthor = originalAuthor,
+ originalSeriesName = originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex,
+ originalDescription = originalDescription,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
+ fileContentModifiedTimestamp = fileContentModifiedTimestamp,
sourceFolder = sourceFolderUri,
folderTextMetadataParsed = folderTextMetadataParsed,
seriesName = seriesName,
seriesIndex = seriesIndex,
+ lastPageIndex = lastPage,
tags = tags.map { it.toSharedTag() },
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
)
}
+fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
+ return toSharedBookItem().copy(displayName = displayName)
+}
+
+fun SharedBookItem.toRecentFileItem(
+ androidBooksById: Map = emptyMap(),
+ tagEntitiesById: Map = 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 {
return SharedTag(
id = id,
@@ -153,92 +224,147 @@ fun ReaderScreenState.toSharedReaderScreenState(
)
}
-fun ReaderScreenState.toSharedLibraryProjectionInput(
- recentFilesFromDb: List,
- dbShelves: List,
- shelfRefs: List,
+fun List.withResolvedTags(
dbTags: List,
tagRefs: List
-): SharedLibraryProjectionInput {
+): List {
val tagsById = dbTags.associateBy { it.id }
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
- val taggedBooks = recentFilesFromDb.map { item ->
- item.copy(tags = bookTagsMap[item.bookId].orEmpty())
+ return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
+}
+
+fun SharedReaderScreenState.toAndroidReaderScreenState(
+ base: ReaderScreenState,
+ androidBooksById: Map,
+ tagEntitiesById: Map = emptyMap()
+): ReaderScreenState {
+ val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
+ fun SharedBookItem.toAndroidBook(): RecentFileItem {
+ return toRecentFileItem(androidBooksById, tagEntitiesById)
}
- return SharedLibraryProjectionInput(
- state = toSharedReaderScreenState(
- rawBooks = taggedBooks,
- dbTags = dbTags
- ),
- booksFromStore = taggedBooks
- .filterNot { it.bookId.endsWith("_reflow") }
- .map { it.toSharedBookItem() },
- shelfRecords = dbShelves.map { it.toSharedShelfRecord() },
- shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() },
- tags = dbTags.map { it.toSharedTag() }
+ fun bookById(bookId: String): RecentFileItem? {
+ return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
+ }
+ return base.copy(
+ recentFiles = recentBooks.map { it.toAndroidBook() },
+ allRecentFiles = libraryBooks.map { it.toAndroidBook() },
+ rawLibraryFiles = rawLibraryBooks.map { it.toAndroidBook() },
+ viewingShelfId = viewingShelfId,
+ isAddingBooksToShelf = isAddingBooksToShelf,
+ contextualActionShelfIds = selectedShelfIds,
+ 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 = emptyMap(),
+ tagEntitiesById: Map = 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 {
- return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN)
+ return this
}
-private fun RenderMode.toSharedRenderMode(): SharedRenderMode {
- return SharedRenderMode.valueOf(name)
+fun SharedFileType.toAndroidFileType(): FileType {
+ return this
}
-private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
- return SharedAddBooksSource.valueOf(name)
+fun RenderMode.toSharedRenderMode(): SharedRenderMode {
+ return this
}
-private fun SortOrder.toSharedSortOrder(): SharedSortOrder {
- return SharedSortOrder.valueOf(name)
+fun SharedRenderMode.toAndroidRenderMode(): RenderMode {
+ return this
}
-private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
- return SharedReadStatusFilter.valueOf(name)
+fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
+ return this
}
-private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
- return SharedLibraryFilters(
- fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() },
- sourceFolders = sourceFolders,
- readStatus = readStatus.toSharedReadStatusFilter(),
- tagIds = tagIds
- )
+fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource {
+ return this
}
-private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
- return SharedSyncedFolder(
- uriString = uriString,
- name = name,
- lastScanTime = lastScanTime,
- allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }
- )
+fun SortOrder.toSharedSortOrder(): SharedSortOrder {
+ return this
}
-private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
- return SharedBannerMessage(
- message = message,
- isError = isError,
- isPersistent = isPersistent
- )
+fun SharedSortOrder.toAndroidSortOrder(): SortOrder {
+ return this
}
-private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
- return SharedAppThemeMode.valueOf(name)
+fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
+ return this
}
-private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
- return SharedAppContrastOption.valueOf(name)
+fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter {
+ return this
}
-private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
- return SharedCustomAppTheme(
- id = id,
- name = name,
- seedColor = seedColor
- )
+fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
+ return this
+}
+
+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
}
diff --git a/app/src/main/java/com/aryan/reader/ThemedBookCover.kt b/app/src/main/java/com/aryan/reader/ThemedBookCover.kt
new file mode 100644
index 0000000..1cfbfcc
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/ThemedBookCover.kt
@@ -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) }
+}
diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt
index 3f608b9..15a2799 100644
--- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt
+++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt
@@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class,
BookTagCrossRef::class
],
- version = 19,
+ version = 22,
exportSchema = false
)
@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 {
return INSTANCE ?: synchronized(this) {
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_9_10, MIGRATION_10_11, MIGRATION_11_12,
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)
.build()
diff --git a/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt b/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt
new file mode 100644
index 0000000..3e403fe
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/data/BookMetadataEdit.kt
@@ -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?
+)
diff --git a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
index 81b3d19..260890c 100644
--- a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
+++ b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
@@ -20,13 +20,19 @@ data class FolderBookMetadata(
val locatorBlockIndex: Int?,
val locatorCharOffset: Int?,
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 {
val json = JSONObject()
json.put("bookId", bookId)
- json.put("title", title)
- json.put("author", author)
json.put("displayName", displayName)
json.put("type", type)
json.put("lastChapterIndex", lastChapterIndex ?: -1)
@@ -58,8 +64,8 @@ data class FolderBookMetadata(
return FolderBookMetadata(
bookId = json.getString("bookId"),
- title = json.optStringNull("title"),
- author = json.optStringNull("author"),
+ title = null,
+ author = null,
displayName = json.optString("displayName", "Unknown"),
type = json.optString("type", "PDF"),
lastChapterIndex = json.optIntNull("lastChapterIndex"),
@@ -72,7 +78,15 @@ data class FolderBookMetadata(
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
locatorCharOffset = json.optIntNull("locatorCharOffset"),
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,
timestamp = System.currentTimeMillis(),
coverImagePath = coverPath,
- title = this.title,
- author = this.author,
+ title = this.displayName.substringBeforeLast('.', this.displayName),
+ author = null,
lastChapterIndex = this.lastChapterIndex,
lastPage = this.lastPage,
lastPositionCfi = this.lastPositionCfi,
@@ -101,6 +115,14 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
bookmarksJson = this.bookmarksJson,
sourceFolderUri = sourceFolderUri,
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
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
index 559a902..889d5a1 100644
--- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
+++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
@@ -7,6 +7,12 @@ import android.os.Environment
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
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.withContext
import org.json.JSONObject
@@ -22,6 +28,12 @@ object LocalSyncUtils {
val uri: Uri
)
+ private data class ParsedAnnotationSidecar(
+ val bookId: String,
+ val timestamp: Long,
+ val data: String
+ )
+
private fun syncSubfolderDocId(rootDocId: String): String {
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
rootDocId
@@ -182,15 +194,14 @@ object LocalSyncUtils {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: 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)
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
return@withContext
}
- val tempFileName = ".${metadata.bookId}.tmp"
- syncDir.findFile(tempFileName)?.delete()
+ val tempFileName = uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId))
val tempFile = syncDir.createFile("application/json", tempFileName)
if (tempFile == null) {
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 syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
- val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
- val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
- syncDir.findFile(tempName)?.delete()
- val tempFile = syncDir.createFile("application/json", tempName)
+ val targetName = localFolderSyncAnnotationFileName(bookId)
+ val tempName = uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId))
if (currentBest != null) {
val (remoteTs, _) = currentBest
@@ -273,10 +282,12 @@ object LocalSyncUtils {
val wrapper = JSONObject()
wrapper.put("version", 1)
+ wrapper.put("bookId", bookId)
wrapper.put("timestamp", timestamp)
wrapper.put("data", JSONObject(jsonPayload))
val contentBytes = wrapper.toString().toByteArray()
+ val tempFile = syncDir.createFile("application/json", tempName)
if (tempFile == null) {
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
return@withContext
@@ -325,17 +336,21 @@ object LocalSyncUtils {
val results = mutableMapOf>()
try {
- val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri)
- .filter { file ->
- val name = file.name
- extractAnnotationBookId(name) != null &&
- !name.contains(".syncthing.")
+ val parsedSidecars = querySyncSubfolderFiles(context, sourceFolderUri)
+ .asSequence()
+ .filter { isAnnotationSidecarCandidateName(it.name) }
+ .mapNotNull { file ->
+ parseAnnotationSidecar(
+ context = context,
+ file = file,
+ fallbackBookId = extractLegacyAnnotationBookId(file.name)
+ )
}
- .groupBy { file -> extractAnnotationBookId(file.name).orEmpty() }
+ .groupBy { it.bookId }
- for ((bookId, files) in groupedFiles) {
- val best = resolveAnnotationConflictsReadOnly(context, bookId, files)
- if (best != null) results[bookId] = best
+ for ((bookId, sidecars) in parsedSidecars) {
+ val best = sidecars.maxByOrNull { it.timestamp }
+ if (best != null) results[bookId] = best.timestamp to best.data
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
@@ -344,44 +359,6 @@ object LocalSyncUtils {
return@withContext results
}
- private fun resolveAnnotationConflictsReadOnly(
- context: Context,
- bookId: String,
- files: List
- ): Pair? {
- 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(
context: Context,
sourceFolderUri: Uri,
@@ -405,17 +382,16 @@ object LocalSyncUtils {
bookId: String,
knownFiles: List? = null
): Pair? {
- val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
- val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
-
val allFiles = knownFiles ?: syncDir.listFiles().asList()
- val candidates = allFiles.filter { file ->
- val name = file.name ?: ""
- (name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
- name.endsWith(".json") &&
- !name.endsWith(".tmp") &&
- !name.contains(".syncthing.")
+ val candidates = allFiles.mapNotNull { file ->
+ val name = file.name ?: return@mapNotNull null
+ if (!isAnnotationSidecarCandidateName(name)) return@mapNotNull null
+ parseAnnotationSidecar(
+ context = context,
+ file = SyncFileEntry(name = name, uri = file.uri),
+ fallbackBookId = extractLegacyAnnotationBookId(name)
+ )?.takeIf { it.bookId == bookId }?.let { file to it }
}
if (candidates.isEmpty()) return null
@@ -425,31 +401,16 @@ object LocalSyncUtils {
var bestFile: DocumentFile? = null
val filesToDelete = mutableListOf()
- for (file in candidates) {
- 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) {
- if (ts > bestTs) {
- if (bestFile != null) filesToDelete.add(bestFile)
-
- bestTs = ts
- bestData = data
- bestFile = file
- } else {
- filesToDelete.add(file)
- }
- } else {
- filesToDelete.add(file)
+ for ((file, sidecar) in candidates) {
+ if (sidecar.timestamp > bestTs) {
+ if (bestFile != null) {
+ filesToDelete.add(bestFile)
}
- } catch (e: Exception) {
- Timber.tag("FolderAnnotationSync").e(e, "Error parsing candidate file: ${file.name}")
+ bestTs = sidecar.timestamp
+ bestData = sidecar.data
+ bestFile = file
+ } else {
+ filesToDelete.add(file)
}
}
@@ -464,7 +425,7 @@ object LocalSyncUtils {
}
if (bestFile != null) {
- val correctName = "${basePattern}.json"
+ val correctName = localFolderSyncAnnotationFileName(bookId)
if (bestFile.name != correctName) {
Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName")
val existingTarget = syncDir.findFile(correctName)
@@ -548,7 +509,7 @@ object LocalSyncUtils {
}
}
- val correctName = ".${bookId}.json"
+ val correctName = localFolderSyncMetadataFileName(bookId)
if (bestFile.name != correctName) {
Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
bestFile.renameTo(correctName)
@@ -563,21 +524,24 @@ object LocalSyncUtils {
syncDir: DocumentFile,
bookId: String
): FolderBookMetadata? {
+ val hashedStem = localFolderSyncSidecarStem(bookId)
val candidates = syncDir.listFiles().filter { file ->
val name = file.name ?: ""
- val normalizedName = if (name.startsWith(".")) name.substring(1) else name
- normalizedName == "$bookId.json" ||
- normalizedName.startsWith("$bookId.sync-conflict") ||
- normalizedName.startsWith("$bookId.json.sync-conflict")
+ val normalizedName = name.normalizedSidecarName()
+ isMetadataSidecarCandidateName(name) &&
+ (
+ normalizedName.matchesJsonSidecarStem(hashedStem) ||
+ normalizedName.matchesJsonSidecarStem(bookId)
+ )
}
if (candidates.isEmpty()) return null
return resolveAndCleanConflicts(context, candidates, bookId)
}
- private fun extractAnnotationBookId(name: String?): String? {
+ private fun extractLegacyAnnotationBookId(name: String?): String? {
if (name.isNullOrBlank()) return null
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")) {
temp = temp.substringBefore(".sync-conflict")
}
@@ -588,9 +552,63 @@ object LocalSyncUtils {
if (temp.startsWith(".")) {
temp = temp.substring(1)
}
+ if (temp.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return 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(
context: Context,
sourceFolderUri: Uri,
@@ -599,13 +617,15 @@ object LocalSyncUtils {
try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: 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 name = file.name ?: return@filter false
- val normalized = if (name.startsWith(".")) name.substring(1) else name
- normalized == "$bookId.json" ||
- normalized.startsWith("$bookId.sync-conflict") ||
- normalized.startsWith("$bookId.json.sync-conflict") ||
- normalized.startsWith("$bookId${ANNOTATION_SUFFIX}")
+ val normalized = name.normalizedSidecarName()
+ normalized.matchesJsonSidecarStem(hashedStem) ||
+ normalized.matchesJsonSidecarStem(bookId) ||
+ normalized.matchesJsonSidecarStem(hashedAnnotationStem) ||
+ normalized.matchesJsonSidecarStem("$bookId$ANNOTATION_SUFFIX")
}
targets.forEach {
try {
@@ -626,34 +646,32 @@ object LocalSyncUtils {
try {
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
- val groupedFiles = allFiles
- .filter {
- val name = it.name
- (name.endsWith(".json") || name.contains(".sync-conflict")) &&
- !name.contains(ANNOTATION_SUFFIX) &&
- !name.endsWith(".tmp") &&
- !name.contains(".syncthing.")
- }
- .groupBy { file ->
- var name = file.name
- if (name.startsWith(".")) name = name.substring(1)
- if (name.contains(".sync-conflict")) {
- name.substringBefore(".sync-conflict")
- } else {
- name.substringBefore(".json")
+ val groupedMetadata = allFiles
+ .asSequence()
+ .filter { isMetadataSidecarCandidateName(it.name) }
+ .mapNotNull { file ->
+ try {
+ val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
+ input.bufferedReader().use { it.readText() }
+ }
+ jsonString?.let(FolderBookMetadata::fromJsonString)
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
+ null
}
}
+ .groupBy { it.bookId }
- groupedFiles.forEach { (bookId, files) ->
- val winner = resolveMetadataConflictsReadOnly(context, files, bookId)
+ groupedMetadata.forEach { (bookId, metadataRecords) ->
+ val winner = metadataRecords.maxByOrNull { it.lastModifiedTimestamp }
if (winner != null) {
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(
- "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) {
@@ -663,29 +681,4 @@ object LocalSyncUtils {
return@withContext finalResults
}
- private fun resolveMetadataConflictsReadOnly(
- context: Context,
- files: List,
- 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
- }
}
diff --git a/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt b/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
index 607bb45..a3e41b6 100644
--- a/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
+++ b/app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
@@ -28,7 +28,8 @@ data class PurchaseEntity(
val purchaseToken: String,
val purchaseTime: Long,
val isAcknowledged: Boolean,
- val isAutoRenewing: Boolean
+ val isAutoRenewing: Boolean,
+ val obfuscatedAccountId: String? = null
)
/**
@@ -41,4 +42,4 @@ data class ProductDetailsEntity(
val formattedPrice: String,
val currencyCode: String,
val priceAmountMicros: Long
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
index 1db6c5a..08cc5fb 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
@@ -33,7 +33,7 @@ interface RecentFileDao {
@Upsert
suspend fun insertOrUpdateFiles(files: List)
- @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
>
@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")
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
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
@@ -94,26 +94,36 @@ interface RecentFileDao {
SELECT * FROM recent_files
WHERE sourceFolderUri IS NOT NULL
AND isDeleted = 0
- AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
- AND folderTextMetadataParsed = 0
+ AND (
+ (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
+ suspend fun getFolderBooksNeedingTextMetadata(limit: Int): List
@Query("""
SELECT * FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0
- AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
- AND folderTextMetadataParsed = 0
+ AND (
+ (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
+ suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String, limit: Int): List
@Query("""
SELECT COUNT(*) FROM recent_files
WHERE sourceFolderUri IS NOT NULL
AND isDeleted = 0
- AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
- AND folderTextMetadataParsed = 0
+ AND (
+ (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
@@ -121,8 +131,10 @@ interface RecentFileDao {
SELECT COUNT(*) FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0
- AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
- AND folderTextMetadataParsed = 0
+ AND (
+ (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
@@ -130,10 +142,60 @@ interface RecentFileDao {
UPDATE recent_files
SET
coverImagePath = COALESCE(:coverImagePath, coverImagePath),
- title = COALESCE(:title, title),
- author = COALESCE(:author, author),
+ title = CASE
+ 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,
- 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
""")
suspend fun updateExtractedMetadata(
@@ -141,7 +203,13 @@ interface RecentFileDao {
coverImagePath: String?,
title: 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")
@@ -149,4 +217,58 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
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
+ )
}
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
index 4dbbb0b..26ace97 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
@@ -52,10 +52,17 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "NULL") val customName: String?,
@ColumnInfo(defaultValue = "NULL") val highlights: String?,
@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 seriesIndex: Double?,
@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(
@@ -81,7 +88,13 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@ColumnInfo(defaultValue = "NULL") val customName: String?,
@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 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
)
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
index 4eb094a..6276f79 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
@@ -46,10 +46,17 @@ data class RecentFileItem(
val customName: String? = null,
val highlightsJson: String? = null,
val fileSize: Long = 0L,
+ val fileContentModifiedTimestamp: Long = 0L,
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,
val folderTextMetadataParsed: Boolean = false,
+ val folderCoverMetadataParsed: Boolean = false,
val tags: List = emptyList()
)
@@ -79,10 +86,17 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
customName = this.customName,
highlightsJson = this.highlights,
fileSize = this.fileSize,
+ fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
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,
highlights = this.highlightsJson,
fileSize = this.fileSize,
+ fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
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,
hasAnnotations = false,
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,
bookmarksJson = this.bookmarksJson,
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,
highlightsJson = null,
fileSize = this.fileSize,
+ fileContentModifiedTimestamp = this.fileContentModifiedTimestamp,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
- description = this.description
+ description = this.description,
+ originalTitle = this.originalTitle,
+ originalAuthor = this.originalAuthor,
+ originalSeriesName = this.originalSeriesName,
+ originalSeriesIndex = this.originalSeriesIndex,
+ originalDescription = this.originalDescription
)
}
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
index b18ef03..673485e 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
@@ -22,9 +22,12 @@ package com.aryan.reader.data
import android.content.Context
import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.net.toUri
+import com.aryan.reader.FileType
import com.aryan.reader.ReaderPerfLog
+import com.aryan.reader.scaledToCanvasLimit
import timber.log.Timber
import com.aryan.reader.BookImporter
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.PageLayoutRepository
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 org.json.JSONObject
import org.json.JSONArray
@@ -48,6 +50,9 @@ import java.util.UUID
import androidx.core.content.edit
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) {
@@ -169,12 +174,43 @@ class RecentFilesRepository(private val context: Context) {
}
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(
uriString = existingItem.uriString ?: item.uriString,
isAvailable = existingItem.isAvailable || item.isAvailable,
- coverImagePath = item.coverImagePath ?: existingItem.coverImagePath,
- title = item.title ?: existingItem.title,
- author = item.author ?: existingItem.author,
+ coverImagePath = if (folderFileChanged) {
+ item.coverImagePath
+ } 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,
lastPage = item.lastPage ?: existingItem.lastPage,
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
@@ -187,10 +223,43 @@ class RecentFilesRepository(private val context: Context) {
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
highlights = item.highlightsJson ?: existingItem.highlights,
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
- seriesName = item.seriesName ?: existingItem.seriesName,
- seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
- description = item.description ?: existingItem.description,
- folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
+ fileContentModifiedTimestamp = if (item.fileContentModifiedTimestamp > 0) item.fileContentModifiedTimestamp else existingItem.fileContentModifiedTimestamp,
+ seriesName = if (folderFileChanged) {
+ item.seriesName
+ } 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 {
item.toRecentFileEntity()
@@ -201,13 +270,60 @@ class RecentFilesRepository(private val context: Context) {
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) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateHighlights(bookId, highlightsJson, currentTime)
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 folderUriString = entity.sourceFolderUri
@@ -217,7 +333,7 @@ class RecentFilesRepository(private val context: Context) {
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
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.")
return@withContext
}
@@ -240,7 +356,15 @@ class RecentFilesRepository(private val context: Context) {
locatorBlockIndex = entity.locatorBlockIndex,
locatorCharOffset = entity.locatorCharOffset,
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(
@@ -275,7 +399,7 @@ class RecentFilesRepository(private val context: Context) {
val hasHighlights = highlightFile.exists()
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 " +
"richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
)
@@ -291,7 +415,7 @@ class RecentFilesRepository(private val context: Context) {
try {
val content = file.readText().trim()
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}"
)
}
@@ -302,7 +426,7 @@ class RecentFilesRepository(private val context: Context) {
}
} catch (e: Exception) {
if (key == "text") {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
+ Timber
.e(e, "android.folder.export.richTextParseFailed book=$bookId")
}
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())
if (hasRichText) {
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
)
}
@@ -348,7 +472,7 @@ class RecentFilesRepository(private val context: Context) {
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
- Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
+ Timber.d(
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
"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()
file.writeText(contentStr)
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}"
)
}
@@ -431,11 +555,15 @@ class RecentFilesRepository(private val context: Context) {
}
}
- suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List = withContext(Dispatchers.IO) {
+ suspend fun getFolderBooksNeedingTextMetadata(
+ sourceFolderUri: String? = null,
+ limit: Int = Int.MAX_VALUE
+ ): List = withContext(Dispatchers.IO) {
+ val queryLimit = limit.coerceAtLeast(1)
val entities = if (sourceFolderUri.isNullOrBlank()) {
- recentFileDao.getFolderBooksNeedingTextMetadata()
+ recentFileDao.getFolderBooksNeedingTextMetadata(queryLimit)
} else {
- recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri)
+ recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri, queryLimit)
}
return@withContext entities.map { it.toRecentFileItem() }
}
@@ -459,7 +587,13 @@ class RecentFilesRepository(private val context: Context) {
coverImagePath = item.coverImagePath,
title = item.title,
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 file = File(cacheDir, filename)
var fos: FileOutputStream? = null
+ var scaledCopy: Bitmap? = null
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)
- bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos)
+ bitmapToSave.compress(Bitmap.CompressFormat.PNG, 90, fos)
Timber.d("Saved cover image to: ${file.absolutePath}")
return@withContext file.absolutePath
} catch (e: Exception) {
@@ -573,9 +715,70 @@ class RecentFilesRepository(private val context: Context) {
return@withContext null
} finally {
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 {
val file = File(filePath)
val deleted = file.delete()
@@ -622,10 +825,11 @@ class RecentFilesRepository(private val context: Context) {
suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) {
try {
+ recentFileDao.getFileByBookId(bookId)?.coverImagePath?.let { deleteCachedCover(it) }
pdfRichTextRepository.getFileForSync(bookId).delete()
pageLayoutRepository.getLayoutFile(bookId).delete()
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) {
Timber.e(e, "Error clearing caches for $bookId")
}
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
index e8649d2..716fba2 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
@@ -35,9 +35,11 @@ import org.w3c.dom.Node
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
+import java.io.ByteArrayOutputStream
import java.net.URLDecoder
import java.nio.file.Paths
import java.util.UUID
+import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -78,7 +80,8 @@ class EpubParser(private val context: Context) {
val originalBookNameHint: String,
val parserVersion: Int,
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
@@ -109,6 +112,9 @@ class EpubParser(private val context: Context) {
private const val BOOK_METADATA_FILE = "book_metadata.json"
private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
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
@@ -185,7 +191,8 @@ class EpubParser(private val context: Context) {
shouldUseToc: Boolean = true,
originalBookNameHint: String = "streamed_book",
parseContent: Boolean = true,
- extractionDirOverride: File? = null
+ extractionDirOverride: File? = null,
+ sourceFingerprint: String? = null
): EpubBook {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId")
@@ -201,7 +208,8 @@ class EpubParser(private val context: Context) {
extractionDir = activeDir,
bookId = bookId,
originalBookNameHint = originalBookNameHint,
- shouldUseToc = shouldUseToc
+ shouldUseToc = shouldUseToc,
+ sourceFingerprint = sourceFingerprint
)?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
return@withContext cachedBook
@@ -215,7 +223,12 @@ class EpubParser(private val context: Context) {
tempFile.outputStream().use { output ->
inputStream.copyTo(output)
}
- filesMap = extractEpubContents(ZipFile(tempFile), extractionDir, parseContent)
+ filesMap = extractEpubContents(
+ zipFile = ZipFile(tempFile),
+ extractionDir = extractionDir,
+ parseContent = parseContent,
+ extractImagesForMetadata = shouldDeleteExtractionDir
+ )
} finally {
tempFile.delete()
}
@@ -229,6 +242,7 @@ class EpubParser(private val context: Context) {
bookId = bookId,
originalBookNameHint = originalBookNameHint,
shouldUseToc = shouldUseToc,
+ sourceFingerprint = sourceFingerprint,
book = book
)
}
@@ -243,7 +257,8 @@ class EpubParser(private val context: Context) {
extractionDir: File,
bookId: String,
originalBookNameHint: String,
- shouldUseToc: Boolean
+ shouldUseToc: Boolean,
+ sourceFingerprint: String?
): EpubBook? {
val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
@@ -255,7 +270,8 @@ class EpubParser(private val context: Context) {
manifest.originalBookNameHint == originalBookNameHint &&
manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
manifest.parseContent &&
- manifest.shouldUseToc == shouldUseToc
+ manifest.shouldUseToc == shouldUseToc &&
+ manifest.sourceFingerprint == sourceFingerprint
if (!isCompatible) {
Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
@@ -277,6 +293,7 @@ class EpubParser(private val context: Context) {
bookId: String,
originalBookNameHint: String,
shouldUseToc: Boolean,
+ sourceFingerprint: String?,
book: EpubBook
) {
try {
@@ -288,7 +305,8 @@ class EpubParser(private val context: Context) {
originalBookNameHint = originalBookNameHint,
parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
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 {
+ internal fun extractEpubContents(
+ zipFile: ZipFile,
+ extractionDir: File,
+ parseContent: Boolean,
+ extractImagesForMetadata: Boolean
+ ): Map {
val filesMap = mutableMapOf()
zipFile.use { zf ->
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
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 (isEssential || isImage) {
- val data = zf.getInputStream(entry).readBytes()
- filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
+ when {
+ isEssential -> {
+ val data = zf.readSmallEntryBytes(entry) ?: return@forEach
+ 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
}
- val outputFile = File(extractionDir, entry.name)
+ val outputFile = safeExtractionFile(extractionDir, entry.name) ?: return@forEach
outputFile.parentFile?.mkdirs()
zf.getInputStream(entry).use { input ->
FileOutputStream(outputFile).use { output ->
@@ -329,6 +365,59 @@ class EpubParser(private val context: Context) {
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(
filesContentMap: Map,
document: EpubDocument,
@@ -712,8 +801,6 @@ class EpubParser(private val context: Context) {
filesContentMap: Map,
@Suppress("UNUSED_PARAMETER") extractionRoot: File
): List {
- val imageExtensions = setOf(".png", ".gif", ".jpg", ".jpeg", ".webp", ".svg")
-
val listedImages = manifestItems.values
.filter { it.mediaType.startsWith("image/") }
.map { manifestItem ->
@@ -725,7 +812,7 @@ class EpubParser(private val context: Context) {
val unlistedImages = filesContentMap.keys
.filter { path ->
val lowerPath = path.lowercase()
- imageExtensions.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
+ EPUB_IMAGE_EXTENSIONS.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
}
.map { path ->
EpubImage(absPath = path)
@@ -743,11 +830,9 @@ class EpubParser(private val context: Context) {
): Bitmap? {
val coverManifestItem = manifestItems[metadataCoverId]
if (coverManifestItem != null) {
- val coverImageBytes = filesContentMap[coverManifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
- ?: File(extractionRoot, coverManifestItem.absPath).takeIf { it.exists() }?.readBytes()
-
- if (coverImageBytes != null) {
- return BitmapFactory.decodeByteArray(coverImageBytes, 0, coverImageBytes.size)
+ val coverImage = decodeEpubImage(coverManifestItem.absPath, filesContentMap, extractionRoot)
+ if (coverImage != null) {
+ return coverImage
} else {
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) {
if (filesContentMap.containsKey(path)) {
- val bytes = filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }
- ?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
-
- bytes?.let {
+ decodeEpubImage(path, filesContentMap, extractionRoot)?.let {
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 ->
- val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
- ?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
-
- bytes?.let {
+ decodeEpubImage(manifestItem.absPath, filesContentMap, extractionRoot)?.let {
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
}
+ private fun decodeEpubImage(
+ path: String,
+ filesContentMap: Map,
+ 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 {
val lowerName = fileName.lowercase()
diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
index 714749a..9ea4bb9 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
@@ -29,6 +29,7 @@ import android.content.Intent
import android.graphics.Color
import android.graphics.Rect
import android.webkit.JavascriptInterface
+import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
@@ -90,6 +91,34 @@ import java.io.BufferedReader
import java.io.InputStreamReader
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 {
return """
@@ -314,12 +343,14 @@ class FootnoteJsBridge(
@Suppress("unused")
class LinkNavJsBridge(
- private val currentChapterTitle: String
+ private val currentChapterTitle: String,
+ private val onInternalLinkClick: (String) -> Unit
) {
@JavascriptInterface
fun onLinkClicked(href: String, epubType: String, linkText: String) {
Timber.tag(TAG_LINK_NAV)
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
+ onInternalLinkClick(href)
}
}
@@ -384,6 +415,7 @@ fun ChapterWebView(
activeHighlightPalette: List,
onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit,
+ onWebViewDisposed: (WebView) -> Unit = {},
activeTextureId: String? = null,
activeTextureAlpha: Float = 0.55f
) {
@@ -554,7 +586,7 @@ fun ChapterWebView(
}, "AutoScrollBridge"
)
- webChromeClient = object : android.webkit.WebChromeClient() {
+ webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
consoleMessage?.let {
val message = it.message()
@@ -668,7 +700,9 @@ fun ChapterWebView(
)
addJavascriptInterface(
- LinkNavJsBridge(chapterTitle), "LinkNavBridge"
+ LinkNavJsBridge(chapterTitle) { href ->
+ this.post { onInternalLinkClick(href) }
+ }, "LinkNavBridge"
)
webViewClient = object : WebViewClient() {
@@ -744,6 +778,37 @@ fun ChapterWebView(
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(
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
escapeJsString(
@@ -907,10 +972,19 @@ fun ChapterWebView(
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
}
webView
- }, update = { webView ->
- Timber.d(
- "WebView update. Setting Font: ${currentFontFamily.fontFamilyName}"
- )
+ },
+ modifier = Modifier.fillMaxSize(),
+ 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
onWebViewInstanceCreated(webView)
val fontCss = getFontCssInjection().replace("\n", " ")
@@ -946,7 +1020,7 @@ fun ChapterWebView(
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
null
)
- }, modifier = Modifier.fillMaxSize()
+ }
)
}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
index 636889c..22c3877 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
@@ -72,6 +72,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.ArrowDropDown
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.Remove
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.SwapHoriz
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.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
+ SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
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")
}
@@ -258,7 +262,8 @@ private val epubToolbarTools = setOf(
ReaderTool.FORMAT,
ReaderTool.SEARCH,
ReaderTool.AI_FEATURES,
- ReaderTool.TTS_CONTROLS
+ ReaderTool.TTS_CONTROLS,
+ ReaderTool.SCREEN_ORIENTATION
)
@Composable
@@ -272,6 +277,7 @@ fun EpubReaderTopBar(
tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
+ isRightToLeftPagination: Boolean,
onNavigateBack: () -> Unit,
isKeepScreenOn: Boolean,
onToggleKeepScreenOn: (Boolean) -> Unit,
@@ -281,12 +287,14 @@ fun EpubReaderTopBar(
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit,
+ onSetRightToLeftPagination: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
+ onOpenScreenOrientation: () -> Unit,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
@@ -414,17 +422,32 @@ fun EpubReaderTopBar(
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
}
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
+ var showReadingModeExpanded by remember { mutableStateOf(false) }
+ var showTtsSettingsExpanded by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = {
showHiddenToolsExpanded = false
+ showReadingModeExpanded = false
+ showTtsSettingsExpanded = false
showMoreMenu = true
}
) {
@@ -435,6 +458,8 @@ fun EpubReaderTopBar(
expanded = showMoreMenu,
onDismissRequest = {
showHiddenToolsExpanded = false
+ showReadingModeExpanded = false
+ showTtsSettingsExpanded = false
showMoreMenu = false
}
) {
@@ -480,7 +505,8 @@ fun EpubReaderTopBar(
onToggleFormat = onToggleFormat,
onToggleSearch = onToggleSearch,
onOpenAiHub = onOpenAiHub,
- onToggleTts = onToggleTts
+ onToggleTts = onToggleTts,
+ onOpenScreenOrientation = onOpenScreenOrientation
)
}
}
@@ -528,31 +554,63 @@ fun EpubReaderTopBar(
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
- enabled = !isTtsActive,
- onClick = {
- showMoreMenu = false
- onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
- },
+ text = { Text(stringResource(R.string.menu_change_reading_mode)) },
+ onClick = { showReadingModeExpanded = !showReadingModeExpanded },
trailingIcon = {
- if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_selected)
+ Icon(
+ Icons.Default.ArrowDropDown,
+ contentDescription = null,
+ modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
)
- })
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
- enabled = !isTtsActive,
- onClick = {
- showMoreMenu = false
- onChangeRenderMode(RenderMode.PAGINATED)
- },
- trailingIcon = {
- if (currentRenderMode == RenderMode.PAGINATED) Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_selected)
- )
- })
+ }
+ )
+ if (showReadingModeExpanded) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
+ enabled = !isTtsActive,
+ onClick = {
+ showMoreMenu = false
+ onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
+ },
+ trailingIcon = {
+ if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_selected)
+ )
+ })
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
+ enabled = !isTtsActive,
+ onClick = {
+ onSetRightToLeftPagination(false)
+ showMoreMenu = false
+ onChangeRenderMode(RenderMode.PAGINATED)
+ },
+ trailingIcon = {
+ if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
+ Icon(
+ Icons.Default.Check,
+ 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()
}
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
@@ -664,39 +722,62 @@ fun EpubReaderTopBar(
})
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_voice_settings)) },
- enabled = !isTtsActive,
- onClick = {
- showMoreMenu = false
- onOpenTtsSettings()
- },
+ text = { Text(stringResource(R.string.menu_tts_settings)) },
+ onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
- }
- )
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
- onClick = {
- showMoreMenu = false
- onOpenTtsReplacements()
},
- leadingIcon = {
+ trailingIcon = {
Icon(
- Icons.Default.GraphicEq,
+ Icons.Default.ArrowDropDown,
contentDescription = null,
- modifier = Modifier.size(20.dp)
+ modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
)
}
)
+ if (showTtsSettingsExpanded) {
+ if (showTtsVoiceSettings) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
+ enabled = !isTtsActive,
+ onClick = {
+ showMoreMenu = false
+ onOpenTtsSettings()
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Default.GraphicEq,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ )
+ }
+ if (showTtsReplacements) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
+ onClick = {
+ showMoreMenu = false
+ onOpenTtsReplacements()
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Default.GraphicEq,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ )
+ }
+ }
}
}
}
@@ -706,6 +787,89 @@ 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)
@Composable
fun EpubReaderBottomBar(
@@ -723,6 +887,7 @@ fun EpubReaderBottomBar(
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onToggleTts: () -> Unit,
+ onOpenScreenOrientation: () -> Unit,
hiddenTools: Set,
toolOrder: List,
bottomTools: Set,
@@ -835,6 +1000,16 @@ fun EpubReaderBottomBar(
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
}
}
@@ -1849,6 +2024,7 @@ private fun ToolPreviewIcon(tool: ReaderTool) {
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.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))
}
}
@@ -1866,7 +2042,8 @@ private fun HiddenEpubToolMenuItem(
onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit,
onOpenAiHub: () -> Unit,
- onToggleTts: () -> Unit
+ onToggleTts: () -> Unit,
+ onOpenScreenOrientation: () -> Unit
) {
val enabled = when (tool) {
ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
@@ -1886,6 +2063,7 @@ private fun HiddenEpubToolMenuItem(
ReaderTool.SEARCH -> onToggleSearch()
ReaderTool.AI_FEATURES -> onOpenAiHub()
ReaderTool.TTS_CONTROLS -> onToggleTts()
+ ReaderTool.SCREEN_ORIENTATION -> onOpenScreenOrientation()
else -> Unit
}
},
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
index c24acc9..1a4d11d 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
@@ -154,12 +154,12 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult
-import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig
import com.aryan.reader.BuiltInThemes
-import com.aryan.reader.CustomTopBanner
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
+import com.aryan.reader.ReaderScreenOrientationEffect
+import com.aryan.reader.ReaderScreenOrientationSheet
import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
@@ -176,6 +176,8 @@ import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
+import com.aryan.reader.loadReaderScreenOrientationMode
+import com.aryan.reader.loadEpubRightToLeftPagination
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences
@@ -196,14 +198,18 @@ import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
+import com.aryan.reader.saveReaderScreenOrientationMode
+import com.aryan.reader.saveEpubRightToLeftPagination
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
+import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
+import com.aryan.reader.shared.reader.ReaderJumpHistory
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@@ -239,11 +245,14 @@ private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
private const val TOOL_ORDER_KEY = "reader_tool_order"
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
+private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version"
+private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 1
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
private const val TAG_LINK_NAV = "LINK_NAV"
+private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
@@ -283,12 +292,25 @@ private fun rememberBottomRoundedCornerPadding(view: View): Dp {
private fun saveHiddenTools(context: Context, hiddenTools: Set) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
- prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
+ prefs.edit {
+ putStringSet(HIDDEN_TOOLS_KEY, hiddenTools)
+ putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
+ }
}
private fun loadHiddenTools(context: Context): Set {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
- return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
+ val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
+ val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
+ if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
+ val migratedHiddenTools = savedHiddenTools + ReaderTool.SCREEN_ORIENTATION.name
+ prefs.edit {
+ putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
+ putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
+ }
+ return migratedHiddenTools
+ }
+ return savedHiddenTools
}
private fun saveToolOrder(context: Context, toolOrder: List) {
@@ -615,7 +637,9 @@ fun EpubReaderHost(
val window = (view.context as? Activity)?.window
val activity = context as? Activity
val scope = rememberCoroutineScope()
- var bannerMessage by remember { mutableStateOf(null) }
+ fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
+ viewModel.showBanner(message, isError, isPersistent)
+ }
DisposableEffect(window, view) {
onDispose {
window?.let {
@@ -652,9 +676,13 @@ fun EpubReaderHost(
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
+ var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
+ var rightToLeftPagination by remember { mutableStateOf(loadEpubRightToLeftPagination(context)) }
+ var showScreenOrientationSheet by remember { mutableStateOf(false) }
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) }
+ ReaderScreenOrientationEffect(screenOrientationMode)
var volumeScrollEnabled by remember {
mutableStateOf(loadVolumeScrollSetting(context))
@@ -904,6 +932,7 @@ fun EpubReaderHost(
var showRecapPopup by remember { mutableStateOf(false) }
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
+ var epubJumpHistory by remember(readerCacheBookId) { mutableStateOf(ReaderJumpHistory()) }
var chapterToLoadOnSwitch by remember { mutableStateOf(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
var paginatedReconfigurationAnchor by remember { mutableStateOf(null) }
@@ -985,11 +1014,17 @@ fun EpubReaderHost(
)
}
+ LaunchedEffect(chapters.size) {
+ epubJumpHistory = epubJumpHistory.pruned(chapters.size)
+ }
+
var paginator by remember { mutableStateOf(null) }
val paginatedPagerState = rememberPagerState(pageCount = {
(paginator as? BookPaginator)?.totalPageCount ?: 0
})
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
+ var paginatedExplicitNavigationEpoch by remember(epubBook) { mutableLongStateOf(0L) }
+ var paginatedExplicitNavigationAnchor by remember(epubBook) { mutableStateOf(null) }
val ttsController = viewModel.ttsController
val ttsState by ttsController.ttsState.collectAsState()
@@ -1105,13 +1140,6 @@ fun EpubReaderHost(
}
}
- LaunchedEffect(bannerMessage) {
- if (bannerMessage != null) {
- delay(2500L)
- bannerMessage = null
- }
- }
-
val configuration = LocalConfiguration.current
var lastOrientation by remember { mutableIntStateOf(configuration.orientation) }
@@ -1145,7 +1173,7 @@ fun EpubReaderHost(
showInsufficientCreditsDialog = true
ttsController.stop()
} else {
- bannerMessage = BannerMessage(message, isError = true)
+ showBanner(message, isError = true)
}
}
}
@@ -1479,9 +1507,8 @@ fun EpubReaderHost(
return false
}
val pageIndex =
- sourceOffset?.let { bookPaginator.findPageForCfiAndOffset(chapterIndex, sourceCfi, it) }
- ?: bookPaginator.findPageForLocator(locator)
- ?: bookPaginator.chapterStartPageIndices[chapterIndex] ?: run {
+ bookPaginator.findStablePageForLocator(locator)
+ ?: bookPaginator.findStableChapterStartPage(chapterIndex) ?: run {
logTtsChapterDiag("Paginated locate aborted: page lookup failed. reason=$reason chapter=$chapterIndex")
return false
}
@@ -1912,6 +1939,13 @@ fun EpubReaderHost(
DisposableEffect(Unit) {
onDispose {
Timber.d("Disposing reader. Last known chapter was ${latestChapterIndex}. Position saved periodically.")
+ webViewRefForTts = null
+ chapterHead = ""
+ chapterChunks = emptyList()
+ startPageThumbnail?.recycle()
+ startPageThumbnail = null
+ autoScrollResumeJob.value?.cancel()
+ autoScrollResumeJob.value = null
}
}
@@ -2246,8 +2280,424 @@ fun EpubReaderHost(
}
}
+ fun Locator.toEpubJumpLocator(pageIndex: Int? = null, cfiOverride: String? = null): SharedReaderLocator {
+ return SharedReaderLocator(
+ chapterIndex = chapterIndex,
+ pageIndex = pageIndex,
+ cfi = cfiOverride ?: "android-locator:$chapterIndex:$blockIndex:$charOffset"
+ )
+ }
+
+ fun SharedReaderLocator.toAndroidLocatorOrNull(): Locator? {
+ val parts = cfi
+ ?.takeIf { it.startsWith("android-locator:") }
+ ?.split(':')
+ ?: return null
+ return Locator(
+ chapterIndex = parts.getOrNull(1)?.toIntOrNull() ?: return null,
+ blockIndex = parts.getOrNull(2)?.toIntOrNull() ?: return null,
+ charOffset = parts.getOrNull(3)?.toIntOrNull() ?: return null
+ )
+ }
+
+ fun currentEpubJumpLocator(): SharedReaderLocator? {
+ return when (currentRenderMode) {
+ RenderMode.VERTICAL_SCROLL -> SharedReaderLocator(
+ chapterIndex = currentChapterIndex,
+ cfi = "android-scroll:$currentScrollYPosition"
+ )
+ RenderMode.PAGINATED -> {
+ val pageIndex = paginatedPagerState.currentPage.takeIf { it >= 0 }
+ val locator = (paginator as? BookPaginator)?.getLocatorForPage(paginatedPagerState.currentPage)
+ val fallbackLocator = lastKnownLocator?.takeIf {
+ currentChapterInPaginatedMode != null && it.chapterIndex == currentChapterInPaginatedMode
+ }
+ locator?.toEpubJumpLocator(pageIndex = pageIndex)
+ ?: fallbackLocator?.toEpubJumpLocator(pageIndex = pageIndex)
+ }
+ }
+ }
+
+ fun chapterStartJumpLocator(chapterIndex: Int): SharedReaderLocator {
+ return SharedReaderLocator(
+ chapterIndex = chapterIndex,
+ href = chapters.getOrNull(chapterIndex)?.absPath,
+ cfi = "android-scroll:0"
+ )
+ }
+
+ fun fragmentJumpLocator(chapterIndex: Int, fragment: String?, href: String? = null): SharedReaderLocator {
+ return SharedReaderLocator(
+ chapterIndex = chapterIndex,
+ href = href ?: chapters.getOrNull(chapterIndex)?.absPath,
+ cfi = fragment?.let { "android-fragment:$it" } ?: "android-scroll:0"
+ )
+ }
+
+ fun cfiJumpLocator(chapterIndex: Int, cfi: String, textQuote: String? = null): SharedReaderLocator {
+ return SharedReaderLocator(
+ chapterIndex = chapterIndex,
+ cfi = cfi,
+ textQuote = textQuote
+ )
+ }
+
+ fun recordEpubJump(target: SharedReaderLocator?) {
+ epubJumpHistory = epubJumpHistory.record(
+ currentLocator = currentEpubJumpLocator(),
+ targetLocator = target,
+ chapterCount = chapters.size
+ )
+ }
+
+ fun paginatedJumpLocatorForPage(
+ pageIndex: Int,
+ targetLocator: Locator? = null,
+ fallbackChapterIndex: Int? = null,
+ allowPageFallback: Boolean = false
+ ): SharedReaderLocator? {
+ val safePageIndex = when {
+ pageIndex < 0 -> return null
+ paginatedPagerState.pageCount > 0 -> pageIndex.coerceIn(0, paginatedPagerState.pageCount - 1)
+ else -> pageIndex
+ }
+ val bookPaginator = paginator as? BookPaginator
+ val resolvedLocator = targetLocator ?: bookPaginator?.getLocatorForPage(safePageIndex)
+ if (resolvedLocator != null) {
+ return resolvedLocator.toEpubJumpLocator(pageIndex = safePageIndex)
+ }
+ if (!allowPageFallback) return null
+ val chapterIndex = fallbackChapterIndex ?: bookPaginator?.findChapterIndexForPage(safePageIndex)
+ return SharedReaderLocator(
+ chapterIndex = chapterIndex,
+ pageIndex = safePageIndex,
+ cfi = "android-page:$safePageIndex"
+ )
+ }
+
+ suspend fun scrollPaginatedToJumpPage(
+ pageIndex: Int,
+ targetLocator: Locator? = null,
+ fallbackToChapterStart: Boolean = false
+ ) {
+ if (paginatedPagerState.pageCount <= 0) return
+ val targetPageIndex = pageIndex.coerceIn(0, paginatedPagerState.pageCount - 1)
+ val bookPaginator = paginator as? BookPaginator
+ val resolvedLocator = targetLocator
+ ?: bookPaginator?.getLocatorForPage(targetPageIndex)
+ ?: if (fallbackToChapterStart) {
+ bookPaginator
+ ?.findChapterIndexForPage(targetPageIndex)
+ ?.let { Locator(chapterIndex = it, blockIndex = 0, charOffset = 0) }
+ } else {
+ null
+ }
+
+ if (resolvedLocator != null) {
+ lastKnownLocator = resolvedLocator
+ }
+ val navigationEpoch = System.currentTimeMillis()
+ paginatedExplicitNavigationEpoch = navigationEpoch
+ paginatedExplicitNavigationAnchor = resolvedLocator
+ Timber.tag(TAG_STABLE_PAGE_NAV).d(
+ "external_scroll_request requestedPage=$pageIndex targetPage=$targetPageIndex anchor=$resolvedLocator fallbackToChapterStart=$fallbackToChapterStart pageCount=${paginatedPagerState.pageCount} epoch=$navigationEpoch"
+ )
+ bookPaginator?.onUserScrolledTo(targetPageIndex)
+ paginatedPagerState.scrollToPage(targetPageIndex)
+ Timber.tag(TAG_STABLE_PAGE_NAV).d(
+ "external_scroll_complete targetPage=$targetPageIndex currentPage=${paginatedPagerState.currentPage} anchor=$resolvedLocator epoch=$navigationEpoch"
+ )
+ }
+
+ fun SharedReaderLocator.epubJumpLabel(): String {
+ val targetPageIndex = pageIndex
+ val targetCfi = cfi.orEmpty()
+ if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) {
+ return "Page ${targetPageIndex + 1}"
+ }
+ val chapter = chapterIndex
+ return if (chapter != null) {
+ chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: "Chapter ${chapter + 1}"
+ } else {
+ "Location"
+ }
+ }
+
+ fun injectVerticalChunksThrough(targetChunk: Int) {
+ if (targetChunk < loadedChunkCount) return
+ (loadedChunkCount..targetChunk).forEach { idx ->
+ val content = chapterChunks.getOrNull(idx)
+ if (content != null) {
+ val escaped = escapeJsString(content)
+ webViewRefForTts?.evaluateJavascript(
+ "javascript:window.virtualization.appendChunk($idx, '$escaped');",
+ null
+ )
+ }
+ }
+ loadUpToChunkIndex = targetChunk
+ loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
+ }
+
+ fun scrollCurrentVerticalChapterToFragment(fragment: String) {
+ val escapedFragment = escapeJsString(fragment)
+ val js = """
+ (function() {
+ var targetId = '$escapedFragment';
+ var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]');
+ if (el) {
+ var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
+ window.scrollTo({ top: targetScrollY, behavior: 'auto' });
+ return -2;
+ }
+ if (window.virtualization && window.virtualization.chunksData) {
+ for (var i = 0; i < window.virtualization.chunksData.length; i++) {
+ var chunkHtml = window.virtualization.chunksData[i];
+ if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) {
+ return i;
+ }
+ }
+ }
+ return -1;
+ })()
+ """.trimIndent()
+ webViewRefForTts?.evaluateJavascript(js) { result ->
+ val chunkIdx = result?.toIntOrNull() ?: -1
+ if (chunkIdx >= 0) {
+ injectVerticalChunksThrough(chunkIdx)
+ val scrollJs = """
+ (function() {
+ var chunkIndex = $chunkIdx;
+ var fragmentId = '$escapedFragment';
+ var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]');
+ if (chunkDiv) {
+ if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
+ chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex];
+ chunkDiv.style.height = "";
+ }
+ setTimeout(function() {
+ var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]');
+ if (el) {
+ var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
+ window.scrollTo({ top: targetScrollY, behavior: 'auto' });
+ } else {
+ var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP;
+ window.scrollTo({ top: targetScrollY, behavior: 'auto' });
+ }
+ }, 150);
+ }
+ })()
+ """.trimIndent()
+ webViewRefForTts?.evaluateJavascript(scrollJs, null)
+ } else if (chunkIdx == -1) {
+ webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null)
+ }
+ }
+ }
+
+ fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) {
+ scope.launch {
+ val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi)
+ val targetChunk = locator?.let { it.blockIndex / 20 }
+ cfiToLoad = cfi
+ initialScrollTargetForChapter = null
+ if (chapterIndex != currentChapterIndex) {
+ chunkTargetOverride = targetChunk?.coerceAtLeast(0) ?: 0
+ currentScrollYPosition = 0
+ currentScrollHeightValue = 0
+ currentChapterIndex = chapterIndex
+ } else {
+ if (targetChunk != null && targetChunk >= 0) {
+ injectVerticalChunksThrough(targetChunk)
+ }
+ webViewRefForTts?.evaluateJavascript(
+ "javascript:window.scrollToCfi('${escapeJsString(cfi)}');",
+ null
+ )
+ }
+ }
+ }
+
+ fun navigateToEpubJumpLocator(locator: SharedReaderLocator) {
+ scope.launch {
+ val chapterIndex = locator.chapterIndex?.coerceIn(0, max(0, chapters.lastIndex))
+ val cfi = locator.cfi.orEmpty()
+ when (currentRenderMode) {
+ RenderMode.VERTICAL_SCROLL -> {
+ clearPendingTtsRelocationState("epub_jump_history")
+ when {
+ cfi.startsWith("android-scroll:") -> {
+ val scrollY = cfi.substringAfter("android-scroll:").toIntOrNull() ?: 0
+ initialScrollTargetForChapter = null
+ if (chapterIndex != null && chapterIndex != currentChapterIndex) {
+ currentScrollYPosition = scrollY
+ currentScrollHeightValue = 0
+ currentChapterIndex = chapterIndex
+ } else {
+ webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0, $scrollY);", null)
+ }
+ }
+ cfi.startsWith("android-fragment:") -> {
+ val fragment = cfi.substringAfter("android-fragment:")
+ initialScrollTargetForChapter = null
+ fragmentToLoad = fragment
+ if (chapterIndex != null && chapterIndex != currentChapterIndex) {
+ currentScrollYPosition = 0
+ currentScrollHeightValue = 0
+ currentChapterIndex = chapterIndex
+ } else {
+ scrollCurrentVerticalChapterToFragment(fragment)
+ }
+ }
+ cfi.startsWith("android-search:") -> {
+ val parts = cfi.split(':')
+ val targetChunk = parts.getOrNull(1)?.toIntOrNull() ?: 0
+ val occurrence = parts.getOrNull(2)?.toIntOrNull() ?: 0
+ initialScrollTargetForChapter = null
+ if (chapterIndex != null && chapterIndex != currentChapterIndex) {
+ chunkTargetOverride = targetChunk.coerceAtLeast(0)
+ searchHighlightTarget = searchState.searchResults.firstOrNull {
+ it.locationInSource == chapterIndex &&
+ it.chunkIndex == targetChunk &&
+ it.occurrenceIndexInLocation == occurrence
+ }
+ currentScrollYPosition = 0
+ currentScrollHeightValue = 0
+ currentChapterIndex = chapterIndex
+ } else {
+ injectVerticalChunksThrough(targetChunk)
+ webViewRefForTts?.evaluateJavascript(
+ "javascript:window.scrollToOccurrence($occurrence);",
+ null
+ )
+ }
+ }
+ cfi.startsWith("android-locator:") -> {
+ val androidLocator = locator.toAndroidLocatorOrNull()
+ val targetCfi = androidLocator?.let { locatorConverter.getCfiFromLocator(epubBook, it) }
+ if (androidLocator != null && targetCfi != null) {
+ navigateVerticalToCfi(androidLocator.chapterIndex, targetCfi)
+ } else if (chapterIndex != null) {
+ initialScrollTargetForChapter = ChapterScrollPosition.START
+ currentScrollYPosition = 0
+ currentScrollHeightValue = 0
+ currentChapterIndex = chapterIndex
+ }
+ }
+ cfi.startsWith("android-page:") && chapterIndex != null -> {
+ initialScrollTargetForChapter = ChapterScrollPosition.START
+ currentScrollYPosition = 0
+ currentScrollHeightValue = 0
+ if (chapterIndex != currentChapterIndex) {
+ currentChapterIndex = chapterIndex
+ } else {
+ webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null)
+ }
+ }
+ cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null -> navigateVerticalToCfi(chapterIndex, cfi)
+ chapterIndex != null -> {
+ initialScrollTargetForChapter = ChapterScrollPosition.START
+ currentScrollYPosition = 0
+ currentScrollHeightValue = 0
+ if (chapterIndex != currentChapterIndex) {
+ currentChapterIndex = chapterIndex
+ } else {
+ webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null)
+ }
+ }
+ }
+ }
+
+ RenderMode.PAGINATED -> {
+ val bookPaginator = paginator as? BookPaginator
+ val directPage = locator.pageIndex?.takeIf { it in 0 until paginatedPagerState.pageCount }
+ isNavigatingToPosition = true
+ try {
+ when {
+ cfi.startsWith("android-locator:") && bookPaginator != null -> {
+ val androidLocator = locator.toAndroidLocatorOrNull()
+ val targetPage = androidLocator?.let { bookPaginator.findStablePageForLocator(it) }
+ if (targetPage != null) {
+ scrollPaginatedToJumpPage(targetPage, androidLocator)
+ } else if (directPage != null) {
+ scrollPaginatedToJumpPage(directPage)
+ }
+ }
+ cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null && bookPaginator != null -> {
+ val androidLocator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi)
+ val targetPage = androidLocator?.let { bookPaginator.findStablePageForLocator(it) }
+ if (targetPage != null) {
+ scrollPaginatedToJumpPage(targetPage, androidLocator)
+ } else if (directPage != null) {
+ scrollPaginatedToJumpPage(directPage)
+ } else {
+ bookPaginator.findStableChapterStartPage(chapterIndex)?.let {
+ scrollPaginatedToJumpPage(it, Locator(chapterIndex, 0, 0), fallbackToChapterStart = true)
+ }
+ }
+ }
+ cfi.startsWith("android-fragment:") && directPage != null -> scrollPaginatedToJumpPage(directPage)
+ cfi.startsWith("android-search:") && directPage != null -> scrollPaginatedToJumpPage(directPage)
+ cfi.startsWith("android-page:") && directPage != null -> scrollPaginatedToJumpPage(directPage)
+ directPage != null -> scrollPaginatedToJumpPage(directPage)
+ chapterIndex != null && bookPaginator != null -> {
+ bookPaginator.findStableChapterStartPage(chapterIndex)?.let {
+ scrollPaginatedToJumpPage(it, Locator(chapterIndex, 0, 0), fallbackToChapterStart = true)
+ }
+ }
+ }
+ } finally {
+ isNavigatingToPosition = false
+ }
+ }
+ }
+ if (showBars) showBars = false
+ }
+ }
+
+ fun goBackInEpubJumpHistory() {
+ val target = epubJumpHistory.backLocator ?: return
+ epubJumpHistory = epubJumpHistory.stepBack()
+ navigateToEpubJumpLocator(target)
+ }
+
+ fun goForwardInEpubJumpHistory() {
+ val target = epubJumpHistory.forwardLocator ?: return
+ epubJumpHistory = epubJumpHistory.stepForward()
+ navigateToEpubJumpLocator(target)
+ }
+
fun navigateToSearchResult(index: Int) {
Timber.tag("NavDiag").d("navigateToSearchResult index: $index")
+ val targetResult = searchState.searchResults.getOrNull(index)
+ if (targetResult != null && currentRenderMode == RenderMode.VERTICAL_SCROLL) {
+ recordEpubJump(
+ SharedReaderLocator(
+ chapterIndex = targetResult.locationInSource,
+ cfi = "android-search:${targetResult.chunkIndex}:${targetResult.occurrenceIndexInLocation}",
+ textQuote = targetResult.snippet.text
+ )
+ )
+ }
+ if (targetResult != null && currentRenderMode == RenderMode.PAGINATED) {
+ scope.launch {
+ searchState.currentSearchResultIndex = index
+ isNavigatingToPosition = true
+ try {
+ val bookPaginator = paginator as? BookPaginator ?: return@launch
+ val pageIdx = bookPaginator.findStablePageForSearchResult(targetResult) ?: return@launch
+ Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx")
+ val targetLocator = bookPaginator.getLocatorForPage(pageIdx)
+ paginatedJumpLocatorForPage(pageIdx, targetLocator)
+ ?.copy(textQuote = targetResult.snippet.text)
+ ?.let { recordEpubJump(it) }
+ scrollPaginatedToJumpPage(pageIdx, targetLocator)
+ } finally {
+ isNavigatingToPosition = false
+ }
+ }
+ return
+ }
performSearchResultNavigation(
index = index,
searchState = searchState,
@@ -2288,7 +2738,11 @@ fun EpubReaderHost(
},
onPaginatedScrollToPage = { pageIdx ->
Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx")
- paginatedPagerState.scrollToPage(pageIdx)
+ val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(pageIdx)
+ paginatedJumpLocatorForPage(pageIdx, targetLocator)
+ ?.copy(textQuote = searchState.searchResults.getOrNull(index)?.snippet?.text)
+ ?.let { recordEpubJump(it) }
+ scrollPaginatedToJumpPage(pageIdx, targetLocator)
}
)
}
@@ -2346,6 +2800,7 @@ fun EpubReaderHost(
if (targetChapterIndex != -1) {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
+ recordEpubJump(fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath))
clearPendingTtsRelocationState("toc_entry_vertical")
fragmentToLoad = entry.fragmentId
if (targetChapterIndex != currentChapterIndex) {
@@ -2434,15 +2889,26 @@ fun EpubReaderHost(
Timber.tag("TOC_NAV_DEBUG").d("TOC Entry Clicked: ${entry.label}, targetChapter: $targetChapterIndex, anchor: ${entry.fragmentId}")
isNavigatingByToc = true
-
- bookPaginator.findPageForAnchor(targetChapterIndex, entry.fragmentId) { targetPage ->
- scope.launch {
+ try {
+ val targetPage = bookPaginator.findStablePageForAnchor(targetChapterIndex, entry.fragmentId)
+ if (targetPage != null) {
+ recordEpubJump(
+ fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath)
+ .copy(pageIndex = targetPage)
+ )
Timber.tag(TAG_LINK_NAV)
.d("[CHAPTER-NAV] source=TOC_ENTRY_PAGINATED, from=$currentChapterIndex, to=$targetChapterIndex, page=$targetPage, anchor='${entry.fragmentId}', label='${entry.label}'")
Timber.tag("TOC_NAV_DEBUG").d("Scrolling Pager to page: $targetPage")
- paginatedPagerState.scrollToPage(targetPage)
- isNavigatingByToc = false
+ val targetLocator = bookPaginator.getLocatorForPage(targetPage)
+ ?: if (entry.fragmentId == null) Locator(targetChapterIndex, 0, 0) else null
+ scrollPaginatedToJumpPage(
+ targetPage,
+ targetLocator,
+ fallbackToChapterStart = entry.fragmentId == null
+ )
}
+ } finally {
+ isNavigatingByToc = false
}
} else {
Timber.tag("TOC_NAV_DEBUG").w("Paginator not ready for TOC navigation.")
@@ -2461,6 +2927,7 @@ fun EpubReaderHost(
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
if (index != currentChapterIndex) {
+ recordEpubJump(chapterStartJumpLocator(index))
clearPendingTtsRelocationState("sidebar_chapter_vertical")
Timber.tag(TAG_LINK_NAV)
.d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER, from=$currentChapterIndex, to=$index")
@@ -2479,12 +2946,18 @@ fun EpubReaderHost(
if (bookPaginator != null) {
val currentFromPager = bookPaginator.findChapterIndexForPage(paginatedPagerState.currentPage)
if (index != currentFromPager) {
- val targetPage = bookPaginator.chapterStartPageIndices[index]
- if (targetPage != null) {
- Timber.tag(TAG_LINK_NAV)
- .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER_PAGINATED, from=$currentFromPager, to=$index, page=$targetPage")
- paginatedPagerState.scrollToPage(targetPage)
- if (showBars) showBars = false
+ isNavigatingByToc = true
+ try {
+ val targetPage = bookPaginator.findStableChapterStartPage(index)
+ if (targetPage != null) {
+ recordEpubJump(chapterStartJumpLocator(index).copy(pageIndex = targetPage))
+ Timber.tag(TAG_LINK_NAV)
+ .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER_PAGINATED, from=$currentFromPager, to=$index, page=$targetPage")
+ scrollPaginatedToJumpPage(targetPage, Locator(index, 0, 0), fallbackToChapterStart = true)
+ if (showBars) showBars = false
+ }
+ } finally {
+ isNavigatingByToc = false
}
}
}
@@ -2498,6 +2971,7 @@ fun EpubReaderHost(
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
+ recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet))
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
cfiToLoad = bookmark.cfi
@@ -2568,36 +3042,39 @@ fun EpubReaderHost(
}
}
RenderMode.PAGINATED -> {
+ recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet))
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
isNavigatingToPosition = true
- val locator = locatorConverter.getLocatorFromCfi(
- book = epubBook,
- chapterIndex = bookmark.chapterIndex,
- cfi = bookmark.cfi
- )
+ try {
+ val bookPaginator = paginator as? BookPaginator
+ val locator = locatorConverter.getLocatorFromCfi(
+ book = epubBook,
+ chapterIndex = bookmark.chapterIndex,
+ cfi = bookmark.cfi
+ )
- if (locator != null) {
- Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator")
- val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator)
- if (pageIndex != null) {
- Timber.d("P-Mode Click: Paginator found page $pageIndex for locator. Scrolling.")
- paginatedPagerState.scrollToPage(pageIndex)
+ if (locator != null && bookPaginator != null) {
+ Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator")
+ val pageIndex = bookPaginator.findStablePageForLocator(locator)
+ if (pageIndex != null) {
+ Timber.d("P-Mode Click: Paginator found page $pageIndex for locator. Scrolling.")
+ scrollPaginatedToJumpPage(pageIndex, locator)
+ } else {
+ Timber.w("P-Mode Click: Paginator could not find a page for the locator. Falling back to chapter start.")
+ val chapterStartPage = bookPaginator.findStableChapterStartPage(bookmark.chapterIndex)
+ if (chapterStartPage != null) {
+ scrollPaginatedToJumpPage(chapterStartPage, Locator(bookmark.chapterIndex, 0, 0), fallbackToChapterStart = true)
+ }
+ }
} else {
- Timber.w("P-Mode Click: Paginator could not find a page for the locator. Falling back to chapter start.")
- val chapterStartPage = (paginator as? BookPaginator)?.chapterStartPageIndices?.get(bookmark.chapterIndex)
- if (chapterStartPage != null) {
- paginatedPagerState.scrollToPage(chapterStartPage)
+ Timber.w("P-Mode Click: Failed to convert CFI to Locator. Falling back to stable chapter start.")
+ val fallbackPage = bookPaginator?.findStableChapterStartPage(bookmark.chapterIndex)
+ if (fallbackPage != null) {
+ scrollPaginatedToJumpPage(fallbackPage, Locator(bookmark.chapterIndex, 0, 0), fallbackToChapterStart = true)
}
}
+ } finally {
isNavigatingToPosition = false
- } else {
- Timber.w("P-Mode Click: Failed to convert CFI to Locator. Using old findPageForCfi as a fallback.")
- paginator?.findPageForCfi(bookmark.chapterIndex, bookmark.cfi) { pageIndex ->
- scope.launch {
- paginatedPagerState.scrollToPage(pageIndex)
- isNavigatingToPosition = false
- }
- }
}
}
}
@@ -2611,6 +3088,7 @@ fun EpubReaderHost(
drawerState.close()
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
+ recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text))
cfiToLoad = highlight.cfi
val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi)
val targetChunk = locator?.let { it.blockIndex / 20 }
@@ -2671,26 +3149,29 @@ fun EpubReaderHost(
}
}
RenderMode.PAGINATED -> {
+ recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text))
isNavigatingToPosition = true
- val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi)
- if (locator != null) {
- val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator)
- if (pageIndex != null) {
- paginatedPagerState.scrollToPage(pageIndex)
+ try {
+ val bookPaginator = paginator as? BookPaginator
+ val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi)
+ if (locator != null && bookPaginator != null) {
+ val pageIndex = bookPaginator.findStablePageForLocator(locator)
+ if (pageIndex != null) {
+ scrollPaginatedToJumpPage(pageIndex, locator)
+ } else {
+ val chapterStartPage = bookPaginator.findStableChapterStartPage(highlight.chapterIndex)
+ if (chapterStartPage != null) {
+ scrollPaginatedToJumpPage(chapterStartPage, Locator(highlight.chapterIndex, 0, 0), fallbackToChapterStart = true)
+ }
+ }
} else {
- val chapterStartPage = (paginator as? BookPaginator)?.chapterStartPageIndices?.get(highlight.chapterIndex)
- if (chapterStartPage != null) {
- paginatedPagerState.scrollToPage(chapterStartPage)
+ val fallbackPage = bookPaginator?.findStableChapterStartPage(highlight.chapterIndex)
+ if (fallbackPage != null) {
+ scrollPaginatedToJumpPage(fallbackPage, Locator(highlight.chapterIndex, 0, 0), fallbackToChapterStart = true)
}
}
+ } finally {
isNavigatingToPosition = false
- } else {
- paginator?.findPageForCfi(highlight.chapterIndex, highlight.cfi) { pageIndex ->
- scope.launch {
- paginatedPagerState.scrollToPage(pageIndex)
- isNavigatingToPosition = false
- }
- }
}
}
}
@@ -2905,8 +3386,7 @@ fun EpubReaderHost(
)
runRecap(chapterIndex, charsScrolled.toInt())
} else {
- bannerMessage =
- BannerMessage("Wait for book to load fully.", isError = true)
+ showBanner("Wait for book to load fully.", isError = true)
}
}
}
@@ -3438,12 +3918,39 @@ fun EpubReaderHost(
onInternalLinkClick = { url ->
scope.launch {
val basePath = "file://${epubBook.extractionBasePath}/"
- val relativeUrl = url.removePrefix(basePath)
+ val rawRelativeUrl = url.removePrefix(basePath)
+ val relativeUrl = if (rawRelativeUrl != url) {
+ rawRelativeUrl
+ } else {
+ val decodedUrl = try {
+ java.net.URLDecoder.decode(url, "UTF-8")
+ } catch (_: Exception) {
+ url
+ }
+ decodedUrl.removePrefix(basePath)
+ }
val pathPart = relativeUrl.substringBefore('#')
val fragmentPart = relativeUrl.substringAfter('#', "").takeIf { it.isNotEmpty() }
val decodedPath = try { java.net.URLDecoder.decode(pathPart, "UTF-8") } catch(e: Exception) { pathPart }
- val targetChapterIndex = chapters.indexOfFirst { it.absPath == decodedPath }
+ val renderedChapter = chapters.getOrNull(targetChapterIndex)
+ val renderedChapterDirectory = renderedChapter
+ ?.htmlFilePath
+ ?.substringBeforeLast('/', "")
+ .orEmpty()
+ .trim('/')
+ val decodedPathDirectory = decodedPath.trim('/')
+ val resolvedTargetChapterIndex = when {
+ pathPart.isBlank() -> targetChapterIndex
+ decodedPath.isBlank() -> targetChapterIndex
+ renderedChapterDirectory.isNotBlank() && decodedPathDirectory == renderedChapterDirectory -> targetChapterIndex
+ else -> chapters.indexOfFirst {
+ it.absPath == decodedPath ||
+ it.htmlFilePath == decodedPath ||
+ it.absPath.trim('/') == decodedPathDirectory ||
+ it.htmlFilePath.trim('/') == decodedPathDirectory
+ }
+ }
Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> url: $url")
Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> basePath: $basePath")
@@ -3451,22 +3958,24 @@ fun EpubReaderHost(
Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> pathPart: $pathPart")
Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> decodedPath: $decodedPath")
Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> fragmentPart: $fragmentPart")
- Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> targetChapterIndex: $targetChapterIndex (current is $currentChapterIndex)")
+ Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> targetChapterIndex: $resolvedTargetChapterIndex (current is $currentChapterIndex)")
- if (targetChapterIndex != -1) {
- if (targetChapterIndex != currentChapterIndex) {
- Timber.tag(TAG_LINK_NAV).d("[CHAPTER-NAV] source=INTERNAL_LINK, from=$currentChapterIndex, to=$targetChapterIndex, fragment='$fragmentPart'")
+ if (resolvedTargetChapterIndex != -1) {
+ recordEpubJump(fragmentJumpLocator(resolvedTargetChapterIndex, fragmentPart, chapters.getOrNull(resolvedTargetChapterIndex)?.absPath ?: decodedPath))
+ if (resolvedTargetChapterIndex != currentChapterIndex) {
+ Timber.tag(TAG_LINK_NAV).d("[CHAPTER-NAV] source=INTERNAL_LINK, from=$currentChapterIndex, to=$resolvedTargetChapterIndex, fragment='$fragmentPart'")
initialScrollTargetForChapter = null
fragmentToLoad = fragmentPart
currentScrollYPosition = 0
currentScrollHeightValue = 0
- currentChapterIndex = targetChapterIndex
+ currentChapterIndex = resolvedTargetChapterIndex
} else {
Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> Target is current chapter. Evaluating JS for fragment.")
if (fragmentPart != null) {
+ val escapedFragment = escapeJsString(fragmentPart)
val js = """
(function() {
- var targetId = '$fragmentPart';
+ var targetId = '$escapedFragment';
var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]');
if (el) {
var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
@@ -3505,7 +4014,7 @@ fun EpubReaderHost(
val scrollJs = """
(function() {
var chunkIndex = $chunkIdx;
- var fragmentId = '$fragmentPart';
+ var fragmentId = '$escapedFragment';
var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]');
if (chunkDiv) {
if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
@@ -3547,6 +4056,11 @@ fun EpubReaderHost(
null
)
},
+ onWebViewDisposed = { webView ->
+ if (webViewRefForTts === webView) {
+ webViewRefForTts = null
+ }
+ },
onScrollFinished = { success ->
Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success")
isNavigatingToPosition = false
@@ -3932,6 +4446,7 @@ fun EpubReaderHost(
effectiveBg = effectiveBg,
effectiveText = effectiveText,
pagerState = paginatedPagerState,
+ isRightToLeftPagination = rightToLeftPagination,
searchQuery = searchState.searchQuery,
fontSizeMultiplier = currentFontSizeEm,
lineHeightMultiplier = currentLineHeight,
@@ -3953,6 +4468,9 @@ fun EpubReaderHost(
activeTextureAlpha = activeTextureAlpha,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
fallbackLocatorForReconfiguration = paginatedReconfigurationAnchor ?: lastKnownLocator,
+ explicitNavigationAnchor = paginatedExplicitNavigationAnchor,
+ explicitNavigationEpoch = paginatedExplicitNavigationEpoch,
+ isExternalNavigationInProgress = isNavigatingToPosition || isNavigatingByToc,
onReconfigurationAnchorCaptured = { locator ->
paginatedReconfigurationAnchor = locator
lastKnownLocator = locator
@@ -3989,7 +4507,11 @@ fun EpubReaderHost(
when {
tapOffset.x < oneQuarterWidthPx -> {
scope.launch {
- val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
+ val targetPage = if (rightToLeftPagination) {
+ (paginatedPagerState.currentPage + 1).coerceAtMost(paginatedPagerState.pageCount - 1)
+ } else {
+ (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
+ }
if (targetPage != paginatedPagerState.currentPage) {
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
@@ -4001,7 +4523,11 @@ fun EpubReaderHost(
scope.launch {
val pageCount = paginatedPagerState.pageCount
if (pageCount > 0) {
- val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
+ val targetPage = if (rightToLeftPagination) {
+ (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
+ } else {
+ (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
+ }
if (targetPage != paginatedPagerState.currentPage) {
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
@@ -4069,6 +4595,26 @@ fun EpubReaderHost(
onFootnoteRequested = { html ->
activeFootnoteHtml = html
},
+ onInternalLinkNavigated = { targetPageIndex ->
+ val bookPaginator = paginator as? BookPaginator
+ val targetChapter = bookPaginator?.findChapterIndexForPage(targetPageIndex)
+ val targetLocator = bookPaginator?.getLocatorForPage(targetPageIndex)
+ val navigationEpoch = System.currentTimeMillis()
+ paginatedExplicitNavigationEpoch = navigationEpoch
+ paginatedExplicitNavigationAnchor = targetLocator
+ Timber.tag(TAG_STABLE_PAGE_NAV).d(
+ "internal_link_target targetPage=$targetPageIndex targetChapter=$targetChapter anchor=$targetLocator epoch=$navigationEpoch"
+ )
+ if (targetLocator != null) {
+ lastKnownLocator = targetLocator
+ }
+ bookPaginator?.onUserScrolledTo(targetPageIndex)
+ paginatedJumpLocatorForPage(
+ pageIndex = targetPageIndex,
+ targetLocator = targetLocator,
+ fallbackChapterIndex = targetChapter
+ )?.let { recordEpubJump(it) }
+ },
onHighlightDeleted = { cfi ->
val toRemove = userHighlights.find { it.cfi == cfi }
if (toRemove != null) {
@@ -4597,6 +5143,7 @@ fun EpubReaderHost(
tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
+ isRightToLeftPagination = rightToLeftPagination,
hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools,
@@ -4665,6 +5212,10 @@ fun EpubReaderHost(
isPageTurnAnimationEnabled = enabled
savePageTurnAnimationSetting(context, enabled)
},
+ onSetRightToLeftPagination = { enabled ->
+ rightToLeftPagination = enabled
+ saveEpubRightToLeftPagination(context, enabled)
+ },
onToggleVolumeScroll = { enabled ->
volumeScrollEnabled = enabled
saveVolumeScrollSetting(context, enabled)
@@ -4681,6 +5232,7 @@ fun EpubReaderHost(
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
+ onOpenScreenOrientation = { showScreenOrientationSheet = true },
onOpenAiHub = { showAiHubSheet = true },
onOpenSlider = {
when (currentRenderMode) {
@@ -4703,7 +5255,7 @@ fun EpubReaderHost(
showBars = false
startPageThumbnail = null
} else {
- bannerMessage = BannerMessage("Book is not paginated yet.")
+ showBanner("Book is not paginated yet.")
}
}
}
@@ -4903,6 +5455,19 @@ fun EpubReaderHost(
)
}
+ EpubJumpHistoryBar(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = bottomPadding + 45.dp),
+ showStandardBars = showBars,
+ searchStateActive = searchState.isSearchActive,
+ backLabel = epubJumpHistory.backLocator?.epubJumpLabel(),
+ forwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel(),
+ onBack = ::goBackInEpubJumpHistory,
+ onForward = ::goForwardInEpubJumpHistory,
+ onClear = { epubJumpHistory = epubJumpHistory.clear() }
+ )
+
// Animated Bottom Bar
EpubReaderBottomBar(
isVisible = showBars,
@@ -4938,7 +5503,7 @@ fun EpubReaderHost(
showBars = false
startPageThumbnail = null
} else {
- bannerMessage = BannerMessage("Book is not paginated yet.")
+ showBanner("Book is not paginated yet.")
}
}
}
@@ -4946,6 +5511,7 @@ fun EpubReaderHost(
onOpenDrawer = {
scope.launch { drawerState.open() }
},
+ onOpenScreenOrientation = { showScreenOrientationSheet = true },
onToggleFormat = {
showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) {
@@ -5238,8 +5804,6 @@ fun EpubReaderHost(
onDismiss = { activeFootnoteHtml = null }
)
}
-
- CustomTopBanner(bannerMessage = bannerMessage)
}
}
@@ -5279,10 +5843,22 @@ fun EpubReaderHost(
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
sliderCurrentPage = page.toFloat()
val scrollY = (page - 1) * currentClientHeightValue
+ recordEpubJump(
+ SharedReaderLocator(
+ chapterIndex = currentChapterIndex,
+ cfi = "android-scroll:$scrollY"
+ )
+ )
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
} else {
sliderCurrentPage = page.toFloat()
- paginatedPagerState.scrollToPage(page - 1)
+ val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1)
+ paginatedJumpLocatorForPage(
+ pageIndex = page - 1,
+ targetLocator = targetLocator,
+ allowPageFallback = true
+ )?.let { recordEpubJump(it) }
+ scrollPaginatedToJumpPage(page - 1, targetLocator)
}
}
}
@@ -5402,6 +5978,17 @@ fun EpubReaderHost(
)
}
+ if (showScreenOrientationSheet) {
+ ReaderScreenOrientationSheet(
+ selectedMode = screenOrientationMode,
+ onModeSelected = {
+ screenOrientationMode = it
+ saveReaderScreenOrientationMode(context, it)
+ },
+ onDismiss = { showScreenOrientationSheet = false }
+ )
+ }
+
if (showFontSelectionSheet) {
ModalBottomSheet(
onDismissRequest = { showFontSelectionSheet = false },
diff --git a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt
index 2fc9858..918089d 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt
@@ -26,12 +26,13 @@ import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ActionMode
import android.view.Menu
-import android.view.MenuItem
+import android.view.MenuInflater
import android.webkit.WebView
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.View
+import android.widget.PopupMenu
import org.json.JSONObject
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 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 =
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
@@ -167,148 +304,12 @@ class InteractiveWebView(
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? {
if (type == ActionMode.TYPE_FLOATING) {
- if (mCustomCallback == null) {
- mCustomCallback = object : ActionMode.Callback2() {
-
- 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)
+ Timber.d("CustomSelection: handling floating action mode locally.")
+ return startLocalSelectionActionMode()
}
return super.startActionMode(originalCallback, type)
}
@@ -316,18 +317,79 @@ class InteractiveWebView(
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
- scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
+ clearPendingSelectionWork()
scrollStopRunnable = Runnable {
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
val selectedText = result?.removeSurrounding("\"")
if (!selectedText.isNullOrBlank()) {
Timber.d("Selection exists after scroll. Restarting action mode.")
- mCustomCallback?.let {
- startActionMode(it, ActionMode.TYPE_FLOATING)
- }
+ startLocalSelectionActionMode()
}
}
}
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
}
-}
\ No newline at end of file
+
+ 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
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
index b8752e2..0a51c5c 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
@@ -59,6 +59,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlinx.serialization.ExperimentalSerializationApi
@@ -76,6 +78,7 @@ private const val PRIORITY_HIGHEST = 0
private const val PRIORITY_HIGH = 1
private const val PRIORITY_MEDIUM = 2
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)
@@ -187,6 +190,7 @@ class BookPaginator(
private val paginationQueue = PriorityBlockingQueue()
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet()
+ private val chapterPaginationLocks = ConcurrentHashMap()
private val navigationCallbacks = ConcurrentHashMap) -> Unit>>()
private var paginationWorker: Job? = null
@@ -430,9 +434,9 @@ class BookPaginator(
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
null
} else {
- pageCache.put(chapterIndex, pages)
applyPageRuntimeIndexes(chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size)
+ pageCache.put(chapterIndex, pages)
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
pages
}
@@ -574,8 +578,86 @@ class BookPaginator(
}
}
+ private suspend fun ensureChapterPaginated(chapterIndex: Int): List? {
+ 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? {
- val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
+ val pages = ensureChapterPaginated(chapterIndex)
if (pages.isNullOrEmpty()) {
Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.")
return null
@@ -794,7 +876,7 @@ class BookPaginator(
}
Timber.i("Worker: Starting pagination for chapter $chapterIndex.")
- val pages = paginateChapter(chapterIndex)
+ val pages = ensureChapterPaginated(chapterIndex)
if (pages != null) {
Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.")
@@ -836,6 +918,9 @@ class BookPaginator(
val difference = actualPageCount - estimatedPageCount
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)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
}
@@ -854,8 +939,16 @@ class BookPaginator(
}
rebuildChapterStartSnapshot()
- if (chapterIndex < currentUserChapterIndex.value) {
- pageShiftRequest.tryEmit(difference)
+ val currentUserChapter = currentUserChapterIndex.value
+ 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.")
- pageCache.put(chapterIndex, pages)
- Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
-
applyPageRuntimeIndexes(chapterIndex, pages)
savePageCacheAsync(chapter, chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size)
+ pageCache.put(chapterIndex, pages)
+ Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
return pages
}
@@ -1105,12 +1197,68 @@ class BookPaginator(
return Jsoup.parse(htmlToParse).body().text()
}
- private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
- if (targetChapterIndex <= 0) {
- return 0
+ suspend fun findStablePageForAnchor(chapterIndex: Int, anchor: String?): Int? = withContext(Dispatchers.IO) {
+ Timber.tag("TOC_NAV_DEBUG").d("Stable precision nav request for anchor: '$anchor'")
+
+ if (anchor.isNullOrBlank()) {
+ return@withContext findStableChapterStartPage(chapterIndex)
}
- val startIndex = chapterStartPageIndices[targetChapterIndex] ?: 0
- return startIndex
+
+ // 1. QUICK LOOKUP: Check the Anchor Index first
+ val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
+
+ val (targetChapter, targetBlock) = if (indexEntry != null) {
+ Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
+ indexEntry.chapterIndex to indexEntry.blockIndex
+ } else {
+ Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
+ chapterIndex to null
+ }
+
+ // 2. ENSURE PAGINATION: Get pages for the determined chapter
+ val chapterPages = ensureChapterPaginated(targetChapter)
+ val chapterStartPage = ensureStableStartPageForChapter(targetChapter)
+
+ if (chapterPages == null || chapterStartPage == null) {
+ Timber.e("Anchor navigation failed: Could not stabilize target chapter $targetChapter.")
+ return@withContext null
+ }
+
+ val indexedPageInChapter = targetBlock?.let { blockIndex ->
+ chapterPageNavigationIndex[targetChapter]
+ ?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
+ ?.pageInChapter
+ } ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
+
+ if (indexedPageInChapter != null) {
+ val finalPage = chapterStartPage + indexedPageInChapter
+ Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
+ return@withContext finalPage
+ }
+
+ // 3. FIND PAGE
+ var targetPageInChapter = 0
+ var found = false
+
+ for ((pageIndex, page) in chapterPages.withIndex()) {
+ val isMatch = if (targetBlock != null) {
+ // Fast path: We know exactly which block we are looking for
+ page.content.any { it.blockIndex == targetBlock }
+ } else {
+ // Slow path: Linear ID scan (fallback)
+ page.content.any { containsAnchor(it, anchor) }
+ }
+
+ if (isMatch) {
+ targetPageInChapter = pageIndex
+ found = true
+ break
+ }
+ }
+
+ val finalPage = chapterStartPage + targetPageInChapter
+ Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
+ finalPage
}
override fun findPageForAnchor(
@@ -1119,70 +1267,8 @@ class BookPaginator(
onResult: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
- Timber.tag("TOC_NAV_DEBUG").d("Precision nav request for anchor: '$anchor'")
-
- if (anchor.isNullOrBlank()) {
- val start = chapterStartPageIndices[chapterIndex] ?: 0
- withContext(Dispatchers.Main) { onResult(start) }
- return@launch
- }
-
- // 1. QUICK LOOKUP: Check the Anchor Index first
- val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
-
- val (targetChapter, targetBlock) = if (indexEntry != null) {
- Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
- indexEntry.chapterIndex to indexEntry.blockIndex
- } else {
- Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
- chapterIndex to null
- }
-
- // 2. ENSURE PAGINATION: Get pages for the determined chapter
- val chapterPages = pageCache[targetChapter] ?: paginateChapter(targetChapter)
- val chapterStartPage = chapterStartPageIndices[targetChapter] ?: 0
-
- if (chapterPages == null) {
- withContext(Dispatchers.Main) { onResult(chapterStartPage) }
- return@launch
- }
-
- val indexedPageInChapter = targetBlock?.let { blockIndex ->
- chapterPageNavigationIndex[targetChapter]
- ?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
- ?.pageInChapter
- } ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
-
- if (indexedPageInChapter != null) {
- val finalPage = chapterStartPage + indexedPageInChapter
- Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
- withContext(Dispatchers.Main) { onResult(finalPage) }
- return@launch
- }
-
- // 3. FIND PAGE
- var targetPageInChapter = 0
- var found = false
-
- for ((pageIndex, page) in chapterPages.withIndex()) {
- val isMatch = if (targetBlock != null) {
- // Fast path: We know exactly which block we are looking for
- page.content.any { it.blockIndex == targetBlock }
- } else {
- // Slow path: Linear ID scan (fallback)
- page.content.any { containsAnchor(it, anchor) }
- }
-
- if (isMatch) {
- targetPageInChapter = pageIndex
- found = true
- break
- }
- }
-
- val finalPage = chapterStartPage + targetPageInChapter
- Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
- withContext(Dispatchers.Main) { onResult(finalPage) }
+ val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
+ withContext(Dispatchers.Main) { onResult(page) }
}
}
@@ -1234,69 +1320,79 @@ class BookPaginator(
onNavigationComplete: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
- Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
-
- val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
- if (targetChapterPath == null) {
- Timber.w("Could not resolve href '$href' to a valid chapter path.")
- return@launch
- }
-
- val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
- if (targetChapterIndex == -1) {
- Timber.w("Could not find chapter for path: $targetChapterPath")
- return@launch
- }
-
- findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
+ 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'")
+
+ val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
+ if (targetChapterPath == null) {
+ Timber.w("Could not resolve href '$href' to a valid chapter path.")
+ return@withContext null
+ }
+
+ val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
+ if (targetChapterIndex == -1) {
+ Timber.w("Could not find chapter for path: $targetChapterPath")
+ return@withContext null
+ }
+
+ findStablePageForAnchor(targetChapterIndex, anchor)
+ }
+
+ suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
+ val targetChapterIndex = result.locationInSource
+ Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
+
+ val chapterPages = ensureChapterPaginated(targetChapterIndex)
+ val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
+
+ if (chapterPages == null || chapterStartPage == null) {
+ Timber.e("Search result navigation failed: Could not stabilize target chapter $targetChapterIndex.")
+ return@withContext null
+ }
+
+ var targetPageInChapter = 0
+ var occurrenceCount = 0
+
+ pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
+ for (block in page.content) {
+ val textToSearch = when (block) {
+ is ParagraphBlock -> block.content.text
+ is HeaderBlock -> block.content.text
+ is QuoteBlock -> block.content.text
+ is ListItemBlock -> block.content.text
+ else -> null
+ }
+
+ if (textToSearch != null) {
+ var lastIndex = -1
+ while (true) {
+ lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
+ if (lastIndex == -1) break
+
+ if (occurrenceCount == result.occurrenceIndexInLocation) {
+ targetPageInChapter = pageIndex
+ Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
+ break@pageLoop
+ }
+ occurrenceCount++
+ }
+ }
+ }
+ }
+ val finalPageIndex = chapterStartPage + targetPageInChapter
+ Timber.i("Search result found. Final page index: $finalPageIndex")
+ finalPageIndex
+ }
+
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
coroutineScope.launch(Dispatchers.IO) {
- val targetChapterIndex = result.locationInSource
- Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
-
- val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
- val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
-
- if (chapterPages == null) {
- Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
- return@launch
- }
-
- var targetPageInChapter = 0
- var occurrenceCount = 0
-
- pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
- for (block in page.content) {
- val textToSearch = when (block) {
- is ParagraphBlock -> block.content.text
- is HeaderBlock -> block.content.text
- is QuoteBlock -> block.content.text
- is ListItemBlock -> block.content.text
- else -> null
- }
-
- if (textToSearch != null) {
- var lastIndex = -1
- while (true) {
- lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
- if (lastIndex == -1) break
-
- if (occurrenceCount == result.occurrenceIndexInLocation) {
- targetPageInChapter = pageIndex
- Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
- break@pageLoop
- }
- occurrenceCount++
- }
- }
- }
- }
- val finalPageIndex = chapterStartPage + targetPageInChapter
- Timber.i("Search result found. Final page index: $finalPageIndex")
- withContext(Dispatchers.Main) { onResult(finalPageIndex) }
+ 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): Int? {
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]
?.firstOrNull { range ->
range.blockIndex == locator.blockIndex &&
@@ -1352,17 +1436,15 @@ class BookPaginator(
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
}
?.let { range ->
- val finalPageIndex = chapterStartPage + range.pageInChapter
- Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex")
- return finalPageIndex
+ Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND via runtime index on pageInChapter ${range.pageInChapter}")
+ return range.pageInChapter
}
chapterPageNavigationIndex[targetChapterIndex]
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.let { entry ->
- val finalPageIndex = chapterStartPage + entry.pageInChapter
- Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex")
- return finalPageIndex
+ Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Using block-range fallback pageInChapter ${entry.pageInChapter}")
+ return entry.pageInChapter
}
var fallbackPageInChapter = -1
@@ -1370,7 +1452,7 @@ class BookPaginator(
for ((pageIndex, page) in chapterPages.withIndex()) {
val allTextBlocks = getAllTextBlocks(page.content)
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) {
if (textBlock.blockIndex == locator.blockIndex) {
@@ -1384,15 +1466,13 @@ class BookPaginator(
val isInside = locator.charOffset in startOffsetOnPage..()
@@ -59,11 +60,14 @@ class MathMLRenderer(private val context: Context) {
init {
handler.post {
- setupWebView()
+ if (!isDestroyed) {
+ setupWebView()
+ }
}
}
suspend fun awaitReady(): Boolean {
+ if (isDestroyed) return false
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
return withTimeoutOrNull(10_000) {
readySignal.await()
@@ -76,9 +80,7 @@ class MathMLRenderer(private val context: Context) {
private fun setupWebView() {
try {
- if (BuildConfig.DEBUG) {
- WebView.setWebContentsDebuggingEnabled(true)
- }
+ if (isDestroyed) return
webView = WebView(context).apply {
@SuppressLint("SetJavaScriptEnabled")
@@ -114,6 +116,9 @@ class MathMLRenderer(private val context: Context) {
}
suspend fun render(mathML: String, originalAltText: String): RenderResult {
+ if (isDestroyed) {
+ return RenderResult.Failure(originalAltText)
+ }
if (!awaitReady()) {
Timber.e("WebView is not available or failed to initialize. Failing render.")
return RenderResult.Failure(originalAltText)
@@ -140,6 +145,10 @@ class MathMLRenderer(private val context: Context) {
}
private fun processNextJob() {
+ if (isDestroyed) {
+ isProcessing = false
+ return
+ }
synchronized(jobQueue) {
if (jobQueue.isEmpty()) {
isProcessing = false
@@ -153,6 +162,10 @@ class MathMLRenderer(private val context: Context) {
}
private fun executeRender() {
+ if (isDestroyed) {
+ isProcessing = false
+ return
+ }
if (!isMathJaxReady) {
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
handler.postDelayed({ executeRender() }, 100)
@@ -208,14 +221,43 @@ class MathMLRenderer(private val context: Context) {
}
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 {
- webView?.destroy()
+ webView?.releaseMathRendererResources()
webView = null
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 {
Timber.e("onSvgReady FAILURE. Received empty SVG.")
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))
}
}
@@ -244,4 +286,4 @@ class MathMLRenderer(private val context: Context) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
index e8d8f4b..30d0293 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
@@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
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 TAG_STABLE_PAGE_NAV = "StablePageNav"
+private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
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 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? {
+ 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, second: List): 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(
@@ -729,6 +756,7 @@ fun PaginatedReaderScreen(
effectiveText: Color,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
+ isRightToLeftPagination: Boolean = false,
searchQuery: String,
fontSizeMultiplier: Float,
lineHeightMultiplier: Float,
@@ -741,6 +769,9 @@ fun PaginatedReaderScreen(
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
fallbackLocatorForReconfiguration: Locator? = null,
+ explicitNavigationAnchor: Locator? = null,
+ explicitNavigationEpoch: Long = 0L,
+ isExternalNavigationInProgress: Boolean = false,
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
onPaginatorReady: (IPaginator) -> Unit,
@@ -754,6 +785,7 @@ fun PaginatedReaderScreen(
onStartTtsFromSelection: (String, Int) -> Unit,
onNoteRequested: (String?) -> Unit,
onFootnoteRequested: (String) -> Unit,
+ onInternalLinkNavigated: (Int) -> Unit = {},
userHighlights: List,
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
@@ -784,6 +816,11 @@ fun PaginatedReaderScreen(
} else Modifier
var isNavigatingByLink by remember { mutableStateOf(false) }
+ var localExplicitNavigationAnchor by remember { mutableStateOf(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)) {
val textMeasurer = rememberTextMeasurer()
@@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount ->
- val anchor = resolvePaginatedReconfigurationAnchor(
- currentPageLocator = anchorLocatorForReconfig,
- fallbackLocator = latestFallbackLocatorForReconfiguration
+ if (pagerState.pageCount <= 0) {
+ Timber.tag(TAG_STABLE_PAGE_NAV)
+ .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 ->
- (paginator as? BookPaginator)?.findPageForLocator(locator)
+ bookPaginator?.findStablePageForLocator(locator)
}
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)
paginator.onUserScrolledTo(resolvedPage)
} 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)
paginator.onUserScrolledTo(newPage)
}
@@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen(
uiState = uiState,
pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
+ isRightToLeftPagination = isRightToLeftPagination,
effectiveBg = effectiveBg,
searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo,
@@ -1163,100 +1278,126 @@ fun PaginatedReaderScreen(
}
}
},
+ onInternalLinkNavigated = onInternalLinkNavigated,
onLinkClick = { currentChapterPath, href, onNavComplete ->
coroutineScope.launch(Dispatchers.IO) {
- isNavigatingByLink = true
- var isFootnote = false
- var footnoteHtml: String? = null
+ withContext(Dispatchers.Main) { isNavigatingByLink = true }
+ try {
+ var isFootnote = false
+ var footnoteHtml: String? = null
- val sourceChapter =
- book.chaptersForPagination.find { it.absPath == currentChapterPath }
- if (sourceChapter != null) {
- val sourceHtml = sourceChapter.htmlContent.ifEmpty {
- try {
- File(book.extractionBasePath, sourceChapter.htmlFilePath)
- .readText()
- } catch (_: Exception) {
- ""
- }
- }
- if (sourceHtml.isNotEmpty()) {
- val doc = Jsoup.parse(sourceHtml)
- val safeHref = href.replace("\"", "\\\"")
- val aTag = doc.select("a[href=\"$safeHref\"]").first()
-
- if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) {
- isFootnote = true
- }
- } else if (href.startsWith("#")) {
- isFootnote = true
- }
- } else if (href.startsWith("#")) {
- isFootnote = true
- }
-
- if (isFootnote) {
- val decodedHref = try {
- URLDecoder.decode(href, "UTF-8")
- } catch (_: Exception) {
- href
- }
- val parts = decodedHref.split('#', limit = 2)
- val pathPart = parts[0]
- val anchor = if (parts.size > 1) parts[1] else null
-
- if (anchor != null) {
- val targetPath = if (pathPart.isBlank()) currentChapterPath else {
+ val sourceChapter =
+ book.chaptersForPagination.find { it.absPath == currentChapterPath }
+ if (sourceChapter != null) {
+ val sourceHtml = sourceChapter.htmlContent.ifEmpty {
try {
- URI(currentChapterPath).resolve(pathPart)
- .normalize().path
+ File(book.extractionBasePath, sourceChapter.htmlFilePath)
+ .readText()
} catch (_: Exception) {
- null
+ ""
}
}
+ if (sourceHtml.isNotEmpty()) {
+ val doc = Jsoup.parse(sourceHtml)
+ val safeHref = href.replace("\"", "\\\"")
+ val aTag = doc.select("a[href=\"$safeHref\"]").first()
- if (targetPath != null) {
- val targetChapter = book.chaptersForPagination.find {
+ 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
+ }
+ }
+ }
+
+ run {
+ val decodedHref = try {
+ URLDecoder.decode(href, "UTF-8")
+ } catch (_: Exception) {
+ href
+ }
+ val parts = decodedHref.split('#', limit = 2)
+ val pathPart = parts[0]
+ val anchor = if (parts.size > 1) parts[1] else null
+
+ if (anchor != null) {
+ val targetPath = if (pathPart.isBlank()) currentChapterPath else {
try {
- URI(it.absPath).normalize().path == targetPath
+ URI(currentChapterPath).resolve(pathPart)
+ .normalize().path
} catch (_: Exception) {
- false
+ null
}
}
- if (targetChapter != null) {
- val targetHtml = targetChapter.htmlContent.ifEmpty {
+ if (targetPath != null) {
+ val targetChapter = book.chaptersForPagination.find {
try {
- File(
- book.extractionBasePath,
- targetChapter.htmlFilePath
- ).readText()
+ URI(it.absPath).normalize().path == targetPath
} catch (_: Exception) {
- ""
+ false
}
}
- if (targetHtml.isNotEmpty()) {
- val doc = Jsoup.parse(targetHtml)
- val noteEl = doc.getElementById(anchor)
- if (noteEl != null) {
- footnoteHtml = noteEl.html()
+
+ if (targetChapter != null) {
+ val targetHtml = targetChapter.htmlContent.ifEmpty {
+ try {
+ File(
+ book.extractionBasePath,
+ targetChapter.htmlFilePath
+ ).readText()
+ } catch (_: Exception) {
+ ""
+ }
+ }
+ if (targetHtml.isNotEmpty()) {
+ val doc = Jsoup.parse(targetHtml)
+ val noteEl = doc.getElementById(anchor)
+ 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()
+ }
+ }
}
}
}
}
}
- }
- withContext(Dispatchers.Main) {
if (!footnoteHtml.isNullOrBlank()) {
- onFootnoteRequested(footnoteHtml)
- isNavigatingByLink = false
+ withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
} else {
- paginator.navigateToHref(currentChapterPath, href) {
- onNavComplete(it)
- isNavigatingByLink = false
+ val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href)
+ withContext(Dispatchers.Main) {
+ 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 }
}
}
},
@@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean =
return null
}
-private fun getHighlightOffsetsInBlock(
+internal fun getHighlightOffsetsInBlock(
block: TextContentBlock, highlight: UserHighlight
): IntRange? {
if (block.cfi == null) return null
@@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock(
val parts = highlight.cfi.split('|')
val startCfi = parts.firstOrNull() ?: highlight.cfi
val endCfi = parts.lastOrNull()
+ val isMultipartHighlight = endCfi != null && endCfi != startCfi
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
is ParagraphBlock -> block.startCharOffsetInSource
@@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock(
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
+ val blockEndAbs = block.endCharOffsetInSource
+ .takeIf { it > blockStartAbs }
+ ?: (blockStartAbs + block.content.text.length)
Timber.d(
"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 highlightText = highlight.text
if (blockText.isEmpty() || highlightText.isEmpty()) return null
- if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.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)
+ 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 = true)) return 0 until blockText.length
+ val normBlock = blockText.filter { !it.isWhitespace() }
+ val normHighlight = highlightText.filter { !it.isWhitespace() }
+ return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
+ 0 until blockText.length
+ } else {
+ null
+ }
}
if (relevantPart != null) {
@@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock(
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$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 e = blockText.length
if (startMatches) {
- val absOffset = CfiUtils.getOffset(startCfi)
+ val absOffset = startAbs ?: CfiUtils.getOffset(startCfi)
val relOffset = absOffset - blockStartAbs
if (relOffset < 0) {
@@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock(
}
if (endMatches) {
- val absOffset = CfiUtils.getOffset(endCfi!!)
+ val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
val relOffset = absOffset - blockStartAbs
Timber.d(
@@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock(
}
}
- if (startIndex >= 0) {
- return startIndex until (startIndex + highlightText.length)
+ if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.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) {
- @Suppress("KotlinConstantConditions") if (isAfterStart) {
- val normBlock = blockText.filter { !it.isWhitespace() }
- val normHighlight = highlightText.filter { !it.isWhitespace() }
- if (normHighlight.contains(normBlock, ignoreCase = true)) {
- return 0 until blockText.length
- }
- }
+ if (startIndex >= 0) {
+ return startIndex until (startIndex + highlightText.length)
}
val match = findFuzzyMatch(blockText, highlightText)
@@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
+ isRightToLeftPagination: Boolean = false,
effectiveBg: Color,
effectiveText: Color,
searchQuery: String,
@@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent(
onGetPage: (Int) -> Page?,
onGetChapterPath: (Int) -> String?,
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
+ onInternalLinkNavigated: (Int) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
isOss: Boolean,
@@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent(
}
}
},
- beyondViewportPageCount = 1
+ beyondViewportPageCount = 1,
+ reverseLayout = isRightToLeftPagination
) { pageIndex ->
val pageOffset =
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
@@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent(
} else {
currentChapterPath?.let { path ->
onLinkClick(path, href) { targetPageIndex ->
+ onInternalLinkNavigated(targetPageIndex)
coroutineScope.launch {
+ Timber.tag(TAG_STABLE_PAGE_NAV).d(
+ "link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}"
+ )
pagerState.scrollToPage(targetPageIndex)
}
}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt
new file mode 100644
index 0000000..eef2832
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/ReaderLinkStyle.kt
@@ -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()
+ 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
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt b/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt
index 9bfccdc..6b830ea 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt
@@ -118,6 +118,17 @@ private fun AnnotatedString.applyReaderThemeForDisplay(
}
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
+ )
+ }
}
}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt b/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt
new file mode 100644
index 0000000..ae7de84
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/StablePaginatedNavigation.kt
@@ -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
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt b/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
index ae9e2f7..e2612c9 100644
--- a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
@@ -20,6 +20,7 @@
package com.aryan.reader.pdf
import android.graphics.Rect
+import android.graphics.RectF
import timber.log.Timber
import androidx.compose.foundation.Canvas
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.shadow
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
@@ -43,17 +45,114 @@ import androidx.compose.ui.unit.dp
import kotlin.math.max
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
fun MagnifierComposable(
sourceBitmap: ImageBitmap,
tiles: List,
currentScale: Float,
magnifierCenterOnBitmap: Offset,
+ contentWidthPx: Int = sourceBitmap.width,
+ contentHeightPx: Int = sourceBitmap.height,
modifier: Modifier = Modifier,
magnifierWidth: Dp = 120.dp,
magnifierHeight: Dp = 60.dp,
zoomFactor: Float = 1.5f,
- selectionRectsInBitmapCoords: List,
+ selectionRectsInContentCoords: List,
highlightColor: Color,
colorFilter: ColorFilter? = null
) {
@@ -81,155 +180,71 @@ fun MagnifierComposable(
}
} else null
- if (relevantTile != null) {
- // --- HIGH-RES TILE PATH ---
+ val bitmapToUse: ImageBitmap
+ val contentSource: MagnifierContentSource
+ if (relevantTile != null && !relevantTile.bitmap.isRecycled) {
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}")
- val bitmapToUse = relevantTile.bitmap.asImageBitmap()
-
- val tileBitmapWidth = relevantTile.bitmap.width.toFloat()
- val tileRenderRectWidth = relevantTile.renderRect.width().toFloat()
-
- val tileScale = if (tileRenderRectWidth > 0) {
- tileBitmapWidth / tileRenderRectWidth
- } else {
- 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
+ bitmapToUse = relevantTile.bitmap.asImageBitmap()
+ contentSource = MagnifierContentSource(
+ sourceWidth = bitmapToUse.width,
+ sourceHeight = bitmapToUse.height,
+ contentLeft = relevantTile.renderRect.left.toFloat(),
+ contentTop = relevantTile.renderRect.top.toFloat(),
+ contentWidth = relevantTile.renderRect.width().toFloat(),
+ contentHeight = relevantTile.renderRect.height().toFloat()
)
-
- Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap")
-
- val sourceRectWidth = magnifierWidthPx / zoomFactor
- val sourceRectHeight = magnifierHeightPx / zoomFactor
- Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
-
- val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f)
- val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
- Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
-
- 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
- }
-
- drawImage(
- image = bitmapToUse,
- srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
- srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
- dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
- colorFilter = colorFilter
- )
-
- selectionRectsInBitmapCoords.forEach { rectInBitmap ->
- val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale
- val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale
- val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale
- val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale
-
- 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(
- color = highlightColor,
- topLeft = Offset(magnifiedLeft, magnifiedTop),
- size = androidx.compose.ui.geometry.Size(
- width = magnifiedRight - magnifiedLeft,
- height = magnifiedBottom - magnifiedTop
- )
- )
- }
- }
-
} 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
+ bitmapToUse = sourceBitmap
+ contentSource = MagnifierContentSource(
+ sourceWidth = sourceBitmap.width,
+ sourceHeight = sourceBitmap.height,
+ contentLeft = 0f,
+ contentTop = 0f,
+ contentWidth = contentWidthPx.toFloat(),
+ contentHeight = contentHeightPx.toFloat()
)
+ }
- selectionRectsInBitmapCoords.forEach { rectInBitmap ->
- val translatedLeft = rectInBitmap.left - clampedSrcLeft
- val translatedTop = rectInBitmap.top - clampedSrcTop
- val rectWidthInBitmap = rectInBitmap.width().toFloat()
- val rectHeightInBitmap = rectInBitmap.height().toFloat()
+ val sample = calculateMagnifierSampleGeometry(
+ centerContentX = magnifierCenterOnBitmap.x,
+ centerContentY = magnifierCenterOnBitmap.y,
+ contentSource = contentSource,
+ magnifierWidthPx = magnifierWidthPx,
+ magnifierHeightPx = magnifierHeightPx,
+ zoomFactor = zoomFactor
+ ) ?: run {
+ Timber.w("Magnifier: Source geometry is invalid, returning.")
+ return@Canvas
+ }
+ Timber.d("Magnifier: Final source rect offset=(${sample.srcLeft}, ${sample.srcTop}), size=${sample.srcWidth}x${sample.srcHeight}")
- val magnifiedLeft = translatedLeft * zoomFactor
- val magnifiedTop = translatedTop * zoomFactor
- val magnifiedWidth = rectWidthInBitmap * zoomFactor
- val magnifiedHeight = rectHeightInBitmap * zoomFactor
+ drawImage(
+ image = bitmapToUse,
+ srcOffset = IntOffset(sample.srcLeft, sample.srcTop),
+ srcSize = IntSize(sample.srcWidth, sample.srcHeight),
+ dstSize = IntSize(
+ magnifierWidthPx.roundToInt().coerceAtLeast(1),
+ magnifierHeightPx.roundToInt().coerceAtLeast(1)
+ ),
+ colorFilter = colorFilter
+ )
- 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
- )
+ selectionRectsInContentCoords.forEach { contentRect ->
+ val magnifierRect = mapContentRectToMagnifier(contentRect, contentSource, sample)
+ if (magnifierRect.width() > 0f && magnifierRect.height() > 0f &&
+ magnifierRect.right > 0f && magnifierRect.left < magnifierWidthPx &&
+ magnifierRect.bottom > 0f && magnifierRect.top < magnifierHeightPx
+ ) {
+ drawRect(
+ color = highlightColor,
+ topLeft = Offset(magnifierRect.left, magnifierRect.top),
+ size = Size(
+ width = magnifierRect.width(),
+ height = magnifierRect.height()
)
- }
+ )
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt b/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt
new file mode 100644
index 0000000..6c19bcd
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfBubblePrefetch.kt
@@ -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 {
+ if (totalPages <= 0 || radius < 0) return emptyList()
+
+ val clampedCurrentPage = currentPage.coerceIn(0, totalPages - 1)
+ val ordered = LinkedHashSet()
+ 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()
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt
index 3f7f9f9..cc916dd 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt
@@ -21,8 +21,14 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.FileInputStream
import java.io.FileOutputStream
+import kotlin.math.roundToInt
+import kotlin.math.sqrt
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 {
val core: PdfiumCoreKt by lazy {
PdfiumCoreKt(Dispatchers.Default)
@@ -31,7 +37,7 @@ object PdfiumCoreProvider {
internal data class DocumentCacheItem(
val doc: ReaderDocument,
- val pfd: ParcelFileDescriptor,
+ val pfd: ParcelFileDescriptor?,
val totalPages: Int,
val pageAspectRatios: List,
val flatTableOfContents: List
@@ -48,7 +54,7 @@ internal class DocumentCache(val maxSize: Int = 3) {
if (evicted) {
CoroutineScope(Dispatchers.IO).launch {
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)
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 =
- page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat()
+ pageWidth.toFloat() / pageHeight.toFloat()
if (aspectRatio.isNaN() || aspectRatio <= 0) {
Timber.e("Invalid aspect ratio for page $pageIndex")
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) {
Timber.e("Invalid calculated bitmap height for page $pageIndex")
@@ -191,4 +217,4 @@ internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bi
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt
index ba584a4..a6f6a3d 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt
@@ -76,6 +76,7 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray
import timber.log.Timber
import androidx.core.graphics.createBitmap
+import com.aryan.reader.pdf.data.VirtualPage
private const val MAX_FIXED_RECURSION = 128
@@ -281,6 +282,7 @@ internal fun PdfTocTreeItem(
@Composable
internal fun PdfNavigationDrawerContent(
pdfDocument: ReaderDocument?,
+ documentKey: String,
flatTableOfContents: List,
bookmarks: Set,
userHighlights: List,
@@ -859,13 +861,16 @@ internal fun PdfNavigationDrawerContent(
},
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) {
withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
- val cached = PdfThumbnailCache.get(pageIdx)
+ val cached = PdfThumbnailCache.get(thumbPageId)
if (cached != null) {
thumb = cached
} else {
@@ -878,7 +883,7 @@ internal fun PdfNavigationDrawerContent(
val bmp = createBitmap(thumbW, thumbH)
bmp.eraseColor(android.graphics.Color.WHITE)
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
- PdfThumbnailCache.put(pageIdx, bmp)
+ PdfThumbnailCache.put(thumbPageId, bmp)
thumb = bmp
}
}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
index 7acfc95..def8f77 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
@@ -123,6 +123,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set
import com.aryan.reader.R
import com.aryan.reader.SearchResult
+import com.aryan.reader.isCanvasSafeBitmap
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
@@ -135,7 +136,6 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
@@ -171,6 +171,47 @@ enum class InkType {
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
}
+internal fun shouldReportPdfPageCamera(
+ isZoomEnabled: Boolean,
+ isVerticalScroll: Boolean,
+ isScrollLocked: Boolean,
+ lockedState: Triple?,
+ hasAppliedLockedState: Boolean
+): Boolean {
+ return !isZoomEnabled ||
+ isVerticalScroll ||
+ !isScrollLocked ||
+ lockedState == null ||
+ hasAppliedLockedState
+}
+
+internal fun initialPdfPageCamera(
+ isZoomEnabled: Boolean,
+ isVerticalScroll: Boolean,
+ isScrollLocked: Boolean,
+ lockedState: Triple?
+): Pair {
+ 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(
val index: 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)
+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_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_IDLE_RENDER_DELAY_MS = 90L
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
@@ -255,17 +313,22 @@ private suspend fun renderExpandedBubbleBitmap(
}
document.openPage(pageIndex)?.use { page ->
- val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
- val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
+ val safeRenderScale = safePdfBitmapRenderScale(
+ 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)
try {
page.renderPageBitmap(
bitmap = bitmap,
- startX = (-bubbleBounds.left * renderScale).roundToInt(),
- startY = (-bubbleBounds.top * renderScale).roundToInt(),
- drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
- drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
+ startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
+ startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
+ drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
+ drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight),
renderAnnot = true
)
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 {
fun calculateFountainPenPoints(
points: List, baseWidth: Float, pageWidth: Float, pageHeight: Float
@@ -380,16 +460,15 @@ internal object PdfBitmapPool {
fun get(size: Int): Bitmap = get(size, size)
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) {
pool.offer(bitmap)
- } else {
- bitmap.recycle()
}
}
fun clear() {
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 val memoryCache = object : LruCache(cacheSize) {
- override fun sizeOf(key: Int, entry: CacheEntry): Int {
+ private val memoryCache = object : LruCache(cacheSize) {
+ override fun sizeOf(key: String, entry: CacheEntry): Int {
return entry.sizeKb
}
}
- fun get(pageIndex: Int): Bitmap? {
- return memoryCache.get(pageIndex)?.bitmap?.takeUnless { it.isRecycled }
+ fun get(pageId: String): Bitmap? {
+ return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
}
- fun put(pageIndex: Int, bitmap: Bitmap) {
- if (get(pageIndex) == null) {
+ fun put(pageId: String, bitmap: Bitmap) {
+ if (get(pageId) == null) {
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
internal fun PdfPageComposable(
pdfDocument: StableHolder,
+ documentKey: String,
pageIndex: Int,
totalPages: Int,
modifier: Modifier = Modifier,
@@ -507,6 +587,7 @@ internal fun PdfPageComposable(
isScrolling: Boolean = false,
lazyListState: LazyListState? = null,
isVerticalScroll: Boolean = false,
+ showPageNumberOverlay: Boolean = true,
visualScaleProvider: () -> Float = { 1f },
clearSelectionTrigger: Long = 0L,
resetZoomTrigger: Long = 0L,
@@ -558,18 +639,13 @@ internal fun PdfPageComposable(
onShowPanelPopup: (Bitmap) -> Unit = {}
) {
val pdfDocumentItem = pdfDocument.item
- var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
- var currentRenderedPageId by remember { mutableStateOf(null) }
-
- val targetPageId = remember(virtualPage, pageIndex) {
- when (virtualPage) {
- is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
- is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
- null -> "PDF_$pageIndex"
- }
+ val targetPageId = remember(documentKey, virtualPage, pageIndex) {
+ pdfRenderPageId(documentKey, pageIndex, virtualPage)
}
- var isLoadingPage by remember { mutableStateOf(true) }
- var pageErrorMessage by remember { mutableStateOf(null) }
+ var bitmapState by remember(targetPageId) { mutableStateOf(PdfThumbnailCache.get(targetPageId)) }
+ var currentRenderedPageId by remember(targetPageId) { mutableStateOf(null) }
+ var isLoadingPage by remember(targetPageId) { mutableStateOf(true) }
+ var pageErrorMessage by remember(targetPageId) { mutableStateOf(null) }
val density = LocalDensity.current
val context = LocalContext.current
val viewConfiguration = LocalViewConfiguration.current
@@ -581,12 +657,30 @@ internal fun PdfPageComposable(
var ocrRipplePosition by remember { mutableStateOf(null) }
var isTransforming by remember { mutableStateOf(false) }
- var scale by remember { mutableFloatStateOf(1f) }
- var offset by remember { mutableStateOf(Offset.Zero) }
+ val initialCamera = initialPdfPageCamera(
+ 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(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) {
- onZoomAndPanChanged?.invoke(scale, offset)
+ LaunchedEffect(scale, offset, shouldReportCamera) {
+ if (shouldReportCamera) {
+ onZoomAndPanChanged?.invoke(scale, offset)
+ }
}
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
@@ -609,7 +703,7 @@ internal fun PdfPageComposable(
val isPdfPage = virtualPage == null || virtualPage is VirtualPage.PdfPage
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
- var tiles by remember { mutableStateOf>(emptyList()) }
+ var tiles by remember(targetPageId) { mutableStateOf>(emptyList()) }
val tileSizeDp = PDF_TILE_SIZE_DP.dp
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
@@ -645,15 +739,15 @@ internal fun PdfPageComposable(
}
}
- val selectionCharRange = remember { mutableStateOf?>(null) }
- var activeDraggingHandle by remember { mutableStateOf(null) }
- var selectedWordScreenRects by remember { mutableStateOf>(emptyList()) }
- val startHandleContentPosition = remember { mutableStateOf(null) }
- val endHandleContentPosition = remember { mutableStateOf(null) }
+ val selectionCharRange = remember(targetPageId) { mutableStateOf?>(null) }
+ var activeDraggingHandle by remember(targetPageId) { mutableStateOf(null) }
+ var selectedWordScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) }
+ val startHandleContentPosition = remember(targetPageId) { mutableStateOf(null) }
+ val endHandleContentPosition = remember(targetPageId) { mutableStateOf(null) }
- var actualBitmapWidthPx by remember { mutableIntStateOf(0) }
- var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
- var currentPageRotation by remember { mutableIntStateOf(0) }
+ var actualBitmapWidthPx by remember(targetPageId) { mutableIntStateOf(0) }
+ var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
+ var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
@@ -736,7 +830,7 @@ internal fun PdfPageComposable(
var magnifierBitmapCenterTarget by remember { mutableStateOf(Offset.Zero) }
val magnifierZoomFactor = 2.0f
- var customMenuState by remember { mutableStateOf(null) }
+ var customMenuState by remember(targetPageId) { mutableStateOf(null) }
val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f
val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero
@@ -826,7 +920,15 @@ internal fun PdfPageComposable(
expandedBubbleIndex = -1
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
expandedBubbleRender = null
- if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
+ if (
+ shouldResetPdfZoomAfterBubbleZoomCleanup(
+ isBubbleZoomModeActive = isBubbleZoomModeActive,
+ scale = scale,
+ isVerticalScroll = isVerticalScroll,
+ isZoomEnabled = isZoomEnabled,
+ isScrollLocked = isScrollLocked
+ )
+ ) {
coroutineScope.launch {
Animatable(scale).animateTo(1f, tween(300)) {
scale = this.value
@@ -881,10 +983,10 @@ internal fun PdfPageComposable(
}
}
- DisposableEffect(Unit) {
+ DisposableEffect(targetPageId) {
onDispose {
val currentBitmap = bitmapState
- val cachedBitmap = PdfThumbnailCache.get(pageIndex)
+ val cachedBitmap = PdfThumbnailCache.get(targetPageId)
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
currentBitmap.recycle()
}
@@ -895,16 +997,16 @@ internal fun PdfPageComposable(
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
// OCR
- var ocrVisionTextForSelection by remember { mutableStateOf(null) }
+ var ocrVisionTextForSelection by remember(targetPageId) { mutableStateOf(null) }
var isPerformingOcrForSelection by remember { mutableStateOf(false) }
var selectionMethodUsed by remember { mutableStateOf(PdfSelectionMethod.PDFIUM) }
- var ocrSelectionSymbolIndices by remember { mutableStateOf?>(null) }
- var allOcrSymbolsForSelection by remember { mutableStateOf>(emptyList()) }
+ var ocrSelectionSymbolIndices by remember(targetPageId) { mutableStateOf?>(null) }
+ var allOcrSymbolsForSelection by remember(targetPageId) { mutableStateOf>(emptyList()) }
- var highlightedTextScreenRects by remember { mutableStateOf>(emptyList()) }
+ var highlightedTextScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) }
val ttsHighlightColor = Color(0xFFFFECB3).copy(alpha = 0.4f)
- var allTextPageHighlightRects by remember { mutableStateOf>(emptyList()) }
+ var allTextPageHighlightRects by remember(targetPageId) { mutableStateOf>(emptyList()) }
var accumulatedKeyboardOffset by remember { mutableFloatStateOf(0f) }
@@ -937,7 +1039,7 @@ internal fun PdfPageComposable(
val mergedSearchHighlightRects =
remember(searchHighlightRects) { mergeRectsIntoLines(searchHighlightRects) }
- var pageLinks by remember { mutableStateOf>(emptyList()) }
+ var pageLinks by remember(targetPageId) { mutableStateOf>(emptyList()) }
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
@@ -1091,9 +1193,9 @@ internal fun PdfPageComposable(
onHighlightLoading(false)
}
- @Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf>(emptyList()) }
- var standardAnnotScreenRects by remember { mutableStateOf>>(emptyList()) }
- var imageScreenRects by remember { mutableStateOf>(emptyList()) }
+ @Suppress("VariableNeverRead") var embeddedAnnotations by remember(targetPageId) { mutableStateOf>(emptyList()) }
+ var standardAnnotScreenRects by remember(targetPageId) { mutableStateOf>>(emptyList()) }
+ var imageScreenRects by remember(targetPageId) { mutableStateOf>(emptyList()) }
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
@@ -1216,84 +1318,80 @@ internal fun PdfPageComposable(
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)
- Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
- if (count > 0) {
- val allAnnots = (0 until count).mapNotNull { i ->
- val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
- if (subtype == annotLink) return@mapNotNull null
+ val allAnnots = (0 until count).mapNotNull { i ->
+ val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
+ if (subtype == annotLink) return@mapNotNull null
- var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
- if (contents.isNullOrBlank()) {
- contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
- }
-
- val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
- val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
- val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
-
- val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
- val pdfRectF = if (pdfRectArray != null) {
- android.graphics.RectF(
- min(pdfRectArray[0], pdfRectArray[2]),
- max(pdfRectArray[1], pdfRectArray[3]),
- max(pdfRectArray[0], pdfRectArray[2]),
- min(pdfRectArray[1], pdfRectArray[3])
- )
- } else android.graphics.RectF()
-
- EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
+ var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
+ if (contents.isNullOrBlank()) {
+ contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
}
- val annotMap = allAnnots.associateBy { it.name }
- val orphans = mutableListOf()
+ val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
+ val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
+ val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
- allAnnots.forEach { annot ->
- if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
- Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
- annotMap[annot.inReplyTo]?.replies?.add(annot)
- } else {
- orphans.add(annot)
- }
- }
-
- Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
-
- val groupedRoots = mutableListOf>()
- orphans.forEach { annot ->
- val match = groupedRoots.find { group ->
- val root = group.first()
- val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) }
- android.graphics.RectF.intersects(inflatedRoot, annot.rect)
- }
- if (match != null) {
- Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!")
- match.add(annot)
- } else {
- groupedRoots.add(mutableListOf(annot))
- }
- }
-
- val rootsWithReplies = groupedRoots.map { group ->
- val root = group.first()
- if (group.size > 1) {
- root.replies.addAll(group.drop(1))
- }
- root
- }
-
- finalDisplayList = rootsWithReplies.filter {
- !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() }
- }
-
- mappedAnnots = finalDisplayList.map { annot ->
- val screenRect = pageWrapper.mapRectToDevice(
- 0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
- currentPageRotation, annot.rect
+ val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
+ val pdfRectF = if (pdfRectArray != null) {
+ android.graphics.RectF(
+ min(pdfRectArray[0], pdfRectArray[2]),
+ max(pdfRectArray[1], pdfRectArray[3]),
+ max(pdfRectArray[0], pdfRectArray[2]),
+ min(pdfRectArray[1], pdfRectArray[3])
)
- annot to screenRect
+ } else android.graphics.RectF()
+
+ EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt)
+ }
+
+ val annotMap = allAnnots.associateBy { it.name }
+ val orphans = mutableListOf()
+
+ allAnnots.forEach { annot ->
+ if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
+ Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
+ annotMap[annot.inReplyTo]?.replies?.add(annot)
+ } else {
+ orphans.add(annot)
}
}
+
+ Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
+
+ val groupedRoots = mutableListOf>()
+ orphans.forEach { annot ->
+ val match = groupedRoots.find { group ->
+ val root = group.first()
+ val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) }
+ android.graphics.RectF.intersects(inflatedRoot, annot.rect)
+ }
+ if (match != null) {
+ Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!")
+ match.add(annot)
+ } else {
+ groupedRoots.add(mutableListOf(annot))
+ }
+ }
+
+ val rootsWithReplies = groupedRoots.map { group ->
+ val root = group.first()
+ if (group.size > 1) {
+ root.replies.addAll(group.drop(1))
+ }
+ root
+ }
+
+ finalDisplayList = rootsWithReplies.filter {
+ !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() }
+ }
+
+ mappedAnnots = finalDisplayList.map { annot ->
+ val screenRect = pageWrapper.mapRectToDevice(
+ 0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
+ currentPageRotation, annot.rect
+ )
+ annot to screenRect
+ }
}
} else {
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.")
val old = bitmapState
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>(emptyList()) }
- var searchAllRects by remember { mutableStateOf>(emptyList()) }
+ var searchFocusedRects by remember(targetPageId) { mutableStateOf>(emptyList()) }
+ var searchAllRects by remember(targetPageId) { mutableStateOf>(emptyList()) }
- var keyboardAdjustmentOriginalOffset by remember { mutableStateOf(null) }
+ var keyboardAdjustmentOriginalOffset by remember(targetPageId) { mutableStateOf(null) }
val mergedSearchFocusedRects = remember(searchFocusedRects) { searchFocusedRects }
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(
modifier = modifier
.onGloballyPositioned { layoutCoordinates = it }
@@ -2706,7 +2800,10 @@ internal fun PdfPageComposable(
Timber.e(
e, "Long press: Error during OCR text selection"
)
- pageErrorMessage = errorOcrSelection
+ pageErrorMessage = context.getString(
+ R.string.error_ocr_selection,
+ e.readablePdfErrorDetail()
+ )
} finally {
isPerformingOcrForSelection = false
ocrRipplePosition = null
@@ -2727,7 +2824,10 @@ internal fun PdfPageComposable(
e,
"Error during long press text selection on page $pageIndex"
)
- pageErrorMessage = errorSelection
+ pageErrorMessage = context.getString(
+ R.string.error_selection,
+ e.readablePdfErrorDetail()
+ )
customMenuState = null
selectionCharRange.value = null
selectedWordScreenRects = emptyList()
@@ -2774,7 +2874,8 @@ internal fun PdfPageComposable(
selectedTool,
isStylusOnlyMode,
userHighlightScreenRects,
- bubbleTapSlopPx
+ bubbleTapSlopPx,
+ isScrollLocked
) {
val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT ||
@@ -3040,7 +3141,7 @@ internal fun PdfPageComposable(
onScaleChanged(scale)
}
}
- } else if (isVerticalScroll && currentOnDoubleTap != null) {
+ } else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) {
currentOnDoubleTap!!(tapOffset)
}
})
@@ -3276,7 +3377,7 @@ internal fun PdfPageComposable(
val startOffset = offset
paginationPanFlingJob = coroutineScope.launch {
try {
- coroutineScope {
+ kotlinx.coroutines.coroutineScope {
launch {
if (flingX != 0f) {
Animatable(startOffset.x).animateDecay(flingX, decay) {
@@ -3543,15 +3644,37 @@ internal fun PdfPageComposable(
}
}
+ var previousLockedViewportSize by remember { mutableStateOf?>(null) }
+
LaunchedEffect(
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
isScrollLocked, lockedState
) {
- if (isScrollLocked && !isVerticalScroll && lockedState != null) {
- scale = lockedState.first
- offset = Offset(lockedState.second, lockedState.third)
+ 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
+ offset = Offset(lockedState.second, lockedState.third)
+ hasAppliedLockedPaginationState = true
+ } else {
+ hasAppliedLockedPaginationState = true
+ }
onScaleChanged(scale)
} else if (!isScrollLocked && !isVerticalScroll) {
+ hasAppliedLockedPaginationState = false
scale = 1f
offset = Offset.Zero
onScaleChanged(1f)
@@ -3699,10 +3822,12 @@ internal fun PdfPageComposable(
currentContainerMaxHeight,
density,
virtualPage,
+ targetPageId,
isVisible,
currentRenderedPageId
) {
if (!isVisible && !isVerticalScroll) return@LaunchedEffect
+ pageErrorMessage = null
val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() }
val viewContainerHeightPx =
@@ -3721,12 +3846,18 @@ internal fun PdfPageComposable(
1f / 1.414f
}
- var scaledWidth = viewContainerWidthPx
- var scaledHeight = (scaledWidth / pageAspect).toInt()
+ val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
+ viewContainerWidthPx to viewContainerHeightPx
+ } else {
+ var fittedWidth = viewContainerWidthPx
+ var fittedHeight = (fittedWidth / pageAspect).toInt()
- if (scaledHeight > viewContainerHeightPx) {
- scaledHeight = viewContainerHeightPx
- scaledWidth = (scaledHeight * pageAspect).toInt()
+ if (fittedHeight > viewContainerHeightPx) {
+ fittedHeight = viewContainerHeightPx
+ fittedWidth = (fittedHeight * pageAspect).toInt()
+ }
+
+ fittedWidth to fittedHeight
}
if (scaledWidth == actualBitmapWidthPx &&
@@ -3760,7 +3891,7 @@ internal fun PdfPageComposable(
val old = bitmapState
if (old != null && old !== finalBitmap) {
- if (old !== PdfThumbnailCache.get(pageIndex)) {
+ if (old !== PdfThumbnailCache.get(targetPageId)) {
old.recycle()
}
}
@@ -3771,7 +3902,7 @@ internal fun PdfPageComposable(
return@LaunchedEffect
}
- coroutineScope.launch {
+ kotlinx.coroutines.coroutineScope {
var localBitmap: Bitmap? = null
try {
val renderResult = withContext(Dispatchers.IO) {
@@ -3783,62 +3914,68 @@ internal fun PdfPageComposable(
return@withContext null
}
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
- val rotation = page.getPageRotation()
- val screenDpi = (density.density * 160).roundToInt()
- val originalWidthPdfUnits = page.getPageWidthPoint()
- val originalHeightPdfUnits = page.getPageHeightPoint()
+ try {
+ val rotation = page.getPageRotation()
+ val originalWidthPdfUnits = page.getPageWidthPoint()
+ val originalHeightPdfUnits = page.getPageHeightPoint()
- if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
+ if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
+ throw Exception("Invalid page dimensions")
+ }
+
+ val aspectRatio =
+ originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
+ val (scaledWidth, scaledHeight) = if (isVerticalScroll) {
+ viewContainerWidthPx to viewContainerHeightPx
+ } else {
+ var fittedWidth = viewContainerWidthPx
+ var fittedHeight = (fittedWidth / aspectRatio).toInt()
+
+ if (fittedHeight > viewContainerHeightPx) {
+ fittedHeight = viewContainerHeightPx
+ fittedWidth = (fittedHeight * aspectRatio).toInt()
+ }
+
+ fittedWidth to fittedHeight
+ }
+
+ if (scaledWidth == actualBitmapWidthPx &&
+ scaledHeight == actualBitmapHeightPx &&
+ bitmapState != null &&
+ currentRenderedPageId == targetPageId
+ ) {
+ return@withContext null
+ }
+
+ val MAX_BASE_DIMEN = 3000
+
+ val baseRenderScale = 1.5f
+
+ var baseW = (scaledWidth * baseRenderScale).toInt()
+ var baseH = (scaledHeight * baseRenderScale).toInt()
+
+ if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
+ val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
+ baseW = (baseW * downScale).toInt().coerceAtLeast(1)
+ baseH = (baseH * downScale).toInt().coerceAtLeast(1)
+ }
+
+ Timber.d(
+ "Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
+ )
+ val newBitmap = createBitmap(baseW, baseH)
+ localBitmap = newBitmap
+ page.renderPageBitmap(
+ newBitmap,
+ 0, 0,
+ baseW, baseH,
+ true
+ )
+
+ Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
+ } finally {
page.close()
- throw Exception("Invalid page dimensions")
}
-
- val aspectRatio =
- originalWidthPdfUnits.toFloat() / originalHeightPdfUnits.toFloat()
- var scaledWidth = viewContainerWidthPx
- var scaledHeight = (scaledWidth / aspectRatio).toInt()
-
- if (scaledHeight > viewContainerHeightPx) {
- scaledHeight = viewContainerHeightPx
- scaledWidth = (scaledHeight * aspectRatio).toInt()
- }
-
- if (scaledWidth == actualBitmapWidthPx &&
- scaledHeight == actualBitmapHeightPx &&
- bitmapState != null &&
- currentRenderedPageId == targetPageId
- ) {
- page.close()
- return@withContext null
- }
-
- val MAX_BASE_DIMEN = 3000
-
- val baseRenderScale = 1.5f
-
- var baseW = (scaledWidth * baseRenderScale).toInt()
- var baseH = (scaledHeight * baseRenderScale).toInt()
-
- if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
- val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
- baseW = (baseW * downScale).toInt().coerceAtLeast(1)
- baseH = (baseH * downScale).toInt().coerceAtLeast(1)
- }
-
- Timber.d(
- "Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
- )
- val newBitmap = createBitmap(baseW, baseH)
- localBitmap = newBitmap
- page.renderPageBitmap(
- newBitmap,
- 0, 0,
- baseW, baseH,
- true
- )
- page.close()
-
- Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight))
}
if (renderResult != null) {
@@ -3856,7 +3993,7 @@ internal fun PdfPageComposable(
withContext(Dispatchers.IO) {
if (old != null && old !== newBitmap && !old.isRecycled) {
- val cached = PdfThumbnailCache.get(pageIndex)
+ val cached = PdfThumbnailCache.get(targetPageId)
if (old !== cached) {
old.recycle()
}
@@ -3866,14 +4003,17 @@ internal fun PdfPageComposable(
val thumbHeight = newBitmap.height / 2
if (thumbWidth > 0 && thumbHeight > 0) {
PdfThumbnailCache.put(
- pageIndex, newBitmap.scale(thumbWidth, thumbHeight)
+ targetPageId, newBitmap.scale(thumbWidth, thumbHeight)
)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
- pageErrorMessage = errorProcessingPage
+ pageErrorMessage = context.getString(
+ R.string.error_processing_page,
+ e.readablePdfErrorDetail()
+ )
} finally {
isLoadingPage = false
localBitmap?.recycle()
@@ -4265,6 +4405,7 @@ internal fun PdfPageComposable(
contentToScreenCoordinates = contentToScreenCoordinates,
density = density,
isVerticalScroll = isVerticalScroll,
+ showPageNumberOverlay = showPageNumberOverlay,
isScrolling = isScrolling,
isEditMode = isEditMode,
selectedTool = selectedTool,
@@ -4296,7 +4437,7 @@ internal fun PdfPageComposable(
else -> {
Text(
- text = stringResource(R.string.error_unable_to_display_page),
+ text = stringResource(R.string.error_unable_to_display_page, pageIndex + 1),
modifier = Modifier
.padding(16.dp)
.align(Alignment.Center)
@@ -4355,7 +4496,13 @@ private fun PdfBitmapLayer(
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) {
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 dstH = if (targetHeight > 0) targetHeight else bitmapState.height
val srcSize = IntSize(bitmapState.width, bitmapState.height)
@@ -4400,7 +4547,12 @@ private fun PdfBitmapLayer(
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
if (needsTiling) {
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(
image = tile.bitmap.asImageBitmap(),
srcOffset = IntOffset.Zero,
@@ -5086,6 +5238,7 @@ private fun PdfPageRenderer(
contentToScreenCoordinates: (Offset) -> Offset,
density: Density,
isVerticalScroll: Boolean,
+ showPageNumberOverlay: Boolean,
isScrolling: Boolean,
isEditMode: Boolean,
selectedTool: InkType,
@@ -5282,7 +5435,7 @@ private fun PdfPageRenderer(
}
// Layer 4: Page Number Indicator
- if (totalPages > 0) {
+ if (showPageNumberOverlay && totalPages > 0) {
val pageNumColor = if (staticData.isDarkMode) {
Color.White
} else {
@@ -5439,10 +5592,12 @@ private fun PdfPageRenderer(
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
currentScale = effectiveScale,
magnifierCenterOnBitmap = magnifierCenterTarget,
+ contentWidthPx = staticData.targetWidth,
+ contentHeightPx = staticData.targetHeight,
magnifierWidth = magnifierWidth,
magnifierHeight = magnifierHeight,
zoomFactor = effectiveZoomFactor,
- selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
+ selectionRectsInContentCoords = selectionData.mergedSelectionRects.item,
highlightColor = Color(0x6633B5E5),
colorFilter = staticData.colorFilter.item,
modifier = Modifier
@@ -5593,6 +5748,21 @@ private fun PdfPageRenderer(
}
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 left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY
@@ -5600,7 +5770,7 @@ private fun PdfPageRenderer(
val logicalHeight = bubble.bounds.height()
val pivotX = left + logicalWidth / 2f
val pivotY = top + logicalHeight / 2f
- val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
+ val targetZoomFactor = safeExpandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds,
viewportWidth = staticData.canvasWidth,
viewportHeight = staticData.canvasHeight
@@ -5613,8 +5783,8 @@ private fun PdfPageRenderer(
val dstOffset = IntOffset(left.toInt(), top.toInt())
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
- val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
- val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
+ val renderScaleX = baseBitmap.width.toFloat() / staticData.targetWidth.toFloat()
+ val renderScaleY = baseBitmap.height.toFloat() / staticData.targetHeight.toFloat()
val srcOffset = IntOffset(
(bubble.bounds.left * renderScaleX).toInt(),
@@ -5651,12 +5821,12 @@ private fun PdfPageRenderer(
)
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
drawImage(
- image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
- srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
- srcSize = if (expandedBubbleRender != null) {
+ image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
+ srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
+ srcSize = if (safeExpandedBubbleRender != null) {
IntSize(
- expandedBubbleRender.bitmap.width,
- expandedBubbleRender.bitmap.height)
+ safeExpandedBubbleRender.bitmap.width,
+ safeExpandedBubbleRender.bitmap.height)
} else {
srcSize
},
@@ -5675,12 +5845,12 @@ private fun PdfPageRenderer(
} else {
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
drawImage(
- image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
- srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
- srcSize = if (expandedBubbleRender != null) {
+ image = (safeExpandedBubbleRender?.bitmap ?: baseBitmap).asImageBitmap(),
+ srcOffset = if (safeExpandedBubbleRender != null) IntOffset.Zero else srcOffset,
+ srcSize = if (safeExpandedBubbleRender != null) {
IntSize(
- expandedBubbleRender.bitmap.width,
- expandedBubbleRender.bitmap.height)
+ safeExpandedBubbleRender.bitmap.width,
+ safeExpandedBubbleRender.bitmap.height)
} else {
srcSize
},
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
index 1af330f..85c0939 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
@@ -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_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
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"
+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) {
DICTIONARY("External Apps", "Top Bar"),
@@ -62,8 +66,9 @@ enum class PdfReaderTool(val title: String, val category: String) {
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
READING_MODE("Reading Mode", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
+ SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
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"),
BOOKMARK("Bookmark", "Overflow Menu"),
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
@@ -91,12 +96,28 @@ val PdfBuiltInThemes = listOf(
internal fun loadPdfHiddenTools(context: Context): Set {
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) {
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 {
@@ -164,6 +185,26 @@ internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
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) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_THEME_KEY, themeId) }
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt
index 7157014..f0bbb43 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt
@@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
-import androidx.paging.compose.itemKey
import com.aryan.reader.R
import com.aryan.reader.SearchResult
@@ -154,9 +153,10 @@ fun PdfSearchResultsPanel(
HorizontalDivider()
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
- items(count = lazyResults.itemCount, key = lazyResults.itemKey {
- "${it.locationInSource}_${it.occurrenceIndexInLocation}"
- }, contentType = lazyResults.itemContentType { "SearchResult" }) { index ->
+ items(
+ count = lazyResults.itemCount,
+ contentType = lazyResults.itemContentType { "SearchResult" }
+ ) { index ->
val result = lazyResults[index]
if (result != null) {
ListItem(
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
index 7e436fd..0d90671 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
@@ -36,11 +36,14 @@ import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.ScreenRotation
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -143,7 +146,8 @@ fun PdfCustomizeToolsSheet(
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
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) }
@@ -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.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.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))
}
}
@@ -511,7 +516,11 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
@Composable
fun PdfVisualOptionsSheet(
systemUiMode: SystemUiMode,
+ showVerticalPageGap: Boolean,
+ showPageNumberOverlay: Boolean,
onSystemUiModeChange: (SystemUiMode) -> Unit,
+ onShowVerticalPageGapChange: (Boolean) -> Unit,
+ onShowPageNumberOverlayChange: (Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -549,6 +558,54 @@ fun PdfVisualOptionsSheet(
onOptionSelected = onSystemUiModeChange,
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)
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
index 7b91896..3bd9bb7 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
@@ -47,6 +47,8 @@ import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.epubreader.SystemUiMode
import kotlin.collections.isNotEmpty
+internal val PdfTabStripHeight = 44.dp
+
private val pdfToolbarTools = setOf(
PdfReaderTool.DICTIONARY,
PdfReaderTool.THEME,
@@ -57,7 +59,8 @@ private val pdfToolbarTools = setOf(
PdfReaderTool.HIGHLIGHT_ALL,
PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE,
- PdfReaderTool.TTS_CONTROLS
+ PdfReaderTool.TTS_CONTROLS,
+ PdfReaderTool.SCREEN_ORIENTATION
)
@OptIn(ExperimentalMaterial3Api::class)
@@ -81,6 +84,7 @@ internal fun PdfTopBar(
isScrollLocked: Boolean,
isEditMode: Boolean,
displayMode: DisplayMode,
+ isRightToLeftPagination: Boolean,
isKeepScreenOn: Boolean,
isTtsSessionActive: Boolean,
isBookmarked: Boolean,
@@ -101,6 +105,7 @@ internal fun PdfTopBar(
onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit,
+ onShowScreenOrientation: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
@@ -114,6 +119,7 @@ internal fun PdfTopBar(
tapToNavigateEnabled: Boolean,
onToggleTapToNavigate: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit,
+ onSetRightToLeftPagination: (Boolean) -> Unit,
onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> 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)
}
+ 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
}
}
@@ -281,11 +294,17 @@ internal fun PdfTopBar(
Box {
var showMoreMenu 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(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = {
showHiddenToolsExpanded = false
+ showReadingModeExpanded = false
+ showTtsSettingsExpanded = false
+ showFileActionsExpanded = false
showMoreMenu = true
}) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
@@ -295,6 +314,9 @@ internal fun PdfTopBar(
expanded = showMoreMenu,
onDismissRequest = {
showHiddenToolsExpanded = false
+ showReadingModeExpanded = false
+ showTtsSettingsExpanded = false
+ showFileActionsExpanded = false
showMoreMenu = false
}
) {
@@ -340,7 +362,8 @@ internal fun PdfTopBar(
onToggleHighlights = onToggleHighlights,
onShowAiHub = onShowAiHub,
onToggleEditMode = onToggleEditMode,
- onToggleTts = onToggleTts
+ onToggleTts = onToggleTts,
+ onShowScreenOrientation = onShowScreenOrientation
)
}
}
@@ -366,18 +389,52 @@ internal fun PdfTopBar(
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
- enabled = !isTtsSessionActive,
- onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
- trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
- )
- HorizontalDivider()
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
- enabled = !isTtsSessionActive,
- onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false },
- trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
+ 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(
+ text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
+ enabled = !isTtsSessionActive,
+ onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
+ trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
+ enabled = !isTtsSessionActive,
+ onClick = {
+ 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()
}
@@ -419,24 +476,41 @@ internal fun PdfTopBar(
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_voice_settings)) },
- enabled = !isTtsSessionActive,
- onClick = { showMoreMenu = false; onShowTtsSettings() },
- leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
+ 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(
+ text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
+ enabled = !isTtsSessionActive,
+ onClick = { showMoreMenu = false; onShowTtsSettings() },
+ leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
+ )
+ }
+ if (showTtsReplacements) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
+ onClick = { showMoreMenu = false; onShowTtsReplacements() },
+ leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
+ )
+ }
+ }
HorizontalDivider()
}
- if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
- onClick = { showMoreMenu = false; onShowTtsReplacements() },
- leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
- )
- }
-
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
DropdownMenuItem(
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
@@ -462,7 +536,7 @@ internal fun PdfTopBar(
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
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,
onClick = { showMoreMenu = false; onReflowAction() },
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
@@ -470,28 +544,45 @@ internal fun PdfTopBar(
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.action_share)) },
- onClick = { showMoreMenu = false; onShare() },
- leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
- )
- }
-
- if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.action_save_copy_to_device)) },
- onClick = { showMoreMenu = false; onSaveCopy() },
- leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
- )
- }
-
- if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.action_print)) },
- onClick = { showMoreMenu = false; onPrint() },
- leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
+ 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(
+ text = { Text(stringResource(R.string.action_share)) },
+ onClick = { showMoreMenu = false; onShare() },
+ leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
+ )
+ }
+ if (showSaveCopyAction) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_save_copy_to_device)) },
+ onClick = { showMoreMenu = false; onSaveCopy() },
+ leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
+ )
+ }
+ if (showPrintAction) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_print)) },
+ onClick = { showMoreMenu = false; onPrint() },
+ leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
+ )
+ }
+ }
}
}
}
@@ -499,7 +590,7 @@ internal fun PdfTopBar(
}
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
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
) {
items(openTabs, key = { it.bookId }) { tab ->
@@ -509,7 +600,7 @@ internal fun PdfTopBar(
Row(
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))
.background(bgColor)
.clickable { onTabClick(tab.bookId) }
@@ -564,7 +655,8 @@ private fun HiddenPdfToolMenuItem(
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
- onToggleTts: () -> Unit
+ onToggleTts: () -> Unit,
+ onShowScreenOrientation: () -> Unit
) {
val enabled = when (tool) {
PdfReaderTool.SLIDER,
@@ -588,6 +680,7 @@ private fun HiddenPdfToolMenuItem(
PdfReaderTool.AI_FEATURES -> onShowAiHub()
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
+ PdfReaderTool.SCREEN_ORIENTATION -> onShowScreenOrientation()
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.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.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))
}
}
@@ -766,6 +860,8 @@ fun PdfBottomBar(
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit,
+ onShowScreenOrientation: () -> Unit,
+ showBubbleZoom: Boolean,
isBubbleZoomModeActive: Boolean,
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)
}
+ 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
}
}
- if (BuildConfig.FLAVOR != "oss") {
+ if (BuildConfig.FLAVOR != "oss" && showBubbleZoom) {
TooltipIconButton(
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),
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
index 65cc6dd..0d42531 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
@@ -63,7 +63,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -81,7 +80,6 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
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.layoutId
import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
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.PdfTextBox
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.coroutineScope
import kotlinx.coroutines.delay
@@ -175,14 +174,70 @@ fun rememberVerticalPdfReaderState(): VerticalPdfReaderState {
private data class PdfPageLayout(
val index: Int,
- val y: Float,
- val height: Float,
- val width: Float,
+ val yPx: Int,
+ val heightPx: Int,
+ val widthPx: Int,
val widthDp: 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")
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
@@ -192,6 +247,7 @@ internal fun PdfVerticalReader(
modifier: Modifier = Modifier,
state: VerticalPdfReaderState,
pdfDocument: StableHolder,
+ documentKey: String,
activeTheme: com.aryan.reader.ReaderTheme,
activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false,
@@ -230,6 +286,7 @@ internal fun PdfVerticalReader(
selectedTool: InkType,
richTextController: RichTextController? = null,
textBoxes: List = emptyList(),
+ textBoxesByPage: Map> = emptyMap(),
selectedTextBoxId: String? = null,
onTextBoxChange: (PdfTextBox) -> Unit = {},
onTextBoxSelect: (String) -> Unit = {},
@@ -245,6 +302,7 @@ internal fun PdfVerticalReader(
stylusButtonHovering: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
userHighlights: List