* Add performance and stylus debugging logs

* Refactor and decouple UI models from `MainViewModel`

* Refactor library state management and projection logic

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Introduce ReaderEngine and enhance EPUB reader features in windows app

* Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support.

* Implement PDF rendering and text extraction for desktop using Pdfium

* Add `NonReaderScreens.kt` and UI dependencies

* Refactor and centralize library state management and models to improve cross-platform consistency

* Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing

* Implement PDF annotation system and enhanced zoom controls for the desktop viewer

* Implement WebView-based EPUB rendering for desktop using CEF and embedded resources

* Optimize UI state projection, navigation state handling, and main screen pager performance

* Implement Bring Your Own Key (BYOK) support for AI features in OSS version

* Support Gemini-based Cloud TTS with BYOK support for OSS builds

* Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling.

* crash fixes

* Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6

* Implement dynamic bottom padding for the page info bar to account for device rounded corners

* Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar`

* Optimize PDF tiling performance and refine pan-and-fling gesture handling

* Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers

* Updated UI for customize toolbar

* Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls

* restructure PDF viewer UI component hierarchy to fix verifier crash

* Implement separate text dimming factors for light and dark themes

* Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers

* Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning

* Centralize file type resolution logic and implement HTML sanitization during import

* Introduce vertical margin customization and configurable progress bar positioning

* texture support in epub reader

* Enhance TTS session management, progress tracking, and diagnostic logging

* Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic.

* Refine TTS page mapping for PDF and overhaul TTS control UI

* Implement natural session completion logic in `TtsPlaybackManager` for cloud tts

* Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen`

* Refine TTS playback continuity across PDF pages and improve state management for session transitions

* Implement global texture transparency and enhance textured theme support across PDF and EPUB readers.

* Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode

* Add Support Project screen

* Optimize library performance via projection caching, batch database updates, and scoped folder synchronization.

* Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic

* Bump version to 1.0.47 (51)
This commit is contained in:
Aryan 2026-05-04 21:55:38 +05:30 committed by GitHub
parent f42de6b462
commit d7a9cae9e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 15287 additions and 3154 deletions

8
.gitignore vendored
View file

@ -8,7 +8,7 @@
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build build/
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
@ -18,3 +18,9 @@ local.properties
google-services.json google-services.json
.kotlin/ .kotlin/
.idea/ .idea/
.gradle/
third_party/pdfium/
*.tgz
kcef-bundle/
cache/
worker/

20
.idea/gradle.xml generated Normal file
View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/desktopApp" />
<option value="$PROJECT_DIR$/shared" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -24,14 +24,13 @@ kotlin {
android { android {
namespace = "com.aryan.reader" namespace = "com.aryan.reader"
compileSdk = 36 compileSdk = 36
ndkVersion = "29.0.14206865"
defaultConfig { defaultConfig {
applicationId = "com.aryan.reader" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 46 versionCode = 51
versionName = "1.0.46" versionName = "1.0.47"
resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru") resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru")
@ -154,6 +153,8 @@ android {
//noinspection UseTomlInstead //noinspection UseTomlInstead
dependencies { dependencies {
implementation(project(":shared"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -190,7 +191,7 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.appcompat:appcompat:1.7.1")
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break) //noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
implementation("androidx.navigation:navigation-compose:2.9.2") implementation("androidx.navigation:navigation-compose:2.9.6")
//noinspection GradleDependency //noinspection GradleDependency
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
//noinspection GradleDependency //noinspection GradleDependency
@ -241,6 +242,11 @@ dependencies {
implementation("com.materialkolor:material-kolor:5.0.0-alpha07") implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0")
implementation("androidx.core:core-splashscreen:1.2.0") implementation("androidx.core:core-splashscreen:1.2.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")

View file

@ -189,7 +189,7 @@
window.VIEWPORT_PADDING_BOTTOM = bottom || 0; window.VIEWPORT_PADDING_BOTTOM = bottom || 0;
}; };
window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64) { window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64, textureAlpha) {
var styleId = "readerThemeStyle"; var styleId = "readerThemeStyle";
var themeStyleElement = document.getElementById(styleId); var themeStyleElement = document.getElementById(styleId);
@ -207,8 +207,14 @@
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF'); var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000'); var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha));
var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
var bgRgb = bgMatch
? `${parseInt(bgMatch[1], 16)}, ${parseInt(bgMatch[2], 16)}, ${parseInt(bgMatch[3], 16)}`
: (isDark ? '18, 18, 18' : '255, 255, 255');
var textureCss = textureBase64 var textureCss = textureBase64
? `background-image: url('${textureBase64}'); background-repeat: repeat; background-blend-mode: multiply;` ? `background-image: linear-gradient(rgba(${bgRgb},${1 - effectiveTextureAlpha}), rgba(${bgRgb},${1 - effectiveTextureAlpha})), url('${textureBase64}'); background-repeat: repeat, repeat; background-blend-mode: normal, normal;`
: 'background-image: none;'; : 'background-image: none;';
var css = ` var css = `
@ -517,7 +523,65 @@
} }
}, true); }, true);
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) { function getReaderImageElements() {
return Array.prototype.slice.call(document.querySelectorAll("img, svg, video, canvas, image"));
}
function rememberReaderImageAnchors() {
getReaderImageElements().forEach(function (image) {
if (image.getAttribute("data-reader-image-anchor")) return;
var parent = image.parentElement || document.body;
var imageRect = image.getBoundingClientRect();
var parentRect = parent.getBoundingClientRect();
var parentWidth = parentRect.width || document.documentElement.clientWidth || window.innerWidth || 0;
if (!parentWidth || imageRect.width <= 0) {
image.setAttribute("data-reader-image-anchor", "center");
return;
}
var imageCenter = imageRect.left + imageRect.width / 2;
var parentCenter = parentRect.left + parentWidth / 2;
var tolerance = Math.max(4, parentWidth * 0.08);
var anchor = "center";
if (Math.abs(imageCenter - parentCenter) <= tolerance || imageRect.width >= parentWidth - 2) {
anchor = "center";
} else if (imageCenter > parentCenter) {
anchor = "right";
} else {
anchor = "left";
}
image.setAttribute("data-reader-image-anchor", anchor);
});
}
function applyReaderImageAnchors() {
getReaderImageElements().forEach(function (image) {
var anchor = image.getAttribute("data-reader-image-anchor") || "center";
image.style.setProperty("display", "block", "important");
image.style.setProperty("height", "auto", "important");
image.style.setProperty("object-fit", "contain", "important");
if (anchor === "right") {
image.style.setProperty("float", "none", "important");
image.style.setProperty("margin-left", "auto", "important");
image.style.setProperty("margin-right", "0", "important");
} else if (anchor === "left") {
image.style.setProperty("margin-left", "0", "important");
image.style.setProperty("margin-right", "auto", "important");
} else {
image.style.setProperty("float", "none", "important");
image.style.setProperty("margin-left", "auto", "important");
image.style.setProperty("margin-right", "auto", "important");
}
});
}
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin, verticalMargin) {
var logTag = "ReaderFontDiagnosis"; var logTag = "ReaderFontDiagnosis";
console.log( console.log(
logTag + logTag +
@ -534,7 +598,9 @@
", ImageSize: " + ", ImageSize: " +
imageSize + imageSize +
", HorizontalMargin: " + ", HorizontalMargin: " +
horizontalMargin horizontalMargin +
", VerticalMargin: " +
verticalMargin
); );
var dynamicStyleId = "dynamicReaderStyles"; var dynamicStyleId = "dynamicReaderStyles";
@ -551,12 +617,16 @@
var newGap = parseFloat(paragraphGap); var newGap = parseFloat(paragraphGap);
var newImageSize = parseFloat(imageSize); var newImageSize = parseFloat(imageSize);
var newHorizontalMargin = parseFloat(horizontalMargin); var newHorizontalMargin = parseFloat(horizontalMargin);
var newVerticalMargin = parseFloat(verticalMargin);
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0; if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0; if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0; if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0; if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0;
if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0; if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
if (isNaN(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 1.0;
rememberReaderImageAnchors();
var fontCss = ""; var fontCss = "";
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") { if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
@ -609,11 +679,14 @@
} }
var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin); var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin);
var verticalPaddingPx = Math.max(0, 16 * newVerticalMargin);
var horizontalMarginCss = ` var horizontalMarginCss = `
body { body {
box-sizing: border-box !important; box-sizing: border-box !important;
padding-left: ${horizontalPaddingPx}px !important; padding-left: ${horizontalPaddingPx}px !important;
padding-right: ${horizontalPaddingPx}px !important; padding-right: ${horizontalPaddingPx}px !important;
padding-top: ${verticalPaddingPx}px !important;
padding-bottom: ${verticalPaddingPx}px !important;
} }
`; `;
@ -629,10 +702,22 @@
width: min(100%, calc(100% * var(--reader-image-size))) !important; width: min(100%, calc(100% * var(--reader-image-size))) !important;
max-width: 100% !important; max-width: 100% !important;
height: auto !important; height: auto !important;
display: block !important;
float: none !important;
margin-left: auto !important;
margin-right: auto !important;
object-fit: contain !important;
}
body p:has(> img:only-child),
body div:has(> img:only-child),
body figure {
text-align: center !important;
} }
`; `;
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n"); dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
applyReaderImageAnchors();
setTimeout(applyReaderImageAnchors, 80);
setTimeout( setTimeout(
function () { function () {
@ -2170,6 +2255,7 @@
if (window.checkImagesForDiagnosis) { if (window.checkImagesForDiagnosis) {
setTimeout(window.checkImagesForDiagnosis, 100); setTimeout(window.checkImagesForDiagnosis, 100);
} }
setTimeout(applyReaderImageAnchors, 80);
}, },
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -52,7 +52,7 @@ static FPDFLink_GetDest_t get_dest_func = nullptr;
static FPDFAction_GetDest_t get_action_dest_func = nullptr; static FPDFAction_GetDest_t get_action_dest_func = nullptr;
static FPDFDest_GetDestPageIndex_t get_dest_page_index_func = nullptr; static FPDFDest_GetDestPageIndex_t get_dest_page_index_func = nullptr;
static FPDFAction_GetFilePath_t get_file_path_func = nullptr; static FPDFAction_GetFilePath_t get_file_path_func = nullptr;
static std::mutex g_pdfium_mutex; static std::recursive_mutex g_pdfium_mutex;
static FPDFLink_GetAnnot_t get_link_annot_func = nullptr; static FPDFLink_GetAnnot_t get_link_annot_func = nullptr;
static FPDFLink_GetAction_t get_link_action_func = nullptr; static FPDFLink_GetAction_t get_link_action_func = nullptr;
static FPDFAction_GetType_t get_action_type_func = nullptr; static FPDFAction_GetType_t get_action_type_func = nullptr;
@ -169,20 +169,23 @@ static bool init_pdfium() {
extern "C" JNIEXPORT jdouble JNICALL extern "C" JNIEXPORT jdouble JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_size_func) return 0.0; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || index < 0) return 0.0;
return get_font_size_func(reinterpret_cast<void*>(textPagePtr), index); return get_font_size_func(reinterpret_cast<void*>(textPagePtr), index);
} }
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_weight_func) return 0; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || index < 0) return 0;
return get_font_weight_func(reinterpret_cast<void*>(textPagePtr), index); return get_font_weight_func(reinterpret_cast<void*>(textPagePtr), index);
} }
// Bulk extraction for blazing fast formatting processing // Bulk extraction for blazing fast formatting processing
extern "C" JNIEXPORT jfloatArray JNICALL extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_size_func || count <= 0) return nullptr; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || count <= 0) return nullptr;
jfloatArray result = env->NewFloatArray(count); jfloatArray result = env->NewFloatArray(count);
jfloat *fill = new jfloat[count]; jfloat *fill = new jfloat[count];
@ -196,7 +199,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclas
extern "C" JNIEXPORT jintArray JNICALL extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_weight_func || count <= 0) return nullptr; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count); jintArray result = env->NewIntArray(count);
jint *fill = new jint[count]; jint *fill = new jint[count];
@ -210,7 +214,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jcl
extern "C" JNIEXPORT jintArray JNICALL extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_info_func || count <= 0) return nullptr; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_info_func || textPagePtr == 0 || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count); jintArray result = env->NewIntArray(count);
jint *fill = new jint[count]; jint *fill = new jint[count];
@ -226,7 +231,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas
extern "C" JNIEXPORT jfloatArray JNICALL extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_char_box_func || count <= 0) return nullptr; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_char_box_func || textPagePtr == 0 || count <= 0) return nullptr;
const int stride = 4; const int stride = 4;
jfloatArray result = env->NewFloatArray(count * stride); jfloatArray result = env->NewFloatArray(count * stride);
@ -247,7 +253,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas
extern "C" JNIEXPORT jstring JNICALL extern "C" JNIEXPORT jstring JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr; if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
@ -294,24 +300,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
if (!init_pdfium() || !count_objects_func) return 0; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !count_objects_func || pagePtr == 0) return 0;
return count_objects_func(reinterpret_cast<void*>(pagePtr)); return count_objects_func(reinterpret_cast<void*>(pagePtr));
} }
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr)); if (!init_pdfium() || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
if (index >= object_count) return 0;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index); void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
return obj ? get_object_type_func(obj) : 0; return obj ? get_object_type_func(obj) : 0;
} }
extern "C" JNIEXPORT jboolean JNICALL extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) {
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr)); if (!init_pdfium() || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
if (index >= object_count) return JNI_FALSE;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index); void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj) return JNI_FALSE; if (!obj) return JNI_FALSE;
@ -326,9 +331,13 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en
extern "C" JNIEXPORT jintArray JNICALL extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) { Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) {
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || !get_image_bitmap_func || !bitmap_get_buffer_func || pagePtr == 0 || index < 0 || dimens == nullptr) return nullptr; std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr)); if (!init_pdfium() || !get_object_func || !get_object_type_func || !get_image_bitmap_func ||
if (index >= object_count) return nullptr; !bitmap_get_width_func || !bitmap_get_height_func || !bitmap_get_stride_func ||
!bitmap_get_buffer_func || !bitmap_destroy_func ||
pagePtr == 0 || index < 0 || dimens == nullptr) {
return nullptr;
}
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index); void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE
@ -379,6 +388,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl
extern "C" JNIEXPORT jboolean JNICALL extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) { Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
init_pdfium(); init_pdfium();
// Return true if we have ANY way to handle actions // Return true if we have ANY way to handle actions
return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE; return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE;
@ -386,6 +396,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1; if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
@ -412,6 +423,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
extern "C" JNIEXPORT jfloatArray JNICALL extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
@ -432,13 +444,14 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jc
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0; if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
return get_annot_count_func(reinterpret_cast<void*>(pagePtr)); return get_annot_count_func(reinterpret_cast<void*>(pagePtr));
} }
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0; if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0;
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index); void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
return annot ? get_annot_subtype_func(annot) : 0; return annot ? get_annot_subtype_func(annot) : 0;
@ -446,6 +459,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass
extern "C" JNIEXPORT jfloatArray JNICALL extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !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* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index); void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
if (!annot) return nullptr; if (!annot) return nullptr;
@ -460,7 +474,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl
extern "C" JNIEXPORT jboolean JNICALL extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE; if (!init_pdfium() || pagePtr == 0) return JNI_FALSE;
void* page = reinterpret_cast<void*>(pagePtr); void* page = reinterpret_cast<void*>(pagePtr);
@ -527,7 +541,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
extern "C" JNIEXPORT jstring JNICALL extern "C" JNIEXPORT jstring JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jclass clazz, jlong docPtr, jlong pagePtr, jdouble x, jdouble y) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jclass clazz, jlong docPtr, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium()) { if (!init_pdfium()) {
LOGE("PdfLinkDiagnostic: init_pdfium failed."); LOGE("PdfLinkDiagnostic: init_pdfium failed.");
return nullptr; return nullptr;

View file

@ -0,0 +1,314 @@
package com.aryan.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AiSettingsScreen(
onBackClick: () -> Unit
) {
val context = LocalContext.current
var settings by remember { mutableStateOf(loadAiByokSettings(context)) }
var selectedProvider by remember { mutableStateOf("gemini") }
var providerMenuExpanded by remember { mutableStateOf(false) }
var pendingKey by remember { mutableStateOf("") }
var showSaveConfirm by remember { mutableStateOf(false) }
var providerToDelete by remember { mutableStateOf<String?>(null) }
fun refresh() {
settings = loadAiByokSettings(context)
}
fun updateModels(newSettings: AiByokSettings) {
saveAiByokSettings(context, newSettings)
settings = loadAiByokSettings(context)
}
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
CustomTopAppBar(
title = { Text("AI keys and models") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
SavedKeyRow("Gemini", maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" })
SavedKeyRow("Groq", maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" })
HorizontalDivider()
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
ExposedDropdownMenuBox(
expanded = providerMenuExpanded,
onExpandedChange = { providerMenuExpanded = it },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = selectedProvider.replaceFirstChar { it.titlecase() },
onValueChange = {},
readOnly = true,
label = { Text("Provider") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = providerMenuExpanded,
onDismissRequest = { providerMenuExpanded = false }
) {
listOf("gemini", "groq").forEach { provider ->
DropdownMenuItem(
text = { Text(provider.replaceFirstChar { it.titlecase() }) },
onClick = {
selectedProvider = provider
providerMenuExpanded = false
},
trailingIcon = if (provider == selectedProvider) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else null
)
}
}
}
OutlinedTextField(
value = pendingKey,
onValueChange = { pendingKey = it },
label = { Text("API key") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { showSaveConfirm = true },
enabled = pendingKey.isNotBlank(),
modifier = Modifier.align(Alignment.End)
) {
Text("Save key")
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
Text(
"When off, each reader AI feature uses its own selected model.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = settings.useOneModel,
onCheckedChange = { updateModels(settings.copy(useOneModel = it)) }
)
}
if (settings.useOneModel) {
ModelSelector(
title = "All AI features",
description = "Smart dictionary, summaries, and recaps all use this model.",
selectedId = settings.modelForAll,
onSelected = { updateModels(settings.copy(modelForAll = it)) }
)
} else {
ModelSelector(
title = "Smart dictionary",
description = "Used when defining selected words or phrases.",
selectedId = settings.defineModel,
onSelected = { updateModels(settings.copy(defineModel = it)) }
)
ModelSelector(
title = "Summaries",
description = "Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.",
selectedId = settings.summarizeModel,
onSelected = { updateModels(settings.copy(summarizeModel = it)) }
)
ModelSelector(
title = "Recaps",
description = "Used for story recap generation.",
selectedId = settings.recapModel,
onSelected = { updateModels(settings.copy(recapModel = it)) }
)
}
ModelSelector(
title = "Cloud TTS",
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
selectedId = settings.ttsModel,
options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
onSelected = { updateModels(settings.copy(ttsModel = it)) }
)
}
}
if (showSaveConfirm) {
AlertDialog(
onDismissRequest = { showSaveConfirm = false },
title = { Text("Save ${selectedProvider.replaceFirstChar { it.titlecase() }} key?") },
text = { Text("After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.") },
confirmButton = {
TextButton(onClick = {
saveAiByokKey(context, selectedProvider, pendingKey)
pendingKey = ""
showSaveConfirm = false
refresh()
}) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { showSaveConfirm = false }) { Text("Cancel") }
}
)
}
providerToDelete?.let { provider ->
AlertDialog(
onDismissRequest = { providerToDelete = null },
title = { Text("Delete ${provider.replaceFirstChar { it.titlecase() }} key?") },
text = { Text("Features using this provider will stop working until a new key is saved.") },
confirmButton = {
TextButton(onClick = {
deleteAiByokKey(context, provider)
providerToDelete = null
refresh()
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { providerToDelete = null }) { Text("Cancel") }
}
)
}
}
@Composable
private fun SavedKeyRow(
label: String,
maskedKey: String,
onDelete: () -> Unit
) {
ListItem(
headlineContent = { Text(label) },
supportingContent = {
Text(maskedKey.ifBlank { "No key saved" })
},
trailingContent = {
IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) {
Icon(Icons.Default.Delete, contentDescription = "Delete $label key")
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModelSelector(
title: String,
description: String,
selectedId: String,
options: List<AiModelOption> = aiByokModelOptions,
onSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val selected = options.firstOrNull { it.id == selectedId }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = selected?.label ?: "No model selected",
onValueChange = {},
readOnly = true,
label = { Text("Model") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("No model selected") },
onClick = {
onSelected("")
expanded = false
},
trailingIcon = if (selectedId.isBlank()) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else null
)
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
onSelected(option.id)
expanded = false
},
trailingIcon = if (option.id == selected?.id) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else null
)
}
}
}
}
}

View file

@ -0,0 +1,53 @@
package com.aryan.reader
import android.net.Uri
import androidx.core.net.toUri
import com.aryan.reader.data.RecentFileItem
import timber.log.Timber
class AndroidFolderPathResolver : FolderPathResolver {
override fun relativeFolderSegments(item: RecentFileItem): List<String> {
val documentUriString = item.uriString ?: return emptyList()
val rootFolderUriString = item.sourceFolderUri ?: return emptyList()
return try {
val documentUri = documentUriString.toUri()
val rootFolderUri = rootFolderUriString.toUri()
val rootDocId = rootFolderUri.treeDocumentIdOrNull() ?: return emptyList()
val documentId = documentUri.documentIdOrNull() ?: return emptyList()
val rootPath = rootDocId.substringAfter(':', "")
val documentPath = documentId.substringAfter(':', "")
val relativeDocumentPath = when {
rootPath.isBlank() -> documentPath
documentPath == rootPath -> ""
documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/")
else -> documentPath
}
relativeDocumentPath
.substringBeforeLast('/', "")
.split('/')
.map { Uri.decode(it).trim() }
.filter { it.isNotEmpty() }
} catch (e: Exception) {
Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}")
emptyList()
}
}
private fun Uri.treeDocumentIdOrNull(): String? {
val segments = pathSegments
val treeIndex = segments.indexOf("tree")
return segments.getOrNull(treeIndex + 1)
}
private fun Uri.documentIdOrNull(): String? {
val segments = pathSegments
val documentIndex = segments.indexOf("document")
if (documentIndex >= 0) {
return segments.getOrNull(documentIndex + 1)
}
return treeDocumentIdOrNull()
}
}

View file

@ -43,13 +43,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.aryan.reader.epubreader.EpubReaderScreen import com.aryan.reader.epubreader.EpubReaderScreen
import com.aryan.reader.feedback.FeedbackScreen import com.aryan.reader.feedback.FeedbackScreen
import com.aryan.reader.feedback.SupportProjectScreen
import com.aryan.reader.pdf.PdfViewerScreen import com.aryan.reader.pdf.PdfViewerScreen
import kotlinx.coroutines.delay
object AppDestinations { object AppDestinations {
const val MAIN_ROUTE = "main" const val MAIN_ROUTE = "main"
@ -57,19 +62,74 @@ object AppDestinations {
const val EPUB_READER_ROUTE = "epub_reader" const val EPUB_READER_ROUTE = "epub_reader"
const val PRO_SCREEN_ROUTE = "pro_screen" const val PRO_SCREEN_ROUTE = "pro_screen"
const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route" const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route"
const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route"
const val FONTS_SCREEN_ROUTE = "fonts_screen_route" const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
}
private fun NavHostController.isReadyForBackStackChange(): Boolean {
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
}
private suspend fun NavHostController.awaitReadyForBackStackChange() {
while (!isReadyForBackStackChange()) {
delay(32)
}
} }
private fun NavHostController.navigateSingleTopTo(route: String) { private fun NavHostController.navigateSingleTopTo(route: String) {
if (!isReadyForBackStackChange()) {
Timber.d("Skipping navigation to $route because the current entry is not resumed yet.")
return
}
try {
navigate(route) { navigate(route) {
launchSingleTop = true launchSingleTop = true
restoreState = true
popUpTo(graph.startDestinationId) { popUpTo(graph.startDestinationId) {
saveState = true saveState = false
}
}
} catch (e: IllegalStateException) {
Timber.w(e, "Navigation to $route ignored because the back stack is mid-transition.")
}
}
private fun NavHostController.navigateToMain() {
navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
}
private fun NavHostController.navigateIfReady(route: String) {
if (currentDestination?.route == route) return
navigateSingleTopTo(route)
}
private fun NavHostController.popBackStackIfReady(): Boolean {
if (!isReadyForBackStackChange()) {
Timber.d("Skipping popBackStack because the current entry is not resumed yet.")
return false
}
return try {
popBackStack()
} catch (e: IllegalStateException) {
Timber.w(e, "popBackStack ignored because the back stack is mid-transition.")
false
}
}
private suspend fun NavHostController.syncRouteTo(route: String) {
awaitReadyForBackStackChange()
if (currentDestination?.route != route) {
if (route == AppDestinations.MAIN_ROUTE) {
navigateToMain()
} else {
navigateSingleTopTo(route)
} }
} }
} }
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
@ -80,35 +140,32 @@ fun AppNavigation(
) { ) {
Timber.d("AppNavigation composable invoked.") Timber.d("AppNavigation composable invoked.")
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) { if (!uiState.isLoading) {
try {
when (uiState.selectedFileType) { when (uiState.selectedFileType) {
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (uiState.selectedPdfUri != null) { if (uiState.selectedPdfUri != null) {
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE) navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
} }
} }
} }
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
if (uiState.selectedEpubBook != null) { if (uiState.selectedEpubBook != null) {
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { if (currentRoute != AppDestinations.EPUB_READER_ROUTE) {
navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE) navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE)
} }
} }
} }
null -> { null -> {
val currentRoute = navController.currentBackStackEntry?.destination?.route if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) { navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
} }
} }
} }
} catch (e: IllegalStateException) {
Timber.w(e, "Navigation transition already in progress, ignoring.")
}
} }
} }
@ -153,7 +210,7 @@ fun AppNavigation(
} }
}, },
onNavigateToPro = { onNavigateToPro = {
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
}, },
viewModel = viewModel viewModel = viewModel
) )
@ -226,7 +283,7 @@ fun AppNavigation(
} }
}, },
onNavigateToPro = { onNavigateToPro = {
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
}, },
onRenderModeChange = viewModel::setRenderMode, onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts, customFonts = customFonts,
@ -276,7 +333,7 @@ fun AppNavigation(
composable(route = AppDestinations.PRO_SCREEN_ROUTE) { composable(route = AppDestinations.PRO_SCREEN_ROUTE) {
ProScreen( ProScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStackIfReady() }
) )
} }
@ -286,10 +343,22 @@ fun AppNavigation(
) )
} }
composable(route = AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) {
SupportProjectScreen(
navController = navController
)
}
composable(route = AppDestinations.FONTS_SCREEN_ROUTE) { composable(route = AppDestinations.FONTS_SCREEN_ROUTE) {
FontsScreen( FontsScreen(
viewModel = viewModel, viewModel = viewModel,
onBackClick = { navController.popBackStack() } onBackClick = { navController.popBackStackIfReady() }
)
}
composable(route = AppDestinations.AI_SETTINGS_SCREEN_ROUTE) {
AiSettingsScreen(
onBackClick = { navController.popBackStackIfReady() }
) )
} }
} }

View file

@ -0,0 +1,127 @@
package com.aryan.reader
import android.net.Uri
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.epub.CalibreBundleResult
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)
data class ImportResult(
val internalUri: Uri,
val bookId: String,
val type: FileType,
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(
val isLimitReached: Boolean = false,
val registeredDevices: List<DeviceItem> = emptyList()
)
data class ReaderScreenState(
val selectedPdfUri: Uri? = null,
val selectedBookId: String? = null,
val selectedEpubBook: EpubBook? = null,
val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
val sortOrder: SortOrder = SortOrder.RECENT,
val initialLocator: Locator? = null,
val initialCfi: String? = null,
val initialBookmarksJson: String? = null,
val initialHighlightsJson: String? = null,
val initialPageInBook: Int? = null,
val shelves: List<Shelf> = emptyList(),
val viewingShelfId: String? = null,
val isAddingBooksToShelf: Boolean = false,
val showCreateShelfDialog: Boolean = false,
val mainScreenStartPage: Int = 0,
val libraryScreenStartPage: Int = 0,
val showRenameShelfDialogFor: String? = null,
val showDeleteShelfDialogFor: String? = null,
val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
val booksSelectedForAdding: Set<String> = emptySet(),
val booksAvailableForAdding: List<RecentFileItem> = emptyList(),
val contextualActionShelfIds: Set<String> = emptySet(),
val currentUser: UserData? = null,
val isAuthMenuExpanded: Boolean = false,
val isProUser: Boolean = false,
val credits: Int = 0,
val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false,
val bannerMessage: BannerMessage? = null,
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
val isReplacingDevice: Boolean = false,
val isRequestingDrivePermission: Boolean = false,
val downloadingBookIds: Set<String> = emptySet(),
val uploadingBookIds: Set<String> = emptySet(),
val syncedFolders: List<SyncedFolder> = emptyList(),
val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "",
val isSearchActive: Boolean = false,
val isRefreshing: Boolean = false,
val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: List<RecentFileItem> = emptyList(),
val rawLibraryFiles: List<RecentFileItem> = emptyList(),
val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0,
val isTabsEnabled: Boolean = false,
val openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null,
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactorLight: Float = 1.0f,
val appTextDimFactorDark: Float = 1.0f,
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val allTags: List<TagEntity> = emptyList(),
val showTagSelectionDialogFor: Set<String> = emptySet(),
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,94 @@
package com.aryan.reader
private val codeOrDataExtensions = setOf(
"csv",
"tsv",
"json",
"xml",
"log",
"java",
"kt",
"py",
"js",
"cpp",
"c",
"cs",
"rb",
"go"
)
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()
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 isCodeOrDataFileName(fileName: String): Boolean {
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
}
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..<lastIndex) substring(dotIndex + 1) else null
}

View file

@ -24,11 +24,11 @@ import android.content.Context
import timber.log.Timber import timber.log.Timber
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.WorkManager
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -53,11 +53,15 @@ class FolderSyncWorker(
const val WORK_NAME = "FolderSyncWorker" const val WORK_NAME = "FolderSyncWorker"
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime" const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
const val KEY_METADATA_ONLY = "key_metadata_only" const val KEY_METADATA_ONLY = "key_metadata_only"
const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
private const val SCAN_DB_BATCH_SIZE = 600
private val syncMutex = Mutex() private val syncMutex = Mutex()
} }
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val workerStart = ReaderPerfLog.nowNanos()
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false) val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null) val jsonString = prefs.getString("synced_folders_list_json", null)
@ -87,17 +91,31 @@ class FolderSyncWorker(
} }
if (folders.isEmpty()) { if (folders.isEmpty()) {
Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.") ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
return Result.success() return Result.success()
} }
Timber.tag("FolderSync").d("Worker: processing ${folders.size} folders.") val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
folders
} else {
folders.filter { it.first == targetFolderUri }
}
if (foldersToProcess.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
return Result.success()
}
ReaderPerfLog.d(
"FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
"target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
)
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
syncMutex.withLock { syncMutex.withLock {
var allSuccess = true var allSuccess = true
for ((uriString, allowedTypes) in folders) { for ((uriString, allowedTypes) in foldersToProcess) {
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly) val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
if (!success) allSuccess = false if (!success) allSuccess = false
} }
@ -107,12 +125,21 @@ class FolderSyncWorker(
val array = org.json.JSONArray(jsonString) val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
array.getJSONObject(i).put("lastScanTime", now) val obj = array.getJSONObject(i)
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
obj.put("lastScanTime", now)
}
} }
prefs.edit { putString("synced_folders_list_json", array.toString()) } prefs.edit { putString("synced_folders_list_json", array.toString()) }
} catch (_: Exception) {} } catch (_: Exception) {}
} }
val elapsed = ReaderPerfLog.elapsedMs(workerStart)
ReaderPerfLog.i(
"FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
"folders=${foldersToProcess.size} elapsed=${elapsed}ms"
)
if (allSuccess) Result.success() else Result.failure() if (allSuccess) Result.success() else Result.failure()
} }
} }
@ -121,8 +148,24 @@ class FolderSyncWorker(
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean { private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
if (folderUriString.isBlank()) return true if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri() val folderUri = folderUriString.toUri()
val folderStart = ReaderPerfLog.nowNanos()
var dirsScanned = 0
var filesSeen = 0
var supportedBooksSeen = 0
var newBooks = 0
var updatedBooks = 0
var unchangedBooks = 0
var dbFlushes = 0
var scanDbFlushes = 0
var sidecarsImported = 0
var stoppedForUnlinkedFolder = false
try { try {
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
return true
}
try { try {
appContext.contentResolver.takePersistableUriPermission( appContext.contentResolver.takePersistableUriPermission(
folderUri, folderUri,
@ -137,17 +180,31 @@ class FolderSyncWorker(
return false return false
} }
Timber.tag("FolderSync").d("Phase 0: Migrating legacy root sidecars to subfolder...") ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped")
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") val folderMetadataMap = ReaderPerfLog.measureSuspend(
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap() name = "FolderSync phase metadata-sidecars",
minLogMs = 25L,
details = { "metadataOnly=$metadataOnly" }
) {
LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
}
ReaderPerfLog.d(
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
)
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...") val preloadedSidecars = mutableMapOf<String, Pair<Long, String>>()
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap() val existingFolderBooks = ReaderPerfLog.measureSuspend(
name = "FolderSync phase load-existing-db",
minLogMs = 25L
) {
recentFilesRepository.getFilesBySourceFolder(folderUriString)
}
val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId }
val remoteMetadataUpdates = mutableListOf<RecentFileItem>()
folderMetadataMap.forEach { (bookId, remoteMeta) -> folderMetadataMap.forEach { (bookId, remoteMeta) ->
val existingItem = recentFilesRepository.getFileByBookId(bookId) val existingItem = existingFolderBooksById[bookId]
if (existingItem != null) { if (existingItem != null) {
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
@ -166,40 +223,28 @@ class FolderSyncWorker(
isRecent = remoteMeta.isRecent || existingItem.isRecent, isRecent = remoteMeta.isRecent || existingItem.isRecent,
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
) )
recentFilesRepository.addRecentFile(itemToUpdate) remoteMetadataUpdates.add(itemToUpdate)
} else { } else {
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}") Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
} }
} }
} }
Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...") if (remoteMetadataUpdates.isNotEmpty()) {
val processedBookIds = mutableSetOf<String>() recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) dbFlushes++
ReaderPerfLog.d(
for (book in existingFolderBooks) { "FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
processedBookIds.add(book.bookId)
val sidecarData = preloadedSidecars[book.bookId]
if (sidecarData != null) {
val (remoteTs, jsonPayload) = sidecarData
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
) )
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L }
if (remoteTs > (localTs + 1000)) { if (metadataOnly) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") sidecarsImported += importAnnotationSidecarsForBooks(
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) folderUri = folderUri,
} else { folderUriString = folderUriString,
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.") books = existingFolderBooks,
} phase = "metadata-only"
} )
} }
if (!metadataOnly) { if (!metadataOnly) {
@ -207,7 +252,17 @@ class FolderSyncWorker(
val contentResolver = appContext.contentResolver val contentResolver = appContext.contentResolver
val foundBookIds = mutableSetOf<String>() val foundBookIds = mutableSetOf<String>()
val newOrUpdatedItems = mutableListOf<RecentFileItem>() val newOrUpdatedItems = mutableListOf<RecentFileItem>()
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap() val existingItemsMap = existingFolderBooksById.toMutableMap()
val existingItemsByUri = existingFolderBooks
.mapNotNull { item -> item.uriString?.let { uri -> uri to item } }
.toMap()
val legacyItemsByName = existingFolderBooks
.asSequence()
.filter { it.bookId.startsWith("local_${it.displayName}_") }
.groupBy { it.displayName }
.mapValues { entry ->
ArrayDeque<RecentFileItem>().apply { addAll(entry.value) }
}
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri) val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
val dirQueue = ArrayDeque<String>() val dirQueue = ArrayDeque<String>()
@ -223,7 +278,13 @@ class FolderSyncWorker(
while (dirQueue.isNotEmpty()) { while (dirQueue.isNotEmpty()) {
if (isStopped) break if (isStopped) break
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
stoppedForUnlinkedFolder = true
break
}
val currentDocId = dirQueue.removeFirst() val currentDocId = dirQueue.removeFirst()
dirsScanned++
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId) val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
try { try {
@ -234,10 +295,17 @@ class FolderSyncWorker(
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext() && !isStopped) { while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
val docId = cursor.getString(idCol) val docId = cursor.getString(idCol)
val name = cursor.getString(nameCol) ?: "" val name = cursor.getString(nameCol) ?: ""
val mimeType = cursor.getString(mimeCol) val mimeType = cursor.getString(mimeCol)
filesSeen++
if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
stoppedForUnlinkedFolder = true
break
}
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
if (!name.startsWith(".") && name != "EpistemeSyncData") { if (!name.startsWith(".") && name != "EpistemeSyncData") {
@ -249,13 +317,15 @@ class FolderSyncWorker(
val type = getFileType(name, mimeType) val type = getFileType(name, mimeType)
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) { if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
supportedBooksSeen++
val stableId = buildStableBookId(name, rootDocId, docId) val stableId = buildStableBookId(name, rootDocId, docId)
foundBookIds.add(stableId) foundBookIds.add(stableId)
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
val docUriString = docUri.toString()
var existingItem = existingItemsMap[stableId] var existingItem = existingItemsMap[stableId]
if (existingItem != null && existingItem.uriString != docUri.toString()) { if (existingItem != null && existingItem.uriString != docUriString) {
val collidedItem = existingItem val collidedItem = existingItem
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId) val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) { if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
@ -273,11 +343,9 @@ class FolderSyncWorker(
} }
if (existingItem == null) { if (existingItem == null) {
val oldItem = existingItemsMap.values.find { val oldItem = existingItemsByUri[docUriString]?.takeIf { it.bookId != stableId }
it.bookId != stableId && ( ?: legacyItemsByName[name]?.firstOrNull {
it.uriString == docUri.toString() || it.bookId != stableId
it.bookId.startsWith("local_${name}_")
)
} }
if (oldItem != null) { if (oldItem != null) {
val oldId = oldItem.bookId val oldId = oldItem.bookId
@ -291,6 +359,7 @@ class FolderSyncWorker(
preloadedSidecars = preloadedSidecars, preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap existingItemsMap = existingItemsMap
) )
legacyItemsByName[name]?.remove(oldItem)
existingItem = existingItemsMap[stableId] existingItem = existingItemsMap[stableId]
} }
} }
@ -324,6 +393,7 @@ class FolderSyncWorker(
fileSize = size fileSize = size
) )
newOrUpdatedItems.add(newItem) newOrUpdatedItems.add(newItem)
newBooks++
} else { } else {
var needsUpdate = false var needsUpdate = false
var updatedItem = existingItem var updatedItem = existingItem
@ -331,7 +401,11 @@ class FolderSyncWorker(
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) { if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).") Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
recentFilesRepository.clearLocalCachesForBook(stableId) recentFilesRepository.clearLocalCachesForBook(stableId)
updatedItem = updatedItem.copy(fileSize = size, lastModifiedTimestamp = lastModified) updatedItem = updatedItem.copy(
fileSize = size,
lastModifiedTimestamp = lastModified,
folderTextMetadataParsed = false
)
needsUpdate = true needsUpdate = true
} }
@ -347,32 +421,26 @@ class FolderSyncWorker(
if (needsUpdate) { if (needsUpdate) {
newOrUpdatedItems.add(updatedItem) newOrUpdatedItems.add(updatedItem)
updatedBooks++
} else {
unchangedBooks++
} }
} }
if (newOrUpdatedItems.size >= 50) { val batchLimit = if (scanDbFlushes == 0) 40 else SCAN_DB_BATCH_SIZE
if (newOrUpdatedItems.size >= batchLimit) {
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync batch dropped: folder unlinked pending=${newOrUpdatedItems.size} folder=$folderUriString")
newOrUpdatedItems.clear()
stoppedForUnlinkedFolder = true
break
}
recentFilesRepository.addRecentFiles(newOrUpdatedItems) recentFilesRepository.addRecentFiles(newOrUpdatedItems)
dbFlushes++
scanDbFlushes++
newOrUpdatedItems.clear() newOrUpdatedItems.clear()
} }
if (!processedBookIds.contains(stableId)) {
val sidecarData = preloadedSidecars[stableId]
if (sidecarData != null) {
val (remoteTs, jsonPayload) = sidecarData
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_$stableId.json"),
File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"),
File(appContext.filesDir, "page_layouts/layout_$stableId.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
if (remoteTs > (localTs + 1000)) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for new book $stableId. Importing.")
recentFilesRepository.importAnnotationBundle(stableId, jsonPayload)
}
}
}
} }
} }
} }
@ -380,16 +448,19 @@ class FolderSyncWorker(
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId") Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
} }
if (stoppedForUnlinkedFolder) break
} }
if (newOrUpdatedItems.isNotEmpty()) { if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) {
recentFilesRepository.addRecentFiles(newOrUpdatedItems) recentFilesRepository.addRecentFiles(newOrUpdatedItems)
dbFlushes++
scanDbFlushes++
newOrUpdatedItems.clear() newOrUpdatedItems.clear()
} }
if (!isStopped) { if (!isStopped && !stoppedForUnlinkedFolder) {
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) val idsToRemove = existingItemsMap.keys.filter { it !in foundBookIds }
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
if (idsToRemove.isNotEmpty()) { if (idsToRemove.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.") Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
@ -398,14 +469,48 @@ class FolderSyncWorker(
} }
} }
if (!isStopped) { if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) {
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") val booksForAnnotationSync = ReaderPerfLog.measureSuspend(
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build() name = "FolderSync phase load-post-scan-db",
minLogMs = 25L
) {
recentFilesRepository.getFilesBySourceFolder(folderUriString)
}
sidecarsImported += importAnnotationSidecarsForBooks(
folderUri = folderUri,
folderUriString = folderUriString,
books = booksForAnnotationSync,
phase = "post-scan"
)
}
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
ReaderPerfLog.i(
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
"new=$newBooks updated=$updatedBooks unchanged=$unchangedBooks " +
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
)
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
.setInputData(
androidx.work.Data.Builder()
.putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
.build()
)
.build()
WorkManager.getInstance(appContext).enqueueUniqueWork( WorkManager.getInstance(appContext).enqueueUniqueWork(
MetadataExtractionWorker.WORK_NAME, MetadataExtractionWorker.WORK_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE, ExistingWorkPolicy.REPLACE,
metaRequest metaRequest
) )
} else {
ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString")
}
} }
return true return true
@ -416,23 +521,87 @@ class FolderSyncWorker(
} }
} }
private suspend fun importAnnotationSidecarsForBooks(
folderUri: android.net.Uri,
folderUriString: String,
books: List<RecentFileItem>,
phase: String
): Int {
if (books.isEmpty()) {
ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
return 0
}
val preloadedSidecars = ReaderPerfLog.measureSuspend(
name = "FolderSync phase annotation-sidecars",
minLogMs = 25L,
details = { "phase=$phase" }
) {
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
}
ReaderPerfLog.d(
"FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
)
if (preloadedSidecars.isEmpty()) return 0
var imported = 0
Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
for (book in books) {
if (isStopped || !isFolderStillLinked(folderUriString)) break
val sidecarData = preloadedSidecars[book.bookId] ?: continue
val (remoteTs, jsonPayload) = sidecarData
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
if (remoteTs > (localTs + 1000)) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
imported++
} else {
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
}
}
ReaderPerfLog.i(
"FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
)
return imported
}
private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null)
if (jsonString != null) {
return try {
val array = org.json.JSONArray(jsonString)
(0 until array.length()).any { index ->
array.getJSONObject(index).optString("uri") == folderUriString
}
} catch (_: Exception) {
false
}
}
return prefs.getString("synced_folder_uri", null) == folderUriString
}
private fun getFileType(name: String, mimeType: String?): FileType? { private fun getFileType(name: String, mimeType: String?): FileType? {
val lowerName = name.lowercase() return when (mimeType) {
return when { "application/pdf" -> FileType.PDF
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF "application/epub+zip" -> FileType.EPUB
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB "application/vnd.oasis.opendocument.text" -> FileType.ODT
mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT "application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX "text/html", "application/xhtml+xml" -> FileType.HTML
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI else -> resolveFileTypeFromName(name)
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
lowerName.endsWith(".cbz") -> FileType.CBZ
lowerName.endsWith(".cbr") -> FileType.CBR
lowerName.endsWith(".cb7") -> FileType.CB7
lowerName.endsWith(".md") || lowerName.endsWith(".markdown") -> FileType.MD
lowerName.endsWith(".txt") -> FileType.TXT
mimeType == "text/html" || lowerName.endsWith(".html") || lowerName.endsWith(".xhtml") || lowerName.endsWith(".htm") -> FileType.HTML
else -> null
} }
} }

View file

@ -73,6 +73,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
@ -134,6 +135,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
@ -151,11 +153,17 @@ internal fun Context.findActivity(): Activity? = when (this) {
else -> null else -> null
} }
@UnstableApi
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController
) { ) {
val compStart = remember { System.currentTimeMillis() }
LaunchedEffect(Unit) {
ReaderPerfLog.d("HomeScreen initial composition ${System.currentTimeMillis() - compStart}ms")
}
val context = LocalContext.current val context = LocalContext.current
val customTabUriHandler = remember { CustomTabUriHandler(context) } val customTabUriHandler = remember { CustomTabUriHandler(context) }
var showCloseAllTabsDialog by remember { mutableStateOf(false) } var showCloseAllTabsDialog by remember { mutableStateOf(false) }
@ -163,14 +171,15 @@ fun HomeScreen(
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val recentFilesForHome = uiState.recentFiles.filter { it.isRecent } val screenModel = remember(uiState) { uiState.toHomeScreenModel() }
val openTabs = uiState.openTabs val recentFilesForHome = screenModel.recentFiles
val selectedContextItems = uiState.contextualActionItems val openTabs = screenModel.openTabs
val isContextualModeActive = selectedContextItems.isNotEmpty() val selectedContextItems = screenModel.selectedItems
val isContextualModeActive = screenModel.isContextualModeActive
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val deviceLimitState = uiState.deviceLimitState val deviceLimitState = screenModel.deviceLimitState
var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showClearCloudDataDialog by remember { mutableStateOf(false) } var showClearCloudDataDialog by remember { mutableStateOf(false) }
@ -304,6 +313,12 @@ fun HomeScreen(
navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE) navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
} }
}, },
onAiSettingsClick = {
scope.launch {
drawerState.close()
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
}
},
navController = navController, navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled onFolderSyncToggle = viewModel::setFolderSyncEnabled
) )
@ -341,7 +356,10 @@ fun HomeScreen(
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) }, onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
onLanguageClick = { showLanguageDialog = true }, onLanguageClick = { showLanguageDialog = true },
onExportLogsClick = { viewModel.exportLogsToFile(context) } onExportLogsClick = { viewModel.exportLogsToFile(context) },
onToggleHideReaderAi = {
saveHideReaderAiFeatures(context, !loadHideReaderAiFeatures(context))
}
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
@ -367,8 +385,8 @@ fun HomeScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) { if (screenModel.isEmpty) {
if (uiState.recentFiles.isEmpty()) { if (screenModel.isLibraryEmpty) {
EmptyState( EmptyState(
title = stringResource(R.string.your_library_empty), title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.your_library_empty_desc), message = stringResource(R.string.your_library_empty_desc),
@ -528,7 +546,8 @@ fun HomeScreen(
uiState = uiState, uiState = uiState,
onThemeModeChanged = viewModel::setAppThemeMode, onThemeModeChanged = viewModel::setAppThemeMode,
onContrastOptionChanged = viewModel::setAppContrastOption, onContrastOptionChanged = viewModel::setAppContrastOption,
onTextDimFactorChanged = viewModel::setAppTextDimFactor, onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight,
onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark,
onSeedColorChanged = viewModel::setAppSeedColor, onSeedColorChanged = viewModel::setAppSeedColor,
onCustomThemeAdded = viewModel::addCustomAppTheme, onCustomThemeAdded = viewModel::addCustomAppTheme,
onCustomThemeDeleted = viewModel::deleteCustomAppTheme, onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
@ -596,6 +615,9 @@ private fun RecentFilesContent(
hasSyncedFolder: Boolean hasSyncedFolder: Boolean
) { ) {
val canRefresh = isSyncEnabled || hasSyncedFolder val canRefresh = isSyncEnabled || hasSyncedFolder
val selectedItemUris = remember(selectedContextItems) {
selectedContextItems.mapNotNullTo(mutableSetOf()) { it.uriString }
}
val content = @Composable { val content = @Composable {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@ -608,7 +630,7 @@ private fun RecentFilesContent(
isTabsEnabled = isTabsEnabled, isTabsEnabled = isTabsEnabled,
onTabCloseClick = onTabCloseClick, onTabCloseClick = onTabCloseClick,
onCloseAllTabsClick = onCloseAllTabsClick, onCloseAllTabsClick = onCloseAllTabsClick,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(), selectedItemUris = selectedItemUris,
pinnedHomeBookIds = pinnedHomeBookIds, pinnedHomeBookIds = pinnedHomeBookIds,
onItemClick = onItemClick, onItemClick = onItemClick,
onItemLongClick = onItemLongClick, onItemLongClick = onItemLongClick,
@ -996,10 +1018,13 @@ fun DefaultTopAppBar(
onTestPanelDetectionClick: () -> Unit, onTestPanelDetectionClick: () -> Unit,
onTestSpeechBubbleDetectionClick: () -> Unit, onTestSpeechBubbleDetectionClick: () -> Unit,
onLanguageClick: () -> Unit, onLanguageClick: () -> Unit,
onExportLogsClick: () -> Unit onExportLogsClick: () -> Unit,
onToggleHideReaderAi: () -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) }
val context = LocalContext.current
var hideReaderAiFeatures by remember { mutableStateOf(loadHideReaderAiFeatures(context)) }
CustomTopAppBar(title = { }, navigationIcon = { CustomTopAppBar(title = { }, navigationIcon = {
IconButton(onClick = onDrawerClick) { IconButton(onClick = onDrawerClick) {
@ -1086,6 +1111,20 @@ fun DefaultTopAppBar(
showOptionsMenu = false 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))
}
}
)
HorizontalDivider() HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache() onClearCache()
@ -1141,6 +1180,7 @@ private fun AppDrawerContent(
onUpgradeClick: () -> Unit, onUpgradeClick: () -> Unit,
onSyncUpsellClick: () -> Unit, onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit, onFontsClick: () -> Unit,
onAiSettingsClick: () -> Unit,
navController: NavHostController, navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit onFolderSyncToggle: (Boolean) -> Unit
) { ) {
@ -1311,6 +1351,26 @@ private fun AppDrawerContent(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
) )
if (isOss && !BuildConfig.IS_OFFLINE) {
NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) },
label = { Text("AI keys and models") },
selected = false,
onClick = onAiSettingsClick,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
if (isOss) {
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.FavoriteBorder, contentDescription = null) },
label = { Text(stringResource(R.string.drawer_support_project)) },
selected = false,
onClick = { navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) }, icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_help_feedback)) }, label = { Text(stringResource(R.string.drawer_help_feedback)) },
@ -1692,7 +1752,8 @@ fun AppThemeBottomSheet(
uiState: ReaderScreenState, uiState: ReaderScreenState,
onThemeModeChanged: (AppThemeMode) -> Unit, onThemeModeChanged: (AppThemeMode) -> Unit,
onContrastOptionChanged: (AppContrastOption) -> Unit, onContrastOptionChanged: (AppContrastOption) -> Unit,
onTextDimFactorChanged: (Float) -> Unit, onTextDimFactorLightChanged: (Float) -> Unit,
onTextDimFactorDarkChanged: (Float) -> Unit,
onSeedColorChanged: (Color?) -> Unit, onSeedColorChanged: (Color?) -> Unit,
onCustomThemeAdded: (CustomAppTheme) -> Unit, onCustomThemeAdded: (CustomAppTheme) -> Unit,
onCustomThemeDeleted: (String) -> Unit, onCustomThemeDeleted: (String) -> Unit,
@ -1756,6 +1817,49 @@ fun AppThemeBottomSheet(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
if (uiState.appThemeMode == AppThemeMode.SYSTEM) {
Text("${stringResource(R.string.app_theme_text_brightness)} (Light)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = uiState.appTextDimFactorLight,
onValueChange = onTextDimFactorLightChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
}
Spacer(Modifier.height(16.dp))
Text("${stringResource(R.string.app_theme_text_brightness)} (Dark)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = uiState.appTextDimFactorDark,
onValueChange = onTextDimFactorDarkChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
}
} else {
Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row( Row(
@ -1768,13 +1872,14 @@ fun AppThemeBottomSheet(
) { ) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider( androidx.compose.material3.Slider(
value = uiState.appTextDimFactor, value = if (uiState.appThemeMode == AppThemeMode.DARK) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight,
onValueChange = onTextDimFactorChanged, onValueChange = if (uiState.appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged,
valueRange = 0.3f..1.0f, valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp) modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
) )
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
} }
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))

View file

@ -0,0 +1,86 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
enum class AddBooksSource {
UNSHELVED,
ALL_BOOKS
}
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> = FileType.entries.toSet()
)
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
data class Shelf(
val id: String,
val name: String,
val type: ShelfType,
val books: List<RecentFileItem>,
val directBooks: List<RecentFileItem> = books,
val parentShelfId: String? = null,
val childShelfIds: List<String> = emptyList(),
val depth: Int = 0,
val sortKey: String = name.lowercase()
) {
val bookCount: Int get() = books.size
val topBook: RecentFileItem? by lazy(LazyThreadSafetyMode.NONE) { books.maxByOrNull { it.timestamp } }
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<FileType> = emptySet(),
val sourceFolders: Set<String> = emptySet(),
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
val tagIds: Set<String> = emptySet()
) {
val isActive: Boolean
get() = fileTypes.isNotEmpty() ||
sourceFolders.isNotEmpty() ||
readStatus != ReadStatusFilter.ALL ||
tagIds.isNotEmpty()
}

View file

@ -56,6 +56,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -131,6 +132,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
@ -141,6 +143,8 @@ import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsViewModel import com.aryan.reader.opds.OpdsViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup import org.jsoup.Jsoup
import timber.log.Timber import timber.log.Timber
@ -154,20 +158,26 @@ private fun getBookCountString(count: Int): String {
return pluralStringResource(id = R.plurals.book_count, count, count) return pluralStringResource(id = R.plurals.book_count, count, count)
} }
@UnstableApi
@SuppressLint("LocalContextGetResourceValueCall") @SuppressLint("LocalContextGetResourceValueCall")
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
) { ) {
val compStart = remember { System.currentTimeMillis() }
LaunchedEffect(Unit) {
ReaderPerfLog.d("LibraryScreen initial composition ${System.currentTimeMillis() - compStart}ms")
}
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems val screenModel = remember(uiState) { uiState.toLibraryScreenModel() }
val isContextualModeActive = selectedItems.isNotEmpty() val selectedItems = screenModel.selectedItems
val selectedShelves = uiState.contextualActionShelfIds val isContextualModeActive = screenModel.isContextualModeActive
val isShelfContextualModeActive = selectedShelves.isNotEmpty() val selectedShelves = screenModel.selectedShelves
val sortOrder = uiState.sortOrder val isShelfContextualModeActive = screenModel.isShelfContextualModeActive
val shelves = uiState.shelves val sortOrder = screenModel.sortOrder
val rawLibraryFiles = uiState.rawLibraryFiles val shelves = screenModel.shelves
val rawLibraryFiles = screenModel.rawLibraryFiles
val tabTitles = remember { val tabTitles = remember {
buildList { buildList {
add(context.getString(R.string.tab_all_books)) add(context.getString(R.string.tab_all_books))
@ -183,21 +193,13 @@ fun LibraryScreen(
pageCount = { tabTitles.size } pageCount = { tabTitles.size }
) )
val containsFolderItems = remember(selectedItems) { val containsFolderItems = screenModel.containsFolderItemsInSelection
selectedItems.any { it.sourceFolderUri != null }
}
LaunchedEffect(uiState.libraryScreenStartPage) {
if (pagerState.currentPage != uiState.libraryScreenStartPage) {
pagerState.animateScrollToPage(uiState.libraryScreenStartPage)
}
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showFilterSheet by remember { mutableStateOf(false) } var showFilterSheet by remember { mutableStateOf(false) }
val isSearchActive = uiState.isSearchActive val isSearchActive = screenModel.isSearchActive
val searchQuery = uiState.searchQuery val searchQuery = screenModel.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult( val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree() contract = ActivityResultContracts.OpenDocumentTree()
@ -250,6 +252,8 @@ fun LibraryScreen(
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
androidx.compose.runtime.snapshotFlow { pagerState.settledPage } androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
.drop(1)
.distinctUntilChanged()
.collect { page -> .collect { page ->
viewModel.setLibraryScreenPage(page) viewModel.setLibraryScreenPage(page)
} }
@ -401,6 +405,7 @@ fun LibraryScreen(
} }
} }
@UnstableApi
@Composable @Composable
fun ShelfScreen( fun ShelfScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
@ -577,6 +582,7 @@ fun LibraryScreenContent(
val isShelfContextualModeActive = selectedShelves.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
val selectedBookIds = remember(selectedItems) { selectedItems.mapTo(mutableSetOf()) { it.bookId } }
var textFieldValue by remember(isSearchActive) { var textFieldValue by remember(isSearchActive) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length))) mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
@ -712,7 +718,16 @@ fun LibraryScreenContent(
Tab( Tab(
selected = pagerState.currentPage == index, selected = pagerState.currentPage == index,
onClick = { onClick = {
scope.launch { pagerState.animateScrollToPage(index) } ReaderPerfLog.d("LibraryPager click page=$index title=$title")
if (pagerState.currentPage != index) {
scope.launch {
val start = ReaderPerfLog.nowNanos()
pagerState.animateScrollToPage(index)
ReaderPerfLog.d(
"LibraryPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(start)}ms"
)
}
}
}, },
text = { Text(title) } text = { Text(title) }
) )
@ -798,6 +813,11 @@ fun LibraryScreenContent(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapPositionalThreshold = 0.25f
),
beyondViewportPageCount = 0,
key = { it } key = { it }
) { page -> ) { page ->
when (page) { when (page) {
@ -822,7 +842,7 @@ fun LibraryScreenContent(
items(recentFiles, key = { it.bookId }) { item -> items(recentFiles, key = { it.bookId }) { item ->
LibraryListItem( LibraryListItem(
item = item, item = item,
isSelected = selectedItems.any { it.bookId == item.bookId }, isSelected = item.bookId in selectedBookIds,
isPinned = item.bookId in pinnedLibraryBookIds, isPinned = item.bookId in pinnedLibraryBookIds,
onItemClick = { onItemClick(item) }, onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) }, onItemLongClick = { onItemLongClick(item) },
@ -1876,6 +1896,18 @@ private fun FolderSyncScreen(
isLoading: Boolean isLoading: Boolean
) { ) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) } var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles
.asSequence()
.filter { it.sourceFolderUri != null }
.groupBy { it.sourceFolderUri!! }
.mapValues { (_, files) ->
FolderFileStats(
totalBooks = files.size,
countsByType = files.groupingBy { it.type }.eachCount()
)
}
}
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
@ -1943,7 +1975,7 @@ private fun FolderSyncScreen(
items(syncedFolders, key = { it.uriString }) { folder -> items(syncedFolders, key = { it.uriString }) { folder ->
FolderCard( FolderCard(
folder = folder, folder = folder,
allRecentFiles = allRecentFiles, stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick, onRemoveClick = onRemoveFolderClick,
onEditFiltersClick = { editingFolder = folder } onEditFiltersClick = { editingFolder = folder }
) )
@ -1952,11 +1984,11 @@ private fun FolderSyncScreen(
} }
} }
if (editingFolder != null) { editingFolder?.let { folder ->
EditFolderFiltersDialog( EditFolderFiltersDialog(
folder = editingFolder!!, folder = folder,
onConfirm = { newFilters -> onConfirm = { newFilters ->
onEditFolderFiltersClick(editingFolder!!, newFilters) onEditFolderFiltersClick(folder, newFilters)
editingFolder = null editingFolder = null
}, },
onDismiss = { editingFolder = null } onDismiss = { editingFolder = null }
@ -1964,11 +1996,20 @@ private fun FolderSyncScreen(
} }
} }
private data class FolderFileStats(
val totalBooks: Int,
val countsByType: Map<FileType, Int>
) {
companion object {
val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap())
}
}
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable @Composable
private fun FolderCard( private fun FolderCard(
folder: SyncedFolder, folder: SyncedFolder,
allRecentFiles: List<RecentFileItem>, stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit, onRemoveClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit onEditFiltersClick: (SyncedFolder) -> Unit
) { ) {
@ -1976,14 +2017,6 @@ private fun FolderCard(
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime)) val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
val folderFiles = remember(allRecentFiles, folder.uriString) {
allRecentFiles.filter { it.sourceFolderUri == folder.uriString }
}
val totalBooks = folderFiles.size
val countsByType = remember(folderFiles) {
folderFiles.groupBy { it.type }.mapValues { it.value.size }
}
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors( colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@ -2058,18 +2091,18 @@ private fun FolderCard(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium) Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
} }
} }
if (countsByType.isNotEmpty()) { if (stats.countsByType.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
androidx.compose.foundation.layout.FlowRow( androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
countsByType.forEach { (type, count) -> stats.countsByType.forEach { (type, count) ->
AssistChip( AssistChip(
onClick = { }, onClick = { },
label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) } label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }

View file

@ -0,0 +1,435 @@
package com.aryan.reader
import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.SmartCollectionEngine
import com.aryan.reader.data.TagEntity
fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List<String>
}
object EmptyFolderPathResolver : FolderPathResolver {
override fun relativeFolderSegments(item: RecentFileItem): List<String> = emptyList()
}
data class LibraryProjectionInput(
val state: ReaderScreenState,
val recentFilesFromDb: List<RecentFileItem>,
val dbShelves: List<ShelfEntity>,
val shelfRefs: List<BookShelfCrossRef>,
val dbTags: List<TagEntity>,
val tagRefs: List<BookTagCrossRef>
)
class LibraryStateProjector(
private val folderPathResolver: FolderPathResolver = EmptyFolderPathResolver
) {
private var cachedProjection: CachedProjection? = null
fun project(input: LibraryProjectionInput): ReaderScreenState {
val start = ReaderPerfLog.nowNanos()
val internalState = input.state
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) },
sortOrder = internalState.sortOrder,
searchQuery = internalState.searchQuery,
libraryFilters = internalState.libraryFilters,
recentFilesLimit = internalState.recentFilesLimit
)
cachedProjection?.takeIf { it.key == cacheKey }?.let { cache ->
val result = buildStateFromCache(internalState, cache)
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}"
)
}
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 cache = CachedProjection(
key = cacheKey,
allLibraryFiles = allLibraryFiles,
allLibraryFilesById = allLibraryFilesById,
sortedLibraryFiles = sortedLibraryFiles,
visibleRecentFiles = visibleRecentFiles,
shelfProjection = shelfProjection,
validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id },
dbTags = input.dbTags
)
cachedProjection = cache
val elapsed = ReaderPerfLog.elapsedMs(start)
if (elapsed >= 16L || allLibraryFiles.size >= 500) {
ReaderPerfLog.d(
"LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " +
"visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " +
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
)
}
return buildStateFromCache(internalState, cache)
}
private fun buildStateFromCache(
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
)
}
private fun buildShelves(
allLibraryFiles: List<RecentFileItem>,
dbShelves: List<ShelfEntity>,
shelfRefs: List<BookShelfCrossRef>,
dbTags: List<TagEntity>,
sortOrder: SortOrder,
syncedFolders: List<SyncedFolder>
): ShelfProjection {
val allShelves = mutableListOf<Shelf>()
val shelvedBookIds = mutableSetOf<String>()
val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId }
val taggedBookIdsByTagId = mutableMapOf<String, MutableList<String>>()
allLibraryFiles.forEach { item ->
item.tags.forEach { tag ->
taggedBookIdsByTagId.getOrPut(tag.id) { mutableListOf() }.add(item.bookId)
}
}
dbShelves.forEach { shelfEntity ->
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
if (rules != null) {
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}
} else {
val bookIdsInShelf = shelfRefsByShelfId[shelfEntity.id].orEmpty()
.sortedBy { it.addedAt }
.map { it.bookId }
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf, sortOrder)))
shelvedBookIds.addAll(bookIdsInShelf)
}
}
val tagShelves = dbTags.mapNotNull { tag ->
val taggedBooks = taggedBookIdsByTagId[tag.id].orEmpty().mapNotNull { baseFilesMap[it] }
if (taggedBooks.isEmpty()) {
null
} else {
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks, sortOrder))
}
}
allShelves.addAll(tagShelves)
val seriesShelves = allLibraryFiles
.filter { !it.seriesName.isNullOrBlank() }
.groupBy { it.seriesName!! }
.filter { it.value.size >= 2 }
.map { (series, books) ->
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
shelvedBookIds.addAll(books.map { it.bookId })
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
}
allShelves.addAll(seriesShelves)
val folderShelves = buildFolderShelves(
allLibraryFiles = allLibraryFiles,
syncedFolders = syncedFolders,
sortOrder = sortOrder
).also { shelves ->
shelves.forEach { shelf ->
shelvedBookIds.addAll(shelf.books.map { it.bookId })
}
}
allShelves.addAll(folderShelves)
val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds }
allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks, sortOrder)))
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
return ShelfProjection(shelves = allShelves, unshelvedBooks = unshelvedBooks)
}
private fun buildFolderShelves(
allLibraryFiles: List<RecentFileItem>,
syncedFolders: List<SyncedFolder>,
sortOrder: SortOrder
): List<Shelf> {
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
val folderSegmentsByBookId = allLibraryFiles
.asSequence()
.filter { it.sourceFolderUri != null }
.associate { it.bookId to folderPathResolver.relativeFolderSegments(it) }
return allLibraryFiles
.filter { it.sourceFolderUri != null }
.groupBy { it.sourceFolderUri!! }
.flatMap { (folderUri, books) ->
val rootName = folderNamesByUri[folderUri] ?: "Local Folder"
val rootShelfId = "folder_$folderUri"
val rootAccumulator = FolderShelfAccumulator(
id = rootShelfId,
name = rootName,
depth = 0,
parentShelfId = null,
sortPath = ""
)
val rootShelf = Shelf(
id = rootShelfId,
name = rootName,
type = ShelfType.FOLDER,
books = sortFiles(books, sortOrder),
directBooks = emptyList(),
childShelfIds = emptyList(),
depth = 0,
sortKey = "folder:${rootName.lowercase()}:"
)
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
val nestedShelvesById = mutableMapOf<String, FolderShelfAccumulator>()
books.forEach { book ->
rootAccumulator.books.add(book)
val segments = folderSegmentsByBookId[book.bookId].orEmpty()
if (segments.isEmpty()) {
rootAccumulator.directBooks.add(book)
}
var currentPath = ""
var parentShelfId = rootShelfId
segments.forEachIndexed { index, segment ->
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
val shelfId = "folder_$folderUri::$currentPath"
val accumulator = nestedShelves.getOrPut(currentPath) {
val newShelf = FolderShelfAccumulator(
id = shelfId,
name = segment,
depth = index + 1,
parentShelfId = parentShelfId,
sortPath = currentPath.lowercase()
)
if (parentShelfId == rootShelfId) {
rootAccumulator.childShelfIds.add(shelfId)
} else {
nestedShelvesById[parentShelfId]?.childShelfIds?.add(shelfId)
}
nestedShelvesById[shelfId] = newShelf
newShelf
}
accumulator.books.add(book)
if (index == segments.lastIndex) {
accumulator.directBooks.add(book)
}
parentShelfId = shelfId
}
}
val sortedNestedShelves = nestedShelves
.values
.sortedBy { it.sortPath }
.map { shelf ->
Shelf(
id = shelf.id,
name = shelf.name,
type = ShelfType.FOLDER,
books = sortFiles(shelf.books, sortOrder),
directBooks = sortFiles(shelf.directBooks, sortOrder),
parentShelfId = shelf.parentShelfId,
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
depth = shelf.depth,
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
)
}
listOf(
rootShelf.copy(
directBooks = sortFiles(rootAccumulator.directBooks, sortOrder),
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
)
) + sortedNestedShelves
}
}
private data class FolderShelfAccumulator(
val id: String,
val name: String,
val depth: Int,
val parentShelfId: String?,
val sortPath: String,
val books: MutableList<RecentFileItem> = mutableListOf(),
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
val childShelfIds: MutableList<String> = mutableListOf()
)
private data class ShelfProjection(
val shelves: List<Shelf>,
val unshelvedBooks: List<RecentFileItem>
)
private data class ProjectionCacheKey(
val recentFilesFromDb: List<RecentFileItem>,
val dbShelves: List<ShelfEntity>,
val shelfRefs: List<BookShelfCrossRef>,
val dbTags: List<TagEntity>,
val tagRefs: List<BookTagCrossRef>,
val folderKeys: List<SyncedFolderProjectionKey>,
val sortOrder: SortOrder,
val searchQuery: String,
val libraryFilters: LibraryFilters,
val recentFilesLimit: Int
)
private data class SyncedFolderProjectionKey(
val uriString: String,
val name: String
)
private data class CachedProjection(
val key: ProjectionCacheKey,
val allLibraryFiles: List<RecentFileItem>,
val allLibraryFilesById: Map<String, RecentFileItem>,
val sortedLibraryFiles: List<RecentFileItem>,
val visibleRecentFiles: List<RecentFileItem>,
val shelfProjection: ShelfProjection,
val validShelfIds: Set<String>,
val dbTags: List<TagEntity>
)
}
fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> {
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) }
}
}
}
fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> {
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
}
}
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
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 }
}
}

View file

@ -45,7 +45,9 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
@UnstableApi
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels() private val viewModel: MainViewModel by viewModels()
@ -91,12 +93,14 @@ class MainActivity : AppCompatActivity() {
AppThemeMode.SYSTEM -> isSystemInDarkTheme() AppThemeMode.SYSTEM -> isSystemInDarkTheme()
} }
val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight
AppTheme( AppTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = uiState.appSeedColor == null, dynamicColor = uiState.appSeedColor == null,
seedColor = uiState.appSeedColor, seedColor = uiState.appSeedColor,
contrastLevel = uiState.appContrastOption.value, contrastLevel = uiState.appContrastOption.value,
textDimFactor = uiState.appTextDimFactor textDimFactor = textDimFactor
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View file

@ -0,0 +1,14 @@
package com.aryan.reader
internal const val KEY_RENDER_MODE = "render_mode"
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
internal const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page"
internal const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page"
internal const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id"
internal const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf"
internal const val KEY_FILTER_FILE_TYPES = "filter_file_types"
internal const val KEY_FILTER_FOLDERS = "filter_folders"
internal const val KEY_FILTER_READ_STATUS = "filter_read_status"
internal const val KEY_FILTER_TAG_IDS = "filter_tag_ids"
internal const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded"

View file

@ -22,10 +22,9 @@ package com.aryan.reader
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
@ -33,17 +32,15 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) { sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) {
object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home) object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home)
@ -55,6 +52,7 @@ private val bottomBarItems = listOf(
BottomBarScreen.Library, BottomBarScreen.Library,
) )
@OptIn(UnstableApi::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
@ -75,21 +73,7 @@ fun MainScreen(
if (viewingShelfName != null) { if (viewingShelfName != null) {
ShelfScreen(viewModel = viewModel) ShelfScreen(viewModel = viewModel)
} else { } else {
val pagerState = rememberPagerState( val selectedPage = uiState.mainScreenStartPage.coerceIn(0, bottomBarItems.lastIndex)
initialPage = uiState.mainScreenStartPage,
pageCount = { bottomBarItems.size }
)
val scope = rememberCoroutineScope()
LaunchedEffect(uiState.mainScreenStartPage) {
if (pagerState.currentPage != uiState.mainScreenStartPage) {
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
}
}
LaunchedEffect(pagerState.currentPage) {
viewModel.setMainScreenPage(pagerState.currentPage)
}
Scaffold( Scaffold(
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
@ -99,23 +83,28 @@ fun MainScreen(
NavigationBarItem( NavigationBarItem(
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) }, icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
label = { Text(stringResource(screen.stringResId)) }, label = { Text(stringResource(screen.stringResId)) },
selected = pagerState.currentPage == index, selected = selectedPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } } onClick = {
ReaderPerfLog.d("MainPager click page=$index route=${screen.route}")
if (selectedPage != index) {
val animStart = ReaderPerfLog.nowNanos()
viewModel.setMainScreenPage(index)
ReaderPerfLog.d(
"MainPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(animStart)}ms"
)
}
}
) )
} }
} }
} }
) { innerPadding -> ) { innerPadding ->
HorizontalPager( androidx.compose.foundation.layout.Box(
state = pagerState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
key = { bottomBarItems[it].route }, ) {
beyondViewportPageCount = 1, when (selectedPage) {
userScrollEnabled = false
) { page ->
when (page) {
0 -> HomeScreen( 0 -> HomeScreen(
viewModel = viewModel, viewModel = viewModel,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,

View file

@ -38,7 +38,7 @@ import kotlinx.serialization.protobuf.ProtoBuf
import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.paginatedreader.semanticBlockModule
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.annotation.StringRes import androidx.annotation.OptIn
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
@ -48,6 +48,7 @@ import androidx.credentials.exceptions.NoCredentialException
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo import androidx.work.WorkInfo
@ -64,8 +65,8 @@ import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.data.RemoteConfigRepository import com.aryan.reader.data.RemoteConfigRepository
import com.aryan.reader.data.ShelfMetadata import com.aryan.reader.data.ShelfMetadata
import com.aryan.reader.data.SmartCollectionEngine
import com.aryan.reader.data.TagEntity import com.aryan.reader.data.TagEntity
import com.aryan.reader.data.getUri
import com.aryan.reader.data.toBookMetadata import com.aryan.reader.data.toBookMetadata
import com.aryan.reader.data.toRecentFileItem import com.aryan.reader.data.toRecentFileItem
import com.aryan.reader.epub.CalibreBundleExtractor import com.aryan.reader.epub.CalibreBundleExtractor
@ -75,6 +76,7 @@ import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.ImportedFileCache import com.aryan.reader.epub.ImportedFileCache
import com.aryan.reader.epub.MobiParser import com.aryan.reader.epub.MobiParser
import com.aryan.reader.epub.SingleFileImporter import com.aryan.reader.epub.SingleFileImporter
import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.ml.ISpeechBubbleDetector import com.aryan.reader.ml.ISpeechBubbleDetector
import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
@ -120,60 +122,18 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.Date
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.Executors.newSingleThreadExecutor import java.util.concurrent.Executors.newSingleThreadExecutor
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
private const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page"
private const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page"
private const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id"
private const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf"
private const val KEY_FILTER_FILE_TYPES = "filter_file_types"
private const val KEY_FILTER_FOLDERS = "filter_folders"
private const val KEY_FILTER_READ_STATUS = "filter_read_status"
private const val KEY_FILTER_TAG_IDS = "filter_tag_ids"
private const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded"
private val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
private val EPUB_READER_FILE_TYPES = setOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
data class ImportResult(
val internalUri: Uri,
val bookId: String,
val type: FileType,
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
)
private data class SpeechBubbleCacheKey( private data class SpeechBubbleCacheKey(
val documentId: String, val documentId: String,
val pageIndex: Int val pageIndex: Int
@ -187,166 +147,8 @@ private data class CachedSpeechBubble(
val maskBitmap: Bitmap? val maskBitmap: Bitmap?
) )
enum class AddBooksSource(@StringRes val labelRes: Int) { @kotlin.OptIn(ExperimentalSerializationApi::class)
UNSHELVED(R.string.add_books_source_unshelved), @UnstableApi
ALL_BOOKS(R.string.add_books_source_all_books)
}
enum class AppThemeMode(@StringRes val labelRes: Int) {
SYSTEM(R.string.app_theme_mode_system),
LIGHT(R.string.app_theme_mode_light),
DARK(R.string.app_theme_mode_dark)
}
enum class AppContrastOption(@StringRes val labelRes: Int, val value: Double) {
STANDARD(R.string.app_contrast_standard, 0.0),
MEDIUM(R.string.app_contrast_medium, 0.5),
HIGH(R.string.app_contrast_high, 1.0)
}
data class CustomAppTheme(
val id: String,
val name: String,
val seedColor: androidx.compose.ui.graphics.Color
)
enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
}
enum class RenderMode {
VERTICAL_SCROLL, PAGINATED
}
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
data class DeviceLimitReachedState(
val isLimitReached: Boolean = false, val registeredDevices: List<DeviceItem> = emptyList()
)
data class SyncedFolder(
val uriString: String, val name: String, val lastScanTime: Long, val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
)
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
data class Shelf(
val id: String,
val name: String,
val type: ShelfType,
val books: List<RecentFileItem>,
val directBooks: List<RecentFileItem> = books,
val parentShelfId: String? = null,
val childShelfIds: List<String> = emptyList(),
val depth: Int = 0,
val sortKey: String = name.lowercase()
) {
val bookCount: Int get() = books.size
val topBook: RecentFileItem? get() = books.maxByOrNull { it.timestamp }
val directBookCount: Int get() = directBooks.size
val childShelfCount: Int get() = childShelfIds.size
}
enum class SortOrder(@StringRes val labelRes: Int) {
RECENT(R.string.sort_recent),
TITLE_ASC(R.string.sort_title_az),
AUTHOR_ASC(R.string.sort_author_az),
PERCENT_ASC(R.string.sort_percent_asc),
PERCENT_DESC(R.string.sort_percent_desc),
SIZE_ASC(R.string.sort_size_smallest),
SIZE_DESC(R.string.sort_size_biggest)
}
enum class ReadStatusFilter(@StringRes val labelRes: Int) {
ALL(R.string.read_status_all),
UNREAD(R.string.read_status_unread),
IN_PROGRESS(R.string.read_status_in_progress),
COMPLETED(R.string.read_status_completed)
}
data class LibraryFilters(
val fileTypes: Set<FileType> = emptySet(),
val sourceFolders: Set<String> = emptySet(),
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
val tagIds: Set<String> = emptySet()
) {
val isActive: Boolean
get() = fileTypes.isNotEmpty() ||
sourceFolders.isNotEmpty() ||
readStatus != ReadStatusFilter.ALL ||
tagIds.isNotEmpty()
}
data class ReaderScreenState(
val selectedPdfUri: Uri? = null,
val selectedBookId: String? = null,
val selectedEpubBook: EpubBook? = null,
val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
val sortOrder: SortOrder = SortOrder.RECENT,
val initialLocator: Locator? = null,
val initialCfi: String? = null,
val initialBookmarksJson: String? = null,
val initialHighlightsJson: String? = null,
val initialPageInBook: Int? = null,
val shelves: List<Shelf> = emptyList(),
val viewingShelfId: String? = null,
val isAddingBooksToShelf: Boolean = false,
val showCreateShelfDialog: Boolean = false,
val mainScreenStartPage: Int = 0,
val libraryScreenStartPage: Int = 0,
val showRenameShelfDialogFor: String? = null,
val showDeleteShelfDialogFor: String? = null,
val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
val booksSelectedForAdding: Set<String> = emptySet(),
val booksAvailableForAdding: List<RecentFileItem> = emptyList(),
val contextualActionShelfIds: Set<String> = emptySet(),
val currentUser: UserData? = null,
val isAuthMenuExpanded: Boolean = false,
val isProUser: Boolean = false,
val credits: Int = 0,
val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false,
val bannerMessage: BannerMessage? = null,
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
val isReplacingDevice: Boolean = false,
val isRequestingDrivePermission: Boolean = false,
val downloadingBookIds: Set<String> = emptySet(),
val uploadingBookIds: Set<String> = emptySet(),
val syncedFolders: List<SyncedFolder> = emptyList(),
val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "",
val isSearchActive: Boolean = false,
val isRefreshing: Boolean = false,
val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: List<RecentFileItem> = emptyList(),
val rawLibraryFiles: List<RecentFileItem> = emptyList(),
val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0,
val isTabsEnabled: Boolean = false,
val openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null,
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactor: Float = 1.0f,
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val allTags: List<TagEntity> = emptyList(),
val showTagSelectionDialogFor: Set<String> = emptySet(),
)
open class MainViewModel(application: Application) : AndroidViewModel(application) { open class MainViewModel(application: Application) : AndroidViewModel(application) {
private val appContext: Context = application.applicationContext private val appContext: Context = application.applicationContext
private val authRepository = AuthRepository(appContext) private val authRepository = AuthRepository(appContext)
@ -380,6 +182,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val _prefsUpdateFlow = MutableStateFlow(0L) private val _prefsUpdateFlow = MutableStateFlow(0L)
private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener
private val feedbackRepository = FeedbackRepository(appContext) private val feedbackRepository = FeedbackRepository(appContext)
private val libraryStateProjector = LibraryStateProjector(AndroidFolderPathResolver())
private var feedbackListener: Any? = null private var feedbackListener: Any? = null
private val importMutex = Mutex() private val importMutex = Mutex()
private val epubRecoveryMutex = Mutex() private val epubRecoveryMutex = Mutex()
@ -611,7 +414,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L
val input = connection.inputStream val input = connection.inputStream
val output = java.io.FileOutputStream(tempFile, isPartial) val output = FileOutputStream(tempFile, isPartial)
val data = ByteArray(16 * 1024) val data = ByteArray(16 * 1024)
var count: Int var count: Int
@ -744,7 +547,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
appContrastOption = try { appContrastOption = try {
AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name) AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name)
} catch (_: Exception) { AppContrastOption.STANDARD }, } catch (_: Exception) { AppContrastOption.STANDARD },
appTextDimFactor = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f), appTextDimFactorLight = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
appTextDimFactorDark = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null, appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null,
customAppThemes = loadCustomAppThemes(prefs) customAppThemes = loadCustomAppThemes(prefs)
) )
@ -808,299 +612,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
open val uiState: StateFlow<ReaderScreenState> = combine( open val uiState: StateFlow<ReaderScreenState> = combine(
_internalState, libraryFlow, tagFlow _internalState, libraryFlow, tagFlow
) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) -> ) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) ->
val tagsById = dbTags.associateBy { it.id } withContext(Dispatchers.Default) {
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> libraryStateProjector.project(
entry.value.mapNotNull { tagsById[it.tagId] } LibraryProjectionInput(
} state = internalState,
recentFilesFromDb = recentFilesFromDb,
val allLibraryFiles = recentFilesFromDb dbShelves = dbShelves,
.filterNot { it.bookId.endsWith("_reflow") } shelfRefs = shelfRefs,
.map { item -> dbTags = dbTags,
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList()) tagRefs = tagRefs
}
val query = internalState.searchQuery.trim()
val rawFilteredByQuery = if (query.isBlank()) {
allLibraryFiles
} else {
allLibraryFiles.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) }
}
}
val filters = internalState.libraryFilters
val libraryFiltered = rawFilteredByQuery.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
}
fun sortFiles(files: List<RecentFileItem>): List<RecentFileItem> {
return when (internalState.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 }
}
}
val sortedLibraryFiles = sortFiles(libraryFiltered)
val visibleRecentFiles = sortFiles(allLibraryFiles.filter { it.isRecent }).take(
if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
) )
val openTabsList = internalState.openTabIds.mapNotNull { tabId -> allLibraryFiles.find { it.bookId == tabId } }
val allShelves = mutableListOf<Shelf>()
val shelvedBookIds = mutableSetOf<String>()
val baseFilesMap = allLibraryFiles.associateBy { it.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, rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}
} else {
val bookIdsInShelf = shelfRefs.filter { it.shelfId == shelfEntity.id }.sortedBy { it.addedAt }.map { it.bookId }
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf)))
shelvedBookIds.addAll(bookIdsInShelf)
}
}
val tagShelves = dbTags.mapNotNull { tag ->
val taggedBooks = allLibraryFiles.filter { item -> item.tags.any { it.id == tag.id } }
if (taggedBooks.isEmpty()) {
null
} else {
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks))
}
}
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 = internalState.syncedFolders,
sortFiles = ::sortFiles
).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)))
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
val validShelfIds = allShelves.mapTo(mutableSetOf()) { it.id }
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in validShelfIds }
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in validShelfIds }
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
val currentShelfBookIds = allShelves
.find { it.id == viewingShelfId }
?.books
?.map { it.bookId }
?.toSet()
?: emptySet()
when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> unshelvedBooks
AddBooksSource.ALL_BOOKS -> allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
}
} else emptyList()
internalState.copy(
recentFiles = visibleRecentFiles,
allRecentFiles = sortedLibraryFiles,
rawLibraryFiles = allLibraryFiles,
viewingShelfId = viewingShelfId,
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
contextualActionShelfIds = selectedShelfIds,
contextualActionItems = internalState.contextualActionItems.mapNotNull { ctx -> allLibraryFiles.find { it.bookId == ctx.bookId } }.toSet(),
shelves = allShelves,
openTabs = openTabsList,
booksAvailableForAdding = booksAvailableForAdding,
allTags = dbTags
) )
}
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), started = SharingStarted.WhileSubscribed(5000),
initialValue = _internalState.value initialValue = _internalState.value
) )
private data class FolderShelfAccumulator(
val id: String,
val name: String,
val depth: Int,
val parentShelfId: String?,
val sortPath: String,
val books: MutableList<RecentFileItem> = mutableListOf(),
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
val childShelfIds: MutableList<String> = mutableListOf()
)
private fun buildFolderShelves(
allLibraryFiles: List<RecentFileItem>,
syncedFolders: List<SyncedFolder>,
sortFiles: (List<RecentFileItem>) -> List<RecentFileItem>
): List<Shelf> {
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
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),
directBooks = mutableListOf<RecentFileItem>().also { direct ->
direct.addAll(books.filter { getRelativeFolderSegments(it).isEmpty() })
},
childShelfIds = emptyList(),
depth = 0,
sortKey = "folder:${rootName.lowercase()}:"
)
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
books.forEach { book ->
rootAccumulator.books.add(book)
val segments = getRelativeFolderSegments(book)
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 {
nestedShelves.values.find { it.id == parentShelfId }?.childShelfIds?.add(shelfId)
}
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),
directBooks = sortFiles(shelf.directBooks),
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),
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
)
) + sortedNestedShelves
}
}
private fun getRelativeFolderSegments(item: RecentFileItem): List<String> {
val documentUriString = item.uriString ?: return emptyList()
val rootFolderUriString = item.sourceFolderUri ?: return emptyList()
return try {
val documentUri = documentUriString.toUri()
val rootFolderUri = rootFolderUriString.toUri()
val rootDocId = DocumentsContract.getTreeDocumentId(rootFolderUri)
val documentId = when {
DocumentsContract.isDocumentUri(appContext, documentUri) -> DocumentsContract.getDocumentId(documentUri)
DocumentsContract.isTreeUri(documentUri) -> DocumentsContract.getTreeDocumentId(documentUri)
else -> return emptyList()
}
val rootPath = rootDocId.substringAfter(':', "")
val documentPath = documentId.substringAfter(':', "")
val relativeDocumentPath = when {
rootPath.isBlank() -> documentPath
documentPath == rootPath -> ""
documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/")
else -> documentPath
}
relativeDocumentPath
.substringBeforeLast('/', "")
.split('/')
.map { Uri.decode(it).trim() }
.filter { it.isNotEmpty() }
} catch (e: Exception) {
Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}")
emptyList()
}
}
fun setTabsEnabled(enabled: Boolean) { fun setTabsEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) } prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) }
_internalState.update { it.copy(isTabsEnabled = enabled) } _internalState.update { it.copy(isTabsEnabled = enabled) }
@ -1529,7 +1058,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("ViewModel instance created.") Timber.d("ViewModel instance created.")
WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME) WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
// --- ADD THIS BLOCK ---
val locatorConverter = LocatorConverter( val locatorConverter = LocatorConverter(
bookCacheDao, bookCacheDao,
ProtoBuf { serializersModule = semanticBlockModule }, ProtoBuf { serializersModule = semanticBlockModule },
@ -1814,7 +1342,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
inputStream = inputStream, inputStream = inputStream,
bookId = bookId, bookId = bookId,
originalBookNameHint = displayName originalBookNameHint = displayName
) ?: throw Exception("MobiParser returned null. The file might be DRM-protected or invalid.") ) ?: throw Exception(
if (MobiParser.isNativeParserAvailable) {
"MobiParser returned null. The file might be DRM-protected or invalid."
} else {
MobiParser.nativeParserUnavailableMessage()
}
)
FileType.FB2 -> fb2Parser.createFb2Book( FileType.FB2 -> fb2Parser.createFb2Book(
inputStream = inputStream, inputStream = inputStream,
@ -1860,10 +1394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) { if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) {
return@withLock return@withLock
} }
if (latestState.selectedEpubBook?.extractionBasePath?.let { path -> if (latestState.selectedEpubBook?.hasReadableExtractedContent() == true) {
path.isNotBlank() && File(path).exists()
} == true
) {
return@withLock return@withLock
} }
@ -2822,7 +2353,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
} }
scanSyncedFolder() triggerFolderSyncWorker(
metadataOnly = false,
showFeedback = true,
targetFolderUriString = newFolder.uriString
)
showBanner(appContext.getString(R.string.banner_folder_added, name)) showBanner(appContext.getString(R.string.banner_folder_added, name))
@ -2835,6 +2370,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun removeSyncedFolder(folder: SyncedFolder) { fun removeSyncedFolder(folder: SyncedFolder) {
viewModelScope.launch { viewModelScope.launch {
val workManager = WorkManager.getInstance(appContext)
ReaderPerfLog.d("FolderRemove request folder=${folder.uriString}")
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
val currentFolders = _internalState.value.syncedFolders.toMutableList() val currentFolders = _internalState.value.syncedFolders.toMutableList()
currentFolders.removeAll { it.uriString == folder.uriString } currentFolders.removeAll { it.uriString == folder.uriString }
@ -2842,9 +2382,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(syncedFolders = currentFolders) } _internalState.update { it.copy(syncedFolders = currentFolders) }
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
try { try {
appContext.contentResolver.releasePersistableUriPermission( appContext.contentResolver.releasePersistableUriPermission(
folder.uriString.toUri(), folder.uriString.toUri(),
@ -2855,7 +2394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
if (currentFolders.isEmpty()) { if (currentFolders.isEmpty()) {
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME)
} }
showBanner(appContext.getString(R.string.banner_folder_removed)) showBanner(appContext.getString(R.string.banner_folder_removed))
@ -2870,16 +2409,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
triggerFolderSyncWorker(metadataOnly = false, showFeedback = true) triggerFolderSyncWorker(metadataOnly = false, showFeedback = true)
} }
private fun triggerFolderSyncWorker(metadataOnly: Boolean, showFeedback: Boolean) { private fun triggerFolderSyncWorker(
metadataOnly: Boolean,
showFeedback: Boolean,
targetFolderUriString: String? = null
) {
val folders = _internalState.value.syncedFolders val folders = _internalState.value.syncedFolders
if (folders.isEmpty()) return if (folders.isEmpty()) return
Timber.tag("FolderSync") val targetFolderName = targetFolderUriString
.d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)") ?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target }
ReaderPerfLog.d(
"FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " +
"metadataOnly=$metadataOnly feedback=$showFeedback"
)
val workManager = WorkManager.getInstance(appContext) val workManager = WorkManager.getInstance(appContext)
if (!metadataOnly) {
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
}
val data = androidx.work.Data.Builder() val data = androidx.work.Data.Builder()
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly).build() .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly)
.apply {
if (!targetFolderUriString.isNullOrBlank()) {
putString(FolderSyncWorker.KEY_TARGET_FOLDER_URI, targetFolderUriString)
}
}
.build()
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>().setInputData(data).build() val request = OneTimeWorkRequestBuilder<FolderSyncWorker>().setInputData(data).build()
@ -2958,7 +2514,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
scanSyncedFolder() triggerFolderSyncWorker(
metadataOnly = false,
showFeedback = true,
targetFolderUriString = folder.uriString
)
} }
} }
} }
@ -2966,12 +2526,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun disconnectAllSyncedFolders() { fun disconnectAllSyncedFolders() {
viewModelScope.launch { viewModelScope.launch {
val workManager = WorkManager.getInstance(appContext)
ReaderPerfLog.d("FolderRemove disconnect all folders=${_internalState.value.syncedFolders.size}")
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME)
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
val folders = _internalState.value.syncedFolders val folders = _internalState.value.syncedFolders
folders.forEach { folder -> folders.forEach { folder ->
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
try { try {
appContext.contentResolver.releasePersistableUriPermission( appContext.contentResolver.releasePersistableUriPermission(
folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION
@ -2985,8 +2550,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
remove(KEY_SYNCED_FOLDER_URI) remove(KEY_SYNCED_FOLDER_URI)
} }
_internalState.update { it.copy(syncedFolders = emptyList()) } _internalState.update { it.copy(syncedFolders = emptyList()) }
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
} }
} }
@ -3869,6 +3432,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1) val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet() val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet() val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
val validActiveBookCacheDirs = allDbIds.mapTo(mutableSetOf()) {
ImportedFileCache.activeBookDirName(it)
}
ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1)) ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1))
cacheDir.listFiles()?.forEach { file -> cacheDir.listFiles()?.forEach { file ->
@ -3879,10 +3445,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (deleted) Timber.d("Sweeper cleaned old temp file: $name") if (deleted) Timber.d("Sweeper cleaned old temp file: $name")
} }
} else if (ImportedFileCache.isActiveBookDir(name)) { } else if (ImportedFileCache.isActiveBookDir(name)) {
val bookId = name.removePrefix("imported_file_") val legacyBookId = name.removePrefix("imported_file_")
if (bookId !in allDbIds) { if (name !in validActiveBookCacheDirs && legacyBookId !in allDbIds && file.lastModified() < oneHourAgo) {
val deleted = file.deleteRecursively() val deleted = file.deleteRecursively()
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId") if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache: $name")
} }
} else if (name.startsWith("opds_stream_")) { } else if (name.startsWith("opds_stream_")) {
val bookIdHash = name.removePrefix("opds_stream_") val bookIdHash = name.removePrefix("opds_stream_")
@ -4274,6 +3840,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null
) { ) {
val openBookStartTime = System.currentTimeMillis() val openBookStartTime = System.currentTimeMillis()
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") .d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
@ -4341,12 +3908,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch { viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId) val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) {
launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") .d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
_internalState.update { _internalState.update {
@ -4357,6 +3918,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isLoading = false isLoading = false
) )
} }
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
persistReaderSession(bookId, type) persistReaderSession(bookId, type)
addFileToRecent( addFileToRecent(
uri, uri,
@ -4378,11 +3940,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) { } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) {
viewModelScope.launch { viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId) val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) {
launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") .d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
val locator = val locator =
@ -4405,6 +3962,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
initialHighlightsJson = recentItem?.highlightsJson, initialHighlightsJson = recentItem?.highlightsJson,
) )
} }
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
persistReaderSession(bookId, type) persistReaderSession(bookId, type)
if (!suppressNavigation) { if (!suppressNavigation) {
@ -4617,87 +4175,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML "text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
"text/plain" -> { "text/plain" -> {
if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) { resolveFileTypeFromName(fileName) ?: FileType.TXT
FileType.MD
} else if (fileName?.let {
it.endsWith(".csv", ignoreCase = true) || it.endsWith(".tsv", ignoreCase = true) ||
it.endsWith(".json", ignoreCase = true) || it.endsWith(".xml", ignoreCase = true) ||
it.endsWith(".log", ignoreCase = true) || it.endsWith(".java", ignoreCase = true) ||
it.endsWith(".kt", ignoreCase = true) || it.endsWith(".py", ignoreCase = true) ||
it.endsWith(".js", ignoreCase = true) || it.endsWith(".cpp", ignoreCase = true) ||
it.endsWith(".c", ignoreCase = true) || it.endsWith(".cs", ignoreCase = true) ||
it.endsWith(".rb", ignoreCase = true) || it.endsWith(".go", ignoreCase = true)
} == true) {
FileType.HTML
} else {
FileType.TXT
}
} }
else -> { else -> {
when { resolveFileTypeFromName(fileName)
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
fileName?.endsWith(".cbr", ignoreCase = true) == true -> FileType.CBR
fileName?.endsWith(".cb7", ignoreCase = true) == true -> FileType.CB7
fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
fileName?.endsWith(
".mobi",
ignoreCase = true
) == true || fileName?.endsWith(
".azw3",
ignoreCase = true
) == true || fileName?.endsWith(
".prc",
ignoreCase = true
) == true -> FileType.MOBI
fileName?.endsWith(
".md",
ignoreCase = true
) == true || fileName?.endsWith(
".markdown",
ignoreCase = true
) == true -> FileType.MD
fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT
fileName?.endsWith(
".fb2",
ignoreCase = true
) == true || fileName?.endsWith(
".fb2.zip",
ignoreCase = true
) == true -> FileType.FB2
fileName?.endsWith(
".html",
ignoreCase = true
) == true || fileName?.endsWith(
".xhtml",
ignoreCase = true
) == true || fileName?.endsWith(
".htm",
ignoreCase = true
) == true -> FileType.HTML
fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX
fileName?.endsWith(".odt", ignoreCase = true) == true -> FileType.ODT
fileName?.endsWith(".fodt", ignoreCase = true) == true -> FileType.FODT
fileName?.endsWith(".csv", ignoreCase = true) == true ||
fileName?.endsWith(".tsv", ignoreCase = true) == true ||
fileName?.endsWith(".json", ignoreCase = true) == true ||
fileName?.endsWith(".xml", ignoreCase = true) == true ||
fileName?.endsWith(".log", ignoreCase = true) == true ||
fileName?.endsWith(".java", ignoreCase = true) == true ||
fileName?.endsWith(".kt", ignoreCase = true) == true ||
fileName?.endsWith(".py", ignoreCase = true) == true ||
fileName?.endsWith(".js", ignoreCase = true) == true ||
fileName?.endsWith(".cpp", ignoreCase = true) == true ||
fileName?.endsWith(".c", ignoreCase = true) == true ||
fileName?.endsWith(".cs", ignoreCase = true) == true ||
fileName?.endsWith(".rb", ignoreCase = true) == true ||
fileName?.endsWith(".go", ignoreCase = true) == true -> FileType.HTML
else -> null
}
} }
} }
} }
@ -4739,7 +4221,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} else { } else {
throw Exception( throw Exception(
if (MobiParser.isNativeParserAvailable) {
"MobiParser returned null. The file might be DRM-protected or invalid." "MobiParser returned null. The file might be DRM-protected or invalid."
} else {
MobiParser.nativeParserUnavailableMessage()
}
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -4945,6 +4431,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun onRecentFileClicked(item: RecentFileItem) { fun onRecentFileClicked(item: RecentFileItem) {
ReaderPerfLog.d("FileOpen click bookId=${item.bookId} name=${item.displayName}")
val currentSelection = _internalState.value.contextualActionItems val currentSelection = _internalState.value.contextualActionItems
if (currentSelection.isNotEmpty()) { if (currentSelection.isNotEmpty()) {
Timber.d("Toggling selection for: ${item.displayName}") Timber.d("Toggling selection for: ${item.displayName}")
@ -5125,6 +4612,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun setMainScreenPage(page: Int) { fun setMainScreenPage(page: Int) {
val sanitizedPage = page.coerceIn(0, 1) val sanitizedPage = page.coerceIn(0, 1)
if (_internalState.value.mainScreenStartPage == sanitizedPage) return
_internalState.update { it.copy(mainScreenStartPage = sanitizedPage) } _internalState.update { it.copy(mainScreenStartPage = sanitizedPage) }
persistLibraryLandingState() persistLibraryLandingState()
} }
@ -5132,6 +4620,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun setLibraryScreenPage(page: Int) { fun setLibraryScreenPage(page: Int) {
val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3 val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3
val sanitizedPage = page.coerceIn(0, maxLibraryPage) val sanitizedPage = page.coerceIn(0, maxLibraryPage)
if (_internalState.value.libraryScreenStartPage == sanitizedPage) return
_internalState.update { _internalState.update {
it.copy(libraryScreenStartPage = sanitizedPage) it.copy(libraryScreenStartPage = sanitizedPage)
} }
@ -5701,9 +5190,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) } prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
} }
fun setAppTextDimFactor(factor: Float) { fun setAppTextDimFactorLight(factor: Float) {
_internalState.update { it.copy(appTextDimFactor = factor) } _internalState.update { it.copy(appTextDimFactorLight = factor) }
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR, factor) } prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, factor) }
}
fun setAppTextDimFactorDark(factor: Float) {
_internalState.update { it.copy(appTextDimFactorDark = factor) }
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, factor) }
} }
fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) { fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) {
@ -5948,6 +5442,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option" private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option"
private const val KEY_APP_SEED_COLOR = "app_seed_color" private const val KEY_APP_SEED_COLOR = "app_seed_color"
private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor" private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor"
private const val KEY_APP_TEXT_DIM_FACTOR_LIGHT = "app_text_dim_factor_light"
private const val KEY_APP_TEXT_DIM_FACTOR_DARK = "app_text_dim_factor_dark"
private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes" private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes"
val SUPPORTED_MIME_TYPES = arrayOf( val SUPPORTED_MIME_TYPES = arrayOf(

View file

@ -2,19 +2,20 @@
package com.aryan.reader package com.aryan.reader
import android.content.Context import android.content.Context
import android.provider.OpenableColumns
import android.util.Xml
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.ImportedFileCache
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import io.legere.pdfiumandroid.PdfiumCore import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.zip.ZipInputStream
class MetadataExtractionWorker( class MetadataExtractionWorker(
private val appContext: Context, private val appContext: Context,
@ -22,179 +23,271 @@ class MetadataExtractionWorker(
) : CoroutineWorker(appContext, workerParams) { ) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext) private val recentFilesRepository = RecentFilesRepository(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
companion object { companion object {
const val WORK_NAME = "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_PROGRESS_LOG_EVERY = 250
} }
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workerStart = ReaderPerfLog.nowNanos()
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val hasLegacy = prefs.contains("synced_folder_uri") val hasLegacy = prefs.contains("synced_folder_uri")
val hasNew = prefs.contains("synced_folders_list_json") val hasNew = prefs.contains("synced_folders_list_json")
if (!hasLegacy && !hasNew) { if (!hasLegacy && !hasNew) {
Timber.tag("MetadataWorker").w("No folders linked. Stopping.") ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
return@withContext Result.success() return@withContext Result.success()
} }
try { try {
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers() val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
if (filesToProcess.isEmpty()) { if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
return@withContext Result.success() return@withContext Result.success()
} }
Timber.tag("MetadataWorker").i("Starting background metadata extraction for ${filesToProcess.size} books.") ReaderPerfLog.i(
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}"
)
val pendingUpdates = mutableListOf<RecentFileItem>()
var processed = 0
var updated = 0
var failed = 0
suspend fun flushUpdates() {
if (pendingUpdates.isEmpty()) return
val flushStart = ReaderPerfLog.nowNanos()
recentFilesRepository.updateExtractedMetadata(pendingUpdates)
ReaderPerfLog.d(
"MetadataWorker DB flush rows=${pendingUpdates.size} elapsed=${ReaderPerfLog.elapsedMs(flushStart)}ms"
)
pendingUpdates.clear()
}
filesToProcess.forEach { item -> filesToProcess.forEach { item ->
if (isStopped) return@forEach if (isStopped) return@forEach
if (item.sourceFolderUri == null) return@forEach if (item.sourceFolderUri == null) return@forEach
val tempExtractionDir =
if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) {
ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata")
} else {
null
}
try { try {
val uri = item.uriString?.toUri() ?: return@forEach val uri = item.uriString?.toUri() ?: return@forEach
val type = item.type val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
val metadata = when (item.type) {
FileType.EPUB -> parseEpubTextMetadata(uri)
FileType.PDF -> parsePdfTextMetadata(uri)
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
FileType.FODT -> parseFlatXmlTextMetadata(uri)
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
else -> TextMetadata()
}
var coverPath: String? = null val title = sanitizeTitle(metadata.title)
var title: String? = null val author = sanitizeAuthor(metadata.author)
var author: String? = null val sizeChanged = fileSize > 0L && fileSize != item.fileSize
val titleChanged = title != null && title != item.title
val authorChanged = author != null && author != item.author
val fileSize = try { if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) {
pendingUpdates.add(
item.copy(
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
folderTextMetadataParsed = true
)
)
if (sizeChanged || titleChanged || authorChanged) {
updated++
}
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
}
processed++
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
ReaderPerfLog.d(
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed"
)
}
} catch (e: Exception) {
failed++
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}")
}
}
flushUpdates()
ReaderPerfLog.i(
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
)
return@withContext Result.success()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
return@withContext Result.failure()
}
}
private fun queryFileSize(uri: android.net.Uri): Long {
return try {
if (uri.scheme == "file") { if (uri.scheme == "file") {
uri.path?.let { File(it).length() } ?: 0L uri.path?.let { File(it).length() } ?: 0L
} else { } else {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor -> appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L
} else 0L } else {
0L
}
} ?: 0L } ?: 0L
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}") Timber.tag("MetadataWorker").e(e, "Failed to query file size for $uri")
0L 0L
} }
}
appContext.contentResolver.openInputStream(uri)?.use { inputStream -> private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
when (type) { val opfEntries = linkedMapOf<String, String>()
FileType.EPUB -> { var containerXml: String? = null
val book = epubParser.createEpubBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
FileType.MOBI -> {
val book = mobiParser.createMobiBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
book?.let {
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
}
}
FileType.PDF -> {
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
title = item.displayName
try { 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 ->
while (true) {
val entry = zip.nextEntry ?: break
if (!entry.isDirectory && entry.name == targetEntryName) {
val xml = zip.readTextEntry()
return parseXmlTextMetadata(xml)
}
zip.closeEntry()
}
}
}
return TextMetadata()
}
private fun parseFlatXmlTextMetadata(uri: android.net.Uri): TextMetadata {
val xml = appContext.contentResolver.openInputStream(uri)?.use { input ->
input.bufferedReader(Charsets.UTF_8).use { it.readText() }
} ?: return TextMetadata()
return parseXmlTextMetadata(xml)
}
private fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
return try {
val pdfiumCore = PdfiumCore(appContext) val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd) val pdfDocument = pdfiumCore.newDocument(pfd)
try {
val meta = pdfiumCore.getDocumentMeta(pdfDocument) val meta = pdfiumCore.getDocumentMeta(pdfDocument)
TextMetadata(title = meta.title, author = meta.author)
val extractedTitle = meta.title } finally {
if (!extractedTitle.isNullOrBlank()) {
title = extractedTitle
}
val extractedAuthor = meta.author
if (!extractedAuthor.isNullOrBlank()) {
author = extractedAuthor
}
pdfiumCore.closeDocument(pdfDocument) pdfiumCore.closeDocument(pdfDocument)
} }
} ?: TextMetadata()
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore") Timber.tag("MetadataWorker").e(e, "Failed to extract PDF text metadata")
TextMetadata()
} }
} }
FileType.ODT, FileType.FODT -> {
val book = odtParser.createOdtBook( private fun parseEpubRootfilePath(containerXml: String): String? {
inputStream = inputStream, val parser = Xml.newPullParser()
bookId = item.bookId, parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
originalBookNameHint = item.displayName, parser.setInput(containerXml.reader())
isFlat = type == FileType.FODT,
parseContent = false, var event = parser.eventType
extractionDirOverride = tempExtractionDir 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)
parser.setInput(xml.reader())
var title: String? = null
var author: String? = null
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG) {
val name = parser.name.substringAfter(':').lowercase()
when {
title == null && name == "title" -> title = parser.nextTextOrNull()
author == null && (name == "creator" || name == "initial-creator") -> {
author = parser.nextTextOrNull()
}
}
}
event = parser.next()
}
return TextMetadata(title = title, author = author)
}
private fun XmlPullParser.nextTextOrNull(): String? {
return try {
nextText()?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
private fun ZipInputStream.readTextEntry(): String {
return String(readBytes(), Charsets.UTF_8)
}
private fun sanitizeTitle(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
}
private fun sanitizeAuthor(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
}
private data class TextMetadata(
val title: String? = null,
val author: String? = null
) )
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
else -> {
title = item.displayName
}
}
}
if (coverPath != null || title != null || author != null || fileSize > 0L) {
val updatedItem = item.copy(
coverImagePath = coverPath ?: item.coverImagePath,
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
fileSize = if (fileSize > 0L) fileSize else item.fileSize
)
recentFilesRepository.addRecentFile(updatedItem)
Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)")
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
} finally {
try {
if (tempExtractionDir?.exists() == true) {
val deleted = tempExtractionDir.deleteRecursively()
if (deleted) {
Timber.tag("MetadataWorker")
.d("Cleaned up temporary extraction cache for ${item.bookId}")
}
}
} catch (e: Exception) {
Timber.tag("MetadataWorker")
.e(e, "Failed to clean up temporary extraction cache for ${item.bookId}")
}
}
}
return@withContext Result.success()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
return@withContext Result.failure()
}
}
} }

View file

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

View file

@ -0,0 +1,59 @@
package com.aryan.reader
import timber.log.Timber
object ReaderPerfLog {
const val TAG = "ReaderPerf"
fun nowNanos(): Long = System.nanoTime()
fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000L
fun d(message: String) {
Timber.tag(TAG).d(message)
}
fun i(message: String) {
Timber.tag(TAG).i(message)
}
fun w(message: String) {
Timber.tag(TAG).w(message)
}
inline fun <T> measure(
name: String,
minLogMs: Long = 16L,
details: () -> String = { "" },
block: () -> T
): T {
val start = nowNanos()
try {
return block()
} finally {
val elapsed = elapsedMs(start)
if (elapsed >= minLogMs) {
val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty()
d("$name took ${elapsed}ms$extra")
}
}
}
suspend inline fun <T> measureSuspend(
name: String,
minLogMs: Long = 16L,
details: () -> String = { "" },
crossinline block: suspend () -> T
): T {
val start = nowNanos()
try {
return block()
} finally {
val elapsed = elapsedMs(start)
if (elapsed >= minLogMs) {
val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty()
d("$name took ${elapsed}ms$extra")
}
}
}
}

View file

@ -0,0 +1,220 @@
package com.aryan.reader
import com.aryan.reader.data.BookShelfCrossRef
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.AddBooksSource as SharedAddBooksSource
import com.aryan.reader.shared.AppContrastOption as SharedAppContrastOption
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
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.ShelfRecord
import com.aryan.reader.shared.SortOrder as SharedSortOrder
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type.toSharedFileType(),
displayName = customName ?: displayName,
timestamp = timestamp,
title = title,
author = author,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
sourceFolder = sourceFolderUri,
seriesName = seriesName,
seriesIndex = seriesIndex,
tags = tags.map { it.toSharedTag() }
)
}
fun TagEntity.toSharedTag(): SharedTag {
return SharedTag(
id = id,
name = name,
color = color
)
}
fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
return ShelfRecord(
id = id,
name = name,
isSmart = isSmart,
smartRulesJson = smartRulesJson
)
}
fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
return SharedBookShelfRef(
bookId = bookId,
shelfId = shelfId,
addedAt = addedAt
)
}
fun ReaderScreenState.toSharedReaderScreenState(
rawBooks: List<RecentFileItem> = rawLibraryFiles,
dbTags: List<TagEntity> = allTags
): SharedReaderScreenState {
return SharedReaderScreenState(
selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
selectedFileType = selectedFileType?.toSharedFileType(),
isLoading = isLoading,
errorMessage = errorMessage,
renderMode = renderMode.toSharedRenderMode(),
sortOrder = sortOrder.toSharedSortOrder(),
viewingShelfId = viewingShelfId,
isAddingBooksToShelf = isAddingBooksToShelf,
showCreateShelfDialog = showCreateShelfDialog,
mainScreenStartPage = mainScreenStartPage,
libraryScreenStartPage = libraryScreenStartPage,
showRenameShelfDialogFor = showRenameShelfDialogFor,
showDeleteShelfDialogFor = showDeleteShelfDialogFor,
addBooksSource = addBooksSource.toSharedAddBooksSource(),
booksSelectedForAdding = booksSelectedForAdding,
selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
selectedShelfIds = contextualActionShelfIds,
isProUser = isProUser,
credits = credits,
isSyncEnabled = isSyncEnabled,
isFolderSyncEnabled = isFolderSyncEnabled,
bannerMessage = bannerMessage?.toSharedBannerMessage(),
downloadingBookIds = downloadingBookIds,
uploadingBookIds = uploadingBookIds,
syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() },
lastFolderScanTime = lastFolderScanTime,
hasUnreadFeedback = hasUnreadFeedback,
searchQuery = searchQuery,
isSearchActive = isSearchActive,
isRefreshing = isRefreshing,
reflowProgress = reflowProgress,
recentBooks = recentFiles.map { it.toSharedBookItem() },
libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters.toSharedLibraryFilters(),
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
openTabs = openTabs.map { it.toSharedBookItem() },
activeTabBookId = activeTabBookId,
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior,
useStrictFileFilter = useStrictFileFilter,
appThemeMode = appThemeMode.toSharedAppThemeMode(),
appContrastOption = appContrastOption.toSharedAppContrastOption(),
appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor,
customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() },
allTags = dbTags.map { it.toSharedTag() },
showTagSelectionDialogFor = showTagSelectionDialogFor
)
}
fun ReaderScreenState.toSharedLibraryProjectionInput(
recentFilesFromDb: List<RecentFileItem>,
dbShelves: List<ShelfEntity>,
shelfRefs: List<BookShelfCrossRef>,
dbTags: List<TagEntity>,
tagRefs: List<BookTagCrossRef>
): SharedLibraryProjectionInput {
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 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 FileType.toSharedFileType(): SharedFileType {
return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN)
}
private fun RenderMode.toSharedRenderMode(): SharedRenderMode {
return SharedRenderMode.valueOf(name)
}
private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
return SharedAddBooksSource.valueOf(name)
}
private fun SortOrder.toSharedSortOrder(): SharedSortOrder {
return SharedSortOrder.valueOf(name)
}
private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
return SharedReadStatusFilter.valueOf(name)
}
private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
return SharedLibraryFilters(
fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() },
sourceFolders = sourceFolders,
readStatus = readStatus.toSharedReadStatusFilter(),
tagIds = tagIds
)
}
private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
return SharedSyncedFolder(
uriString = uriString,
name = name,
lastScanTime = lastScanTime,
allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }
)
}
private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
return SharedBannerMessage(
message = message,
isError = isError,
isPersistent = isPersistent
)
}
private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
return SharedAppThemeMode.valueOf(name)
}
private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
return SharedAppContrastOption.valueOf(name)
}
private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
return SharedCustomAppTheme(
id = id,
name = name,
seedColor = seedColor
)
}

View file

@ -0,0 +1,42 @@
package com.aryan.reader
import androidx.annotation.StringRes
val AddBooksSource.labelRes: Int
@StringRes get() = when (this) {
AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved
AddBooksSource.ALL_BOOKS -> R.string.add_books_source_all_books
}
val AppThemeMode.labelRes: Int
@StringRes get() = when (this) {
AppThemeMode.SYSTEM -> R.string.app_theme_mode_system
AppThemeMode.LIGHT -> R.string.app_theme_mode_light
AppThemeMode.DARK -> R.string.app_theme_mode_dark
}
val AppContrastOption.labelRes: Int
@StringRes get() = when (this) {
AppContrastOption.STANDARD -> R.string.app_contrast_standard
AppContrastOption.MEDIUM -> R.string.app_contrast_medium
AppContrastOption.HIGH -> R.string.app_contrast_high
}
val SortOrder.labelRes: Int
@StringRes get() = when (this) {
SortOrder.RECENT -> R.string.sort_recent
SortOrder.TITLE_ASC -> R.string.sort_title_az
SortOrder.AUTHOR_ASC -> R.string.sort_author_az
SortOrder.PERCENT_ASC -> R.string.sort_percent_asc
SortOrder.PERCENT_DESC -> R.string.sort_percent_desc
SortOrder.SIZE_ASC -> R.string.sort_size_smallest
SortOrder.SIZE_DESC -> R.string.sort_size_biggest
}
val ReadStatusFilter.labelRes: Int
@StringRes get() = when (this) {
ReadStatusFilter.ALL -> R.string.read_status_all
ReadStatusFilter.UNREAD -> R.string.read_status_unread
ReadStatusFilter.IN_PROGRESS -> R.string.read_status_in_progress
ReadStatusFilter.COMPLETED -> R.string.read_status_completed
}

View file

@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class, TagEntity::class,
BookTagCrossRef::class BookTagCrossRef::class
], ],
version = 18, version = 19,
exportSchema = false exportSchema = false
) )
@TypeConverters(FileTypeConverter::class) @TypeConverters(FileTypeConverter::class)
@ -251,6 +251,12 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_18_19 = object : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN folderTextMetadataParsed INTEGER NOT NULL DEFAULT 0")
}
}
fun getDatabase(context: Context): AppDatabase { fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
@ -263,7 +269,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18 MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19
) )
.fallbackToDestructiveMigration(false) .fallbackToDestructiveMigration(false)
.build() .build()

View file

@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.aryan.reader.ReaderPerfLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
@ -16,6 +17,116 @@ object LocalSyncUtils {
private const val ANNOTATION_SUFFIX = "_annotations" private const val ANNOTATION_SUFFIX = "_annotations"
private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData" private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
private data class SyncFileEntry(
val name: String,
val uri: Uri
)
private fun syncSubfolderDocId(rootDocId: String): String {
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
rootDocId
} else if (rootDocId.endsWith(":")) {
rootDocId + SYNC_SUBFOLDER_NAME
} else {
"$rootDocId/$SYNC_SUBFOLDER_NAME"
}
}
private fun querySyncSubfolderFiles(context: Context, sourceFolderUri: Uri): List<SyncFileEntry> {
val start = ReaderPerfLog.nowNanos()
val resolver = context.contentResolver
val rootDocId = try {
DocumentsContract.getTreeDocumentId(sourceFolderUri)
} catch (_: Exception) {
ReaderPerfLog.w("LocalSync direct query skipped: invalid tree uri=$sourceFolderUri")
return emptyList()
}
val syncDocId = syncSubfolderDocId(rootDocId)
val syncDirUri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, syncDocId)
val documentProjection = arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE)
val isSyncDir = try {
resolver.query(syncDirUri, documentProjection, null, null, null)?.use { cursor ->
cursor.moveToFirst() &&
cursor.getString(0) == DocumentsContract.Document.MIME_TYPE_DIR
} == true
} catch (_: Exception) {
false
}
if (!isSyncDir) {
ReaderPerfLog.d(
"LocalSync direct query sync dir missing rootDocId=$rootDocId syncDocId=$syncDocId"
)
return querySyncSubfolderFilesFallback(context, sourceFolderUri, "missing-direct-sync-dir")
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(sourceFolderUri, syncDocId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val entries = mutableListOf<SyncFileEntry>()
try {
resolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val mimeType = cursor.getString(mimeCol)
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) continue
val name = cursor.getString(nameCol) ?: continue
val docId = cursor.getString(idCol) ?: continue
entries.add(
SyncFileEntry(
name = name,
uri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, docId)
)
)
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to query sync subfolder directly")
return querySyncSubfolderFilesFallback(context, sourceFolderUri, "direct-query-error")
}
ReaderPerfLog.d(
"LocalSync direct query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms syncDocId=$syncDocId"
)
return entries
}
private fun querySyncSubfolderFilesFallback(
context: Context,
sourceFolderUri: Uri,
reason: String
): List<SyncFileEntry> {
val start = ReaderPerfLog.nowNanos()
return try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri)
val syncDir = rootTree?.findFile(SYNC_SUBFOLDER_NAME)
if (syncDir == null || !syncDir.isDirectory) {
ReaderPerfLog.w("LocalSync fallback query found no sync dir reason=$reason uri=$sourceFolderUri")
emptyList()
} else {
val entries = syncDir.listFiles()
.asSequence()
.filter { it.isFile }
.mapNotNull { file ->
val name = file.name ?: return@mapNotNull null
SyncFileEntry(name = name, uri = file.uri)
}
.toList()
ReaderPerfLog.d(
"LocalSync fallback query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms reason=$reason"
)
entries
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to query sync subfolder fallback")
emptyList()
}
}
private fun getOrCreateSyncDir(rootTree: DocumentFile): DocumentFile? { private fun getOrCreateSyncDir(rootTree: DocumentFile): DocumentFile? {
val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME) val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME)
if (existing != null && existing.isDirectory) return existing if (existing != null && existing.isDirectory) return existing
@ -209,22 +320,22 @@ object LocalSyncUtils {
suspend fun preloadAnnotationSidecars( suspend fun preloadAnnotationSidecars(
context: Context, context: Context,
rootTree: DocumentFile sourceFolderUri: Uri
): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) { ): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, Pair<Long, String>>() val results = mutableMapOf<String, Pair<Long, String>>()
try { try {
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri)
if (syncDir == null || !syncDir.isDirectory) return@withContext results .filter { file ->
val bookIds = syncDir.listFiles() val name = file.name
.mapNotNull { extractAnnotationBookId(it.name) } extractAnnotationBookId(name) != null &&
.toSet() !name.contains(".syncthing.")
for (bookId in bookIds) {
val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
if (best != null) {
results[bookId] = best
} }
.groupBy { file -> extractAnnotationBookId(file.name).orEmpty() }
for ((bookId, files) in groupedFiles) {
val best = resolveAnnotationConflictsReadOnly(context, bookId, files)
if (best != null) results[bookId] = best
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars") Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
@ -233,6 +344,44 @@ object LocalSyncUtils {
return@withContext results return@withContext results
} }
private fun resolveAnnotationConflictsReadOnly(
context: Context,
bookId: String,
files: List<SyncFileEntry>
): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
var bestTs = -1L
var bestData: String? = null
for (file in files) {
val name = file.name
if (!((name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
name.endsWith(".json") &&
!name.endsWith(".tmp") &&
!name.contains(".syncthing."))
) {
continue
}
try {
val content = context.contentResolver.openInputStream(file.uri)?.use {
it.bufferedReader().readText()
} ?: continue
val json = JSONObject(content)
val ts = json.optLong("timestamp", 0L)
val data = json.optJSONObject("data")?.toString()
if (data != null && ts > bestTs) {
bestTs = ts
bestData = data
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: $name")
}
}
return bestData?.let { bestTs to it }
}
suspend fun getAnnotationSidecar( suspend fun getAnnotationSidecar(
context: Context, context: Context,
sourceFolderUri: Uri, sourceFolderUri: Uri,
@ -253,12 +402,13 @@ object LocalSyncUtils {
private fun resolveAndCleanAnnotationConflicts( private fun resolveAndCleanAnnotationConflicts(
context: Context, context: Context,
syncDir: DocumentFile, syncDir: DocumentFile,
bookId: String bookId: String,
knownFiles: List<DocumentFile>? = null
): Pair<Long, String>? { ): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}" val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
val allFiles = syncDir.listFiles() val allFiles = knownFiles ?: syncDir.listFiles().asList()
val candidates = allFiles.filter { file -> val candidates = allFiles.filter { file ->
val name = file.name ?: "" val name = file.name ?: ""
@ -475,21 +625,17 @@ object LocalSyncUtils {
val finalResults = mutableMapOf<String, FolderBookMetadata>() val finalResults = mutableMapOf<String, FolderBookMetadata>()
try { try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
if (syncDir == null || !syncDir.isDirectory) return@withContext finalResults
val allFiles = syncDir.listFiles()
val groupedFiles = allFiles val groupedFiles = allFiles
.filter { .filter {
val name = it.name ?: "" val name = it.name
(name.endsWith(".json") || name.contains(".sync-conflict")) && (name.endsWith(".json") || name.contains(".sync-conflict")) &&
!name.contains(ANNOTATION_SUFFIX) && !name.contains(ANNOTATION_SUFFIX) &&
!name.endsWith(".tmp") && !name.endsWith(".tmp") &&
!name.contains(".syncthing.") !name.contains(".syncthing.")
} }
.groupBy { file -> .groupBy { file ->
var name = file.name ?: "" var name = file.name
if (name.startsWith(".")) name = name.substring(1) if (name.startsWith(".")) name = name.substring(1)
if (name.contains(".sync-conflict")) { if (name.contains(".sync-conflict")) {
name.substringBefore(".sync-conflict") name.substringBefore(".sync-conflict")
@ -499,17 +645,47 @@ object LocalSyncUtils {
} }
groupedFiles.forEach { (bookId, files) -> groupedFiles.forEach { (bookId, files) ->
val winner = resolveAndCleanConflicts(context, files, bookId) val winner = resolveMetadataConflictsReadOnly(context, files, bookId)
if (winner != null) { if (winner != null) {
finalResults[bookId] = winner finalResults[bookId] = winner
} }
} }
Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records from root.") Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.")
ReaderPerfLog.d(
"LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}"
)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Error scanning root folder for metadata") Timber.tag(TAG).e(e, "Error scanning sync data folder for metadata")
ReaderPerfLog.w("LocalSync metadata read failed uri=$sourceFolderUri")
} }
return@withContext finalResults return@withContext finalResults
} }
private fun resolveMetadataConflictsReadOnly(
context: Context,
files: List<SyncFileEntry>,
bookId: String
): FolderBookMetadata? {
var bestMeta: FolderBookMetadata? = null
for (file in files) {
try {
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
input.bufferedReader().use { it.readText() }
}
if (jsonString != null) {
val meta = FolderBookMetadata.fromJsonString(jsonString)
if (meta.bookId == bookId &&
(bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp)
) {
bestMeta = meta
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
}
}
return bestMeta
}
} }

View file

@ -90,8 +90,59 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)") @Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long) suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
@Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0") @Query("""
suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity> SELECT * FROM recent_files
WHERE sourceFolderUri IS NOT NULL
AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
AND folderTextMetadataParsed = 0
""")
suspend fun getFolderBooksNeedingTextMetadata(): List<RecentFileEntity>
@Query("""
SELECT * FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
AND folderTextMetadataParsed = 0
""")
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List<RecentFileEntity>
@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
""")
suspend fun countFolderBooksNeedingTextMetadata(): Int
@Query("""
SELECT COUNT(*) FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
AND folderTextMetadataParsed = 0
""")
suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int
@Query("""
UPDATE recent_files
SET
coverImagePath = COALESCE(:coverImagePath, coverImagePath),
title = COALESCE(:title, title),
author = COALESCE(:author, author),
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
folderTextMetadataParsed = 1
WHERE bookId = :bookId
""")
suspend fun updateExtractedMetadata(
bookId: String,
coverImagePath: String?,
title: String?,
author: String?,
fileSize: Long
)
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL") @Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
suspend fun detachAllFolderBooks() suspend fun detachAllFolderBooks()

View file

@ -54,7 +54,8 @@ data class RecentFileEntity(
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long, @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
@ColumnInfo(defaultValue = "NULL") val seriesName: String?, @ColumnInfo(defaultValue = "NULL") val seriesName: String?,
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?, @ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
@ColumnInfo(defaultValue = "NULL") val description: String? @ColumnInfo(defaultValue = "NULL") val description: String?,
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean
) )
data class RecentFileSummary( data class RecentFileSummary(

View file

@ -19,9 +19,7 @@
*/ */
package com.aryan.reader.data package com.aryan.reader.data
import android.net.Uri
import com.aryan.reader.FileType import com.aryan.reader.FileType
import androidx.core.net.toUri
data class RecentFileItem( data class RecentFileItem(
val bookId: String, val bookId: String,
@ -51,10 +49,9 @@ data class RecentFileItem(
val seriesName: String? = null, val seriesName: String? = null,
val seriesIndex: Double? = null, val seriesIndex: Double? = null,
val description: String? = null, val description: String? = null,
val folderTextMetadataParsed: Boolean = false,
val tags: List<TagEntity> = emptyList() val tags: List<TagEntity> = emptyList()
) { )
fun getUri(): Uri? = uriString?.toUri()
}
fun RecentFileEntity.toRecentFileItem(): RecentFileItem { fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
return RecentFileItem( return RecentFileItem(
@ -84,7 +81,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
fileSize = this.fileSize, fileSize = this.fileSize,
seriesName = this.seriesName, seriesName = this.seriesName,
seriesIndex = this.seriesIndex, seriesIndex = this.seriesIndex,
description = this.description description = this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed
) )
} }
@ -116,7 +114,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
fileSize = this.fileSize, fileSize = this.fileSize,
seriesName = this.seriesName, seriesName = this.seriesName,
seriesIndex = this.seriesIndex, seriesIndex = this.seriesIndex,
description = this.description description = this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed
) )
} }

View file

@ -0,0 +1,6 @@
package com.aryan.reader.data
import android.net.Uri
import androidx.core.net.toUri
fun RecentFileItem.getUri(): Uri? = uriString?.toUri()

View file

@ -24,6 +24,7 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.ReaderPerfLog
import timber.log.Timber import timber.log.Timber
import com.aryan.reader.BookImporter import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.room.withTransaction
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PdfAnnotationRepository
@ -47,7 +49,8 @@ private const val COVER_CACHE_DIR = "cover_cache"
class RecentFilesRepository(private val context: Context) { class RecentFilesRepository(private val context: Context) {
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao() private val database = AppDatabase.getDatabase(context)
private val recentFileDao = database.recentFileDao()
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR) private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
private val bookImporter = BookImporter(context) private val bookImporter = BookImporter(context)
@ -57,10 +60,10 @@ class RecentFilesRepository(private val context: Context) {
private val pdfTextBoxRepository = PdfTextBoxRepository(context) private val pdfTextBoxRepository = PdfTextBoxRepository(context)
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context) private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves() val activeShelvesFlow = database.shelfDao().getAllActiveShelves()
val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs() val shelfCrossRefsFlow = database.shelfDao().getAllBookShelfCrossRefs()
val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags() val tagsFlow = database.tagDao().getAllTags()
val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs() val tagCrossRefsFlow = database.tagDao().getAllBookTagCrossRefs()
init { init {
if (!coverCacheDir.exists()) { if (!coverCacheDir.exists()) {
@ -184,7 +187,8 @@ class RecentFilesRepository(private val context: Context) {
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize, fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
seriesName = item.seriesName ?: existingItem.seriesName, seriesName = item.seriesName ?: existingItem.seriesName,
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex, seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
description = item.description ?: existingItem.description description = item.description ?: existingItem.description,
folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
) )
} else { } else {
item.toRecentFileEntity() item.toRecentFileEntity()
@ -355,12 +359,25 @@ class RecentFilesRepository(private val context: Context) {
} }
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) { suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
val filesToRemove = getFilesBySourceFolder(folderUriString) val start = ReaderPerfLog.nowNanos()
if (filesToRemove.isNotEmpty()) { val filesToRemove = recentFileDao.getFilesBySourceFolder(folderUriString)
Timber.d("DeleteDebug: Cascading deletion for ${filesToRemove.size} files from folder.")
deleteFilePermanently(filesToRemove.map { it.bookId })
} else {
recentFileDao.deleteFilesBySourceFolder(folderUriString) recentFileDao.deleteFilesBySourceFolder(folderUriString)
ReaderPerfLog.i(
"FolderRemove db delete books=${filesToRemove.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms folder=$folderUriString"
)
filesToRemove.forEach { item ->
item.coverImagePath?.let { deleteCachedCover(it) }
try {
pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete()
pdfRichTextRepository.getFileForSync(item.bookId).delete()
pageLayoutRepository.getLayoutFile(item.bookId).delete()
pdfTextBoxRepository.getFileForSync(item.bookId).delete()
pdfHighlightRepository.getFileForSync(item.bookId).delete()
ImportedFileCache.clearBookCache(context, item.bookId)
} catch (e: Exception) {
Timber.e(e, "Error during local cleanup for detached folder book ${item.bookId}")
}
} }
} }
@ -381,8 +398,40 @@ class RecentFilesRepository(private val context: Context) {
} }
} }
suspend fun getFolderBooksWithoutCovers(): List<RecentFileItem> = withContext(Dispatchers.IO) { suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() } val entities = if (sourceFolderUri.isNullOrBlank()) {
recentFileDao.getFolderBooksNeedingTextMetadata()
} else {
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri)
}
return@withContext entities.map { it.toRecentFileItem() }
}
suspend fun hasFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): Boolean = withContext(Dispatchers.IO) {
val count = if (sourceFolderUri.isNullOrBlank()) {
recentFileDao.countFolderBooksNeedingTextMetadata()
} else {
recentFileDao.countFolderBooksNeedingTextMetadata(sourceFolderUri)
}
return@withContext count > 0
}
suspend fun updateExtractedMetadata(items: List<RecentFileItem>) = withContext(Dispatchers.IO) {
if (items.isEmpty()) return@withContext
items.chunked(300).forEach { chunk ->
database.withTransaction {
chunk.forEach { item ->
recentFileDao.updateExtractedMetadata(
bookId = item.bookId,
coverImagePath = item.coverImagePath,
title = item.title,
author = item.author,
fileSize = item.fileSize
)
}
}
}
Timber.tag(ReaderPerfLog.TAG).d("Metadata extraction batch updated ${items.size} rows.")
} }
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) { suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {

View file

@ -23,6 +23,7 @@ import android.graphics.Bitmap
import com.aryan.reader.epub.EpubParser.EpubPageTarget import com.aryan.reader.epub.EpubParser.EpubPageTarget
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.io.File
@Serializable @Serializable
data class EpubTocEntry( data class EpubTocEntry(
@ -51,3 +52,14 @@ data class EpubBook(
val seriesIndex: Double? = null, val seriesIndex: Double? = null,
val description: String? = null, val description: String? = null,
) )
fun EpubBook.hasReadableExtractedContent(): Boolean {
if (extractionBasePath.isBlank()) return false
val extractionDir = File(extractionBasePath)
if (!extractionDir.isDirectory) return false
if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true
return chapters.all { chapter ->
File(extractionDir, chapter.htmlFilePath).isFile
}
}

View file

@ -9,8 +9,12 @@ object ImportedFileCache {
private const val TEMP_PREFIX = "imported_file_tmp_" private const val TEMP_PREFIX = "imported_file_tmp_"
private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+") private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+")
fun activeBookDirName(bookId: String): String {
return "$ACTIVE_PREFIX${bookMarker(bookId)}"
}
fun activeBookDir(context: Context, bookId: String): File { fun activeBookDir(context: Context, bookId: String): File {
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId") return File(context.cacheDir, activeBookDirName(bookId))
} }
fun prepareActiveBookDir(context: Context, bookId: String): File { fun prepareActiveBookDir(context: Context, bookId: String): File {
@ -39,6 +43,7 @@ object ImportedFileCache {
fun clearBookCache(context: Context, bookId: String) { fun clearBookCache(context: Context, bookId: String) {
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively() activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
legacyActiveBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
clearTemporaryBookDirs(context, bookId) clearTemporaryBookDirs(context, bookId)
} }
@ -69,6 +74,10 @@ object ImportedFileCache {
return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name) return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name)
} }
private fun legacyActiveBookDir(context: Context, bookId: String): File {
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
}
private fun bookMarker(bookId: String): String { private fun bookMarker(bookId: String): String {
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40) val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
val hash = bookId.hashCode().toLong() and 0xffffffffL val hash = bookId.hashCode().toLong() and 0xffffffffL

View file

@ -109,10 +109,20 @@ class MobiParser(private val context: Context) {
private external fun parseMobiFile(filePath: String): ParsedMobiData? private external fun parseMobiFile(filePath: String): ParsedMobiData?
companion object { companion object {
init { private val nativeLoadError: Throwable? = try {
System.loadLibrary("mobi") System.loadLibrary("mobi")
System.loadLibrary("native-lib") System.loadLibrary("native-lib")
null
} catch (t: Throwable) {
Timber.e(t, "MOBI native parser is unavailable on this device.")
t
} }
val isNativeParserAvailable: Boolean
get() = nativeLoadError == null
fun nativeParserUnavailableMessage(): String =
nativeLoadError?.message ?: "MOBI native parser is unavailable on this device."
} }
suspend fun createMobiBook( suspend fun createMobiBook(
@ -122,6 +132,11 @@ class MobiParser(private val context: Context) {
parseContent: Boolean = true, parseContent: Boolean = true,
extractionDirOverride: File? = null extractionDirOverride: File? = null
): EpubBook? = withContext(Dispatchers.IO) { ): EpubBook? = withContext(Dispatchers.IO) {
if (!isNativeParserAvailable) {
Timber.e("Skipping MOBI parsing: ${nativeParserUnavailableMessage()}")
return@withContext null
}
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir) val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try { try {
tempFile.outputStream().use { output -> tempFile.outputStream().use { output ->

View file

@ -35,6 +35,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.safety.Safelist
import org.zwobble.mammoth.DocumentConverter import org.zwobble.mammoth.DocumentConverter
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -52,6 +54,15 @@ class SingleFileImporter(private val context: Context) {
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
} }
private val htmlSafelist = Safelist.relaxed()
.addTags("article", "aside", "details", "div", "figcaption", "figure", "footer", "header", "main", "section", "summary")
.addAttributes(":all", "class", "dir", "id", "lang", "title")
.addAttributes("a", "name", "target")
.addProtocols("a", "href", "http", "https", "mailto", "tel", "#")
.addProtocols("img", "src", "http", "https", "data", "file", "content")
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
suspend fun importSingleFile( suspend fun importSingleFile(
inputStream: InputStream, inputStream: InputStream,
type: FileType, type: FileType,
@ -61,8 +72,9 @@ class SingleFileImporter(private val context: Context) {
): EpubBook { ): EpubBook {
val lowerHint = originalBookNameHint.lowercase() val lowerHint = originalBookNameHint.lowercase()
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") ||
val isCodeOrData = listOf(".json", ".xml", ".log", ".java", ".kt", ".py", ".js", ".cpp", ".c", ".cs", ".rb", ".go").any { lowerHint.endsWith(it) } lowerHint.endsWith(".csv.txt") || lowerHint.endsWith(".tsv.txt")
val isCodeOrData = com.aryan.reader.isCodeOrDataFileName(originalBookNameHint)
if (type == FileType.HTML && (isCsv || isCodeOrData)) { if (type == FileType.HTML && (isCsv || isCodeOrData)) {
return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv) return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv)
@ -99,7 +111,7 @@ class SingleFileImporter(private val context: Context) {
writer.write("</head>\n<body>\n<pre><code>\n") writer.write("</head>\n<body>\n<pre><code>\n")
} }
val delimiter = if (originalBookNameHint.lowercase().endsWith(".tsv")) '\t' else ',' val delimiter = if (originalBookNameHint.lowercase().let { it.endsWith(".tsv") || it.endsWith(".tsv.txt") }) '\t' else ','
inputStream.bufferedReader().use { reader -> inputStream.bufferedReader().use { reader ->
var line = reader.readLine() var line = reader.readLine()
@ -177,11 +189,7 @@ class SingleFileImporter(private val context: Context) {
) )
} }
File(context.cacheDir, "imported_file_$bookId").deleteRecursively() val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val metadataFile = File(extractionDir, "book_metadata.json") val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) { if (metadataFile.exists()) {
@ -245,7 +253,7 @@ class SingleFileImporter(private val context: Context) {
val chapterTitle = "Page $pageNum" val chapterTitle = "Page $pageNum"
val document = parser.parse(rawText) val document = parser.parse(rawText)
val htmlBody = renderer.render(document) val htmlBody = sanitizeHtmlFragment(renderer.render(document))
val fileName = "page_$pageNum.html" val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName) val file = File(extractionDir, fileName)
@ -315,11 +323,7 @@ class SingleFileImporter(private val context: Context) {
) )
} }
File(context.cacheDir, "imported_file_$bookId").deleteRecursively() val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val metadataFile = File(extractionDir, "book_metadata.json") val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) { if (metadataFile.exists()) {
@ -480,11 +484,7 @@ class SingleFileImporter(private val context: Context) {
) )
} }
File(context.cacheDir, "imported_file_$bookId").deleteRecursively() val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val metadataFile = File(extractionDir, "book_metadata.json") val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) { if (metadataFile.exists()) {
@ -507,6 +507,7 @@ class SingleFileImporter(private val context: Context) {
val chapters = mutableListOf<EpubChapter>() val chapters = mutableListOf<EpubChapter>()
inputStream.bufferedReader().use { reader -> inputStream.bufferedReader().use { reader ->
var inScript = false
var inStyle = false var inStyle = false
var inBody = false var inBody = false
var pageNum = 1 var pageNum = 1
@ -516,6 +517,19 @@ class SingleFileImporter(private val context: Context) {
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
val trimmed = line!!.trim() val trimmed = line!!.trim()
if (inScript) {
if (trimmed.contains("</script", ignoreCase = true)) {
inScript = false
}
continue
}
if (trimmed.startsWith("<script", ignoreCase = true)) {
if (!trimmed.contains("</script", ignoreCase = true)) {
inScript = true
}
continue
}
if (!inBody) { if (!inBody) {
if (trimmed.startsWith("<title", ignoreCase = true)) { if (trimmed.startsWith("<title", ignoreCase = true)) {
val t = trimmed.substringAfter(">").substringBefore("</title>") val t = trimmed.substringAfter(">").substringBefore("</title>")
@ -632,6 +646,10 @@ class SingleFileImporter(private val context: Context) {
return@withContext book return@withContext book
} }
private fun sanitizeHtmlFragment(html: String): String {
return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
}
private suspend fun parseDocx( private suspend fun parseDocx(
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String, originalBookNameHint: String,
@ -654,11 +672,7 @@ class SingleFileImporter(private val context: Context) {
) )
} }
File(context.cacheDir, "imported_file_$bookId").deleteRecursively() val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val metadataFile = File(extractionDir, "book_metadata.json") val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) { if (metadataFile.exists()) {
@ -754,8 +768,9 @@ class SingleFileImporter(private val context: Context) {
val chapterTitle = if (pageNum > 1 || bodyContent.contains("<page-break")) "Page $pageNum" else title val chapterTitle = if (pageNum > 1 || bodyContent.contains("<page-break")) "Page $pageNum" else title
val fileName = "page_$pageNum.html" val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName) val file = File(extractionDir, fileName)
val sanitizedBodyContent = sanitizeHtmlFragment(bodyContent)
val fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", "&quot;")}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${bodyContent.trim()}\n</body>\n</html>" val fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", "&quot;")}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${sanitizedBodyContent.trim()}\n</body>\n</html>"
file.writeText(fullHtml) file.writeText(fullHtml)

View file

@ -26,10 +26,8 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.util.Base64
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebSettings import android.webkit.WebSettings
@ -83,13 +81,12 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderTexture import com.aryan.reader.getReaderTextureDataUri
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
import java.io.BufferedReader import java.io.BufferedReader
import java.io.ByteArrayOutputStream
import java.io.InputStreamReader import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV" private const val TAG_LINK_NAV = "LINK_NAV"
@ -351,6 +348,7 @@ fun ChapterWebView(
currentParagraphGap: Float, currentParagraphGap: Float,
currentImageSize: Float, currentImageSize: Float,
currentHorizontalMargin: Float, currentHorizontalMargin: Float,
currentVerticalMargin: Float,
onChapterInitiallyScrolled: () -> Unit, onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTap: () -> Unit, onTap: () -> Unit,
@ -386,7 +384,8 @@ fun ChapterWebView(
activeHighlightPalette: List<HighlightColor>, activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit, onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit, onInternalLinkClick: (String) -> Unit,
activeTextureId: String? = null activeTextureId: String? = null,
activeTextureAlpha: Float = 0.55f
) { ) {
Timber.d( Timber.d(
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget" "RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
@ -406,18 +405,9 @@ fun ChapterWebView(
val textureBase64 by remember(activeTextureId) { val textureBase64 by remember(activeTextureId) {
mutableStateOf( mutableStateOf(
activeTextureId?.let { id -> getReaderTextureDataUri(context, activeTextureId)
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
val bmp = BitmapFactory.decodeResource(context.resources, resId)
val out = ByteArrayOutputStream()
bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
"data:image/png;base64," + Base64.encodeToString(
out.toByteArray(),
Base64.NO_WRAP
) )
} }
})
}
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady) val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated) val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated)
@ -476,10 +466,10 @@ fun ChapterWebView(
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64) { LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64, activeTextureAlpha) {
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb())) val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb())) val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", null) localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});", null)
} }
key( key(
@ -489,6 +479,7 @@ fun ChapterWebView(
currentParagraphGap, currentParagraphGap,
currentImageSize, currentImageSize,
currentHorizontalMargin, currentHorizontalMargin,
currentVerticalMargin,
currentFontFamily, currentFontFamily,
currentTextAlign currentTextAlign
) { ) {
@ -737,7 +728,7 @@ fun ChapterWebView(
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb())) val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
val textHex = val textHex =
String.format("#%06X", (0xFFFFFF and effectiveText.toArgb())) String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});",
null) null)
val fragmentsJson = org.json.JSONArray(tocFragments).toString() val fragmentsJson = org.json.JSONArray(tocFragments).toString()
@ -782,7 +773,7 @@ fun ChapterWebView(
} }
view?.evaluateJavascript( view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);", "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null null
) )
@ -946,7 +937,7 @@ fun ChapterWebView(
) )
webView.evaluateJavascript( webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);", "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null null
) )

View file

@ -41,8 +41,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.BuildConfig
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.areReaderAiFeaturesEnabled
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -92,7 +92,7 @@ fun DictionarySettingsDialog(
) )
// ── Dictionary ── // ── Dictionary ──
if (BuildConfig.FLAVOR != "oss") { if (areReaderAiFeaturesEnabled(context)) {
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,

View file

@ -32,11 +32,14 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiFeature
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiHubBottomSheet import com.aryan.reader.AiHubBottomSheet
import com.aryan.reader.BuildConfig
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.callByokTextAi
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.fetchRecap import com.aryan.reader.fetchRecap
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
@ -55,6 +58,7 @@ import java.net.URL
*/ */
suspend fun summarizeBookContent( suspend fun summarizeBookContent(
content: String, content: String,
context: Context,
authToken: String?, authToken: String?,
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> }, onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
onUpdate: (String) -> Unit, onUpdate: (String) -> Unit,
@ -68,6 +72,27 @@ suspend fun summarizeBookContent(
} }
Timber.d("Starting summarization for content of length: ${content.length}") Timber.d("Starting summarization for content of length: ${content.length}")
@Suppress("KotlinConstantConditions")
if (BuildConfig.FLAVOR == "oss") {
if (BuildConfig.IS_OFFLINE) {
onError("AI features are unavailable in the offline OSS build.")
onFinish()
return
}
callByokTextAi(
context = context,
feature = AiFeature.SUMMARIZE,
systemInstruction = "You are an expert in analyzing written content. Provide a concise, easy-to-read summary of the provided chapter. Identify the main ideas, plot points, and themes. Do not add a preamble like 'Here is the summary:'",
userPrompt = content,
temperature = 0.2,
maxTokens = 8192,
onUpdate = onUpdate,
onError = onError
)
onFinish()
return
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
try { try {
@ -196,6 +221,7 @@ suspend fun executeRecapLogic(
summarizeBookContent( summarizeBookContent(
content = textToSummarize, content = textToSummarize,
context = context,
authToken = authToken, authToken = authToken,
onUsageReceived = { cost, _ -> onUsageReceived = { cost, _ ->
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits") Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")

View file

@ -123,7 +123,9 @@ fun VerticalScrollbar(
if (viewportRatio >= 1f) return@derivedStateOf null if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2) val maxThumbHeight = viewportHeight / 2f
val minThumbHeight = minOf(80f, maxThumbHeight)
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
val firstItemIndex = listState.firstVisibleItemIndex val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset val firstItemOffset = listState.firstVisibleItemScrollOffset

View file

@ -19,7 +19,7 @@
*/ */
// EpubReaderScreen.kt // EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead", @file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants" "UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
) )
package com.aryan.reader.epubreader package com.aryan.reader.epubreader
@ -35,6 +35,8 @@ import android.graphics.Bitmap
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.view.RoundedCorner
import android.view.View
import android.webkit.WebView import android.webkit.WebView
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
@ -119,9 +121,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -134,6 +141,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
@ -158,12 +166,17 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.countWords import com.aryan.reader.countWords
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderThemeId import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock import com.aryan.reader.paginatedreader.HeaderBlock
@ -180,11 +193,11 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderThemeId import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -218,12 +231,50 @@ private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled" private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled" private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools" 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 TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore" 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_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay" private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
private const val TAG_LINK_NAV = "LINK_NAV" private const val TAG_LINK_NAV = "LINK_NAV"
private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
val insets = rootWindowInsets ?: return 0
return max(
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius ?: 0,
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius ?: 0
)
}
@Composable
private fun rememberBottomRoundedCornerPadding(view: View): Dp {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
var radiusPx by remember(view) { mutableIntStateOf(view.bottomRoundedCornerRadiusPx()) }
DisposableEffect(
view,
configuration.orientation,
configuration.screenWidthDp,
configuration.screenHeightDp
) {
val listener = View.OnLayoutChangeListener { updatedView, _, _, _, _, _, _, _, _ ->
radiusPx = updatedView.bottomRoundedCornerRadiusPx()
}
view.addOnLayoutChangeListener(listener)
radiusPx = view.bottomRoundedCornerRadiusPx()
onDispose {
view.removeOnLayoutChangeListener(listener)
}
}
return with(density) { radiusPx.toDp() }
}
private fun saveHiddenTools(context: Context, hiddenTools: Set<String>) { private fun saveHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) } prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
@ -234,6 +285,34 @@ private fun loadHiddenTools(context: Context): Set<String> {
return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet() return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
} }
private fun saveToolOrder(context: Context, toolOrder: List<ReaderTool>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
}
private fun loadToolOrder(context: Context): List<ReaderTool> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val savedTools = prefs.getString(TOOL_ORDER_KEY, null)
?.split(',')
?.filter { it.isNotBlank() }
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
.orEmpty()
return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
}
private fun saveBottomTools(context: Context, bottomTools: Set<String>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putStringSet(BOTTOM_TOOLS_KEY, bottomTools) }
}
private fun loadBottomTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getStringSet(
BOTTOM_TOOLS_KEY,
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
}
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) { private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) } prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) }
@ -338,7 +417,7 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package" private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private fun loadUseOnlineDict(context: Context): Boolean { private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true) return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
} }
@ -381,6 +460,7 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
const val PREF_READER_THEME = "reader_theme_id" const val PREF_READER_THEME = "reader_theme_id"
const val PREF_CUSTOM_THEMES = "custom_themes_json" const val PREF_CUSTOM_THEMES = "custom_themes_json"
@UnstableApi
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
fun EpubReaderScreen( fun EpubReaderScreen(
@ -416,8 +496,8 @@ fun EpubReaderScreen(
} }
} else null } else null
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath) { val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) {
epubBook.extractionBasePath.isNotBlank() && File(epubBook.extractionBasePath).exists() epubBook.hasReadableExtractedContent()
} }
var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) { var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
mutableStateOf(false) mutableStateOf(false)
@ -563,6 +643,7 @@ fun EpubReaderHost(
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) } var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) } var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) } var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) } var showVisualOptionsSheet by remember { mutableStateOf(false) }
@ -582,7 +663,7 @@ fun EpubReaderHost(
var currentTtsMode by remember { var currentTtsMode by remember {
mutableStateOf( mutableStateOf(
loadTtsMode(context).let { loadTtsMode(context).let {
if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it
} }
) )
} }
@ -738,18 +819,19 @@ fun EpubReaderHost(
} }
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) } var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
var toolOrder by remember { mutableStateOf(loadToolOrder(context)) }
var bottomTools by remember { mutableStateOf(loadBottomTools(context)) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) } var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String -> @Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
val isOss = BuildConfig.FLAVOR == "oss" val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) { if (effectiveUseOnline) {
val wordCount = countWords(word) val wordCount = countWords(word)
if (wordCount > 1 && !isProUser) { if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true showDictionaryUpsellDialog = true
} else { } else {
selectedTextForAi = word selectedTextForAi = word
@ -815,6 +897,8 @@ fun EpubReaderHost(
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) } var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
var bookmarks by remember(epubBook.title) { var bookmarks by remember(epubBook.title) {
mutableStateOf( mutableStateOf(
@ -991,6 +1075,7 @@ fun EpubReaderHost(
var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) } var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) }
var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) } var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) }
var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) } var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) }
var currentVerticalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.verticalMargin) }
var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) } var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) }
var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) } var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) } var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
@ -1006,14 +1091,14 @@ fun EpubReaderHost(
var showFontSelectionSheet by remember { mutableStateOf(false) } var showFontSelectionSheet by remember { mutableStateOf(false) }
val fontSheetState = rememberModalBottomSheetState() val fontSheetState = rememberModalBottomSheetState()
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) { LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
if (isFormatLocal) { if (isFormatLocal) {
saveLocalReaderSettings( saveLocalReaderSettings(
context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
) )
} else { } else {
saveReaderSettings( saveReaderSettings(
context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
) )
} }
} }
@ -1130,6 +1215,7 @@ fun EpubReaderHost(
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) } var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) }
val activeTheme = remember(currentThemeId, customThemes) { val activeTheme = remember(currentThemeId, customThemes) {
BuiltInThemes.find { it.id == currentThemeId } BuiltInThemes.find { it.id == currentThemeId }
@ -1151,6 +1237,19 @@ fun EpubReaderHost(
} else activeTheme.textColor } else activeTheme.textColor
} }
val activeTextureId = activeTheme.textureId val activeTextureId = activeTheme.textureId
val activeTextureAlpha = 1f - globalTextureTransparency
val activeTextureBitmap = remember(activeTextureId) {
loadReaderTextureBitmap(context, activeTextureId)
}
val activeTextureModifier = activeTextureBitmap?.let { bitmap ->
Modifier.drawBehind {
drawRect(
brush = ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)),
blendMode = BlendMode.SrcOver,
alpha = activeTextureAlpha.coerceIn(0f, 1f)
)
}
} ?: Modifier
val infoBarBgColor = remember(effectiveBg, isDarkTheme) { val infoBarBgColor = remember(effectiveBg, isDarkTheme) {
val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f
@ -1401,7 +1500,7 @@ fun EpubReaderHost(
} }
fun startTts() { fun startTts() {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true showInsufficientCreditsDialog = true
return return
} }
@ -1441,6 +1540,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
totalChapters = chapters.size,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -1460,7 +1560,7 @@ fun EpubReaderHost(
) )
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true showInsufficientCreditsDialog = true
return return
} }
@ -1504,6 +1604,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
totalChapters = chapters.size,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -1582,18 +1683,6 @@ fun EpubReaderHost(
focusRequester = searchFocusRequester focusRequester = searchFocusRequester
) )
if (epubBook.extractionBasePath.isBlank() || !File(epubBook.extractionBasePath).exists()) {
Timber.e("Extraction base path is blank or does not exist: ${epubBook.extractionBasePath}"
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Error: Book content not found. Path: ${epubBook.extractionBasePath}",
color = MaterialTheme.colorScheme.error
)
}
return
}
val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) { val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) {
if (currentClientHeightValue > 0) { if (currentClientHeightValue > 0) {
max( max(
@ -1989,10 +2078,7 @@ fun EpubReaderHost(
} }
} }
val pageInfoBottomPadding by animateDpAsState( val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding
targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
label = "PageInfoBottomPadding"
)
val isPageInfoVisible = when (pageInfoMode) { val isPageInfoVisible = when (pageInfoMode) {
PageInfoMode.DEFAULT -> !showBars PageInfoMode.DEFAULT -> !showBars
@ -2631,7 +2717,7 @@ fun EpubReaderHost(
} }
val handleGenerateSummary: (Boolean) -> Unit = { force -> val handleGenerateSummary: (Boolean) -> Unit = { force ->
if (!isProUser && credits <= 0) { if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) {
showInsufficientCreditsDialog = true showInsufficientCreditsDialog = true
showAiHubSheet = false showAiHubSheet = false
} else { } else {
@ -2688,6 +2774,7 @@ fun EpubReaderHost(
val finalSummaryBuilder = StringBuilder() val finalSummaryBuilder = StringBuilder()
summarizeBookContent( summarizeBookContent(
content = text, content = text,
context = context,
authToken = token, authToken = token,
onUsageReceived = { cost, freeRemaining -> onUsageReceived = { cost, freeRemaining ->
currentCost = cost currentCost = cost
@ -2750,7 +2837,7 @@ fun EpubReaderHost(
} }
val handleGenerateRecap: () -> Unit = { val handleGenerateRecap: () -> Unit = {
if (credits <= 0) { if (BuildConfig.FLAVOR != "oss" && credits <= 0) {
showInsufficientCreditsDialog = true showInsufficientCreditsDialog = true
showAiHubSheet = false showAiHubSheet = false
} else { } else {
@ -2820,6 +2907,7 @@ fun EpubReaderHost(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(effectiveBg) .background(effectiveBg)
.then(activeTextureModifier)
.padding(top = effectiveTopPadding) .padding(top = effectiveTopPadding)
.focusRequester(containerFocusRequester) .focusRequester(containerFocusRequester)
.focusable() .focusable()
@ -2879,13 +2967,15 @@ fun EpubReaderHost(
) { ) {
when (currentRenderMode) { when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> { RenderMode.VERTICAL_SCROLL -> {
val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = contentTopPadding)
.padding(bottom = contentBottomPadding) .padding(bottom = contentBottomPadding)
.padding(top = 16.dp)
.testTag("ReaderContainer") .testTag("ReaderContainer")
) { ) {
if (chapters.isEmpty()) { if (chapters.isEmpty()) {
@ -3292,10 +3382,12 @@ fun EpubReaderHost(
currentParagraphGap = currentParagraphGap, currentParagraphGap = currentParagraphGap,
currentImageSize = currentImageSize, currentImageSize = currentImageSize,
currentHorizontalMargin = currentHorizontalMargin, currentHorizontalMargin = currentHorizontalMargin,
currentVerticalMargin = currentVerticalMargin,
currentFontFamily = currentFontFamily, currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath, customFontPath = currentCustomFontPath,
currentTextAlign = currentTextAlign, currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId, activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
onHighlightClicked = { onHighlightClicked = {
lastHighlightClickTime = System.currentTimeMillis() lastHighlightClickTime = System.currentTimeMillis()
showBars = false showBars = false
@ -3462,7 +3554,7 @@ fun EpubReaderHost(
if (ttsChunks.isNotEmpty()) { if (ttsChunks.isNotEmpty()) {
logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex") logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex")
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true showInsufficientCreditsDialog = true
ttsShouldStartOnChapterLoad = false ttsShouldStartOnChapterLoad = false
return@launch return@launch
@ -3483,6 +3575,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
chapterIndex = targetChapterIndex, chapterIndex = targetChapterIndex,
totalChapters = chapters.size,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -3528,6 +3621,7 @@ fun EpubReaderHost(
summarizeBookContent( summarizeBookContent(
content = content, content = content,
context = context,
authToken = token, authToken = token,
onUsageReceived = { cost: Double?, freeRemaining: Int? -> onUsageReceived = { cost: Double?, freeRemaining: Int? ->
currentCost = cost currentCost = cost
@ -3779,11 +3873,14 @@ fun EpubReaderHost(
} }
RenderMode.PAGINATED -> { RenderMode.PAGINATED -> {
val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = contentTopPadding)
.padding(bottom = contentBottomPadding) .padding(bottom = contentBottomPadding)
.testTag("ReaderContainer") .testTag("ReaderContainer")
) { ) {
@ -3799,6 +3896,7 @@ fun EpubReaderHost(
paragraphGapMultiplier = currentParagraphGap, paragraphGapMultiplier = currentParagraphGap,
imageSizeMultiplier = currentImageSize, imageSizeMultiplier = currentImageSize,
horizontalMarginMultiplier = currentHorizontalMargin, horizontalMarginMultiplier = currentHorizontalMargin,
verticalMarginMultiplier = currentVerticalMargin,
fontFamily = activeFontFamily, fontFamily = activeFontFamily,
textAlign = currentTextAlign, textAlign = currentTextAlign,
activeHighlightPalette = currentHighlightPalette, activeHighlightPalette = currentHighlightPalette,
@ -3810,6 +3908,7 @@ fun EpubReaderHost(
offset = ttsState.startOffsetInSource offset = ttsState.startOffsetInSource
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 }, ).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
activeTextureId = activeTextureId, activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex, initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f), modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
onPaginatorReady = { newPaginator -> onPaginatorReady = { newPaginator ->
@ -4094,17 +4193,19 @@ fun EpubReaderHost(
// Page Info Bar (Vertical) // Page Info Bar (Vertical)
AnimatedVisibility( AnimatedVisibility(
visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible, visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
enter = fadeIn(animationSpec = tween(200)), enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)),
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(
if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(PAGE_INFO_BAR_HEIGHT) .height(pageInfoBarHeight)
.background(infoBarBgColor) .background(infoBarBgColor)
.padding(bottom = bottomPadding + pageInfoBottomPadding) .then(activeTextureModifier)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -4140,17 +4241,19 @@ fun EpubReaderHost(
// Page Info Bar (Paginated) // Page Info Bar (Paginated)
AnimatedVisibility( AnimatedVisibility(
visible = renderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0, visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
enter = fadeIn(animationSpec = tween(200)), enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)),
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(
if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(PAGE_INFO_BAR_HEIGHT) .height(pageInfoBarHeight)
.background(infoBarBgColor) .background(infoBarBgColor)
.padding(bottom = bottomPadding + pageInfoBottomPadding) .then(activeTextureModifier)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -4442,6 +4545,8 @@ fun EpubReaderHost(
volumeScrollEnabled = volumeScrollEnabled, volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools,
onCustomizeTools = { showCustomizeToolsSheet = true }, onCustomizeTools = { showCustomizeToolsSheet = true },
onNavigateBack = { triggerSaveAndExit() }, onNavigateBack = { triggerSaveAndExit() },
isKeepScreenOn = isKeepScreenOn, isKeepScreenOn = isKeepScreenOn,
@ -4522,6 +4627,71 @@ fun EpubReaderHost(
onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true }, onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true }, onOpenVisualOptions = { showVisualOptionsSheet = true },
onOpenAiHub = { showAiHubSheet = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
sliderStartPage = currentPageInChapter
sliderCurrentPage = currentPageInChapter.toFloat()
isPageSliderVisible = true
showBars = false
scope.launch {
webViewRefForTts?.let { webView ->
startPageThumbnail = captureWebViewVisibleArea(webView)
}
}
}
RenderMode.PAGINATED -> {
if (paginatedPagerState.pageCount > 0) {
sliderStartPage = paginatedPagerState.currentPage + 1
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
isPageSliderVisible = true
showBars = false
startPageThumbnail = null
} else {
bannerMessage = BannerMessage("Book is not paginated yet.")
}
}
}
},
onOpenDrawer = {
scope.launch { drawerState.open() }
},
onToggleFormat = {
showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) {
searchState.showSearchResultsPanel = false
isPageSliderVisible = false
}
},
onToggleSearch = {
searchState.isSearchActive = true
searchState.showSearchResultsPanel = true
showBars = true
showFormatAdjustmentBars = false
},
onToggleTts = {
if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS")
userStoppedTts = true
ttsController.stop()
} else {
when {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
startTts()
}
activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true -> {
showPermissionRationaleDialog = true
}
else -> {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
},
onToggleReflow = if (onToggleReflow != null) { onToggleReflow = if (onToggleReflow != null) {
{ {
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) { val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@ -4687,8 +4857,12 @@ fun EpubReaderHost(
ttsState = ttsState, ttsState = ttsState,
isProUser = isProUser, isProUser = isProUser,
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools,
currentTtsMode = currentTtsMode, currentTtsMode = currentTtsMode,
onOpenAiHub = { showAiHubSheet = true }, onOpenAiHub = { showAiHubSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenSlider = { onOpenSlider = {
when (currentRenderMode) { when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> { RenderMode.VERTICAL_SCROLL -> {
@ -4770,6 +4944,8 @@ fun EpubReaderHost(
onImageSizeChange = { currentImageSize = it }, onImageSizeChange = { currentImageSize = it },
currentHorizontalMargin = currentHorizontalMargin, currentHorizontalMargin = currentHorizontalMargin,
onHorizontalMarginChange = { currentHorizontalMargin = it }, onHorizontalMarginChange = { currentHorizontalMargin = it },
currentVerticalMargin = currentVerticalMargin,
onVerticalMarginChange = { currentVerticalMargin = it },
currentFont = currentFontFamily, currentFont = currentFontFamily,
currentCustomFontName = if(currentCustomFontPath != null) { currentCustomFontName = if(currentCustomFontPath != null) {
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font" customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
@ -4788,6 +4964,7 @@ fun EpubReaderHost(
currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL
currentImageSize = DEFAULT_IMAGE_SIZE_VAL currentImageSize = DEFAULT_IMAGE_SIZE_VAL
currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL
currentVerticalMargin = DEFAULT_VERTICAL_MARGIN_VAL
currentFontFamily = ReaderFont.ORIGINAL currentFontFamily = ReaderFont.ORIGINAL
currentCustomFontPath = null currentCustomFontPath = null
currentTextAlign = ReaderTextAlign.DEFAULT currentTextAlign = ReaderTextAlign.DEFAULT
@ -5085,10 +5262,20 @@ fun EpubReaderHost(
if (showCustomizeToolsSheet) { if (showCustomizeToolsSheet) {
CustomizeToolsSheet( CustomizeToolsSheet(
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools,
onUpdate = { newHiddenSet -> onUpdate = { newHiddenSet ->
hiddenTools = newHiddenSet hiddenTools = newHiddenSet
saveHiddenTools(context, newHiddenSet) saveHiddenTools(context, newHiddenSet)
}, },
onOrderUpdate = { newOrder ->
toolOrder = newOrder
saveToolOrder(context, newOrder)
},
onPlacementUpdate = { newBottomTools ->
bottomTools = newBottomTools
saveBottomTools(context, newBottomTools)
},
onDismiss = { showCustomizeToolsSheet = false } onDismiss = { showCustomizeToolsSheet = false }
) )
} }
@ -5133,6 +5320,11 @@ fun EpubReaderHost(
pageInfoMode = it pageInfoMode = it
savePageInfoMode(context, it) savePageInfoMode(context, it)
}, },
pageInfoPosition = pageInfoPosition,
onPageInfoPositionChange = {
pageInfoPosition = it
savePageInfoPosition(context, it)
},
pullToTurnEnabled = pullToTurnEnabled, pullToTurnEnabled = pullToTurnEnabled,
onPullToTurnChange = { onPullToTurnChange = {
pullToTurnEnabled = it pullToTurnEnabled = it
@ -5173,6 +5365,11 @@ fun EpubReaderHost(
ReaderThemePanel( ReaderThemePanel(
isVisible = true, isVisible = true,
currentThemeId = currentThemeId, currentThemeId = currentThemeId,
globalTextureTransparency = globalTextureTransparency,
onGlobalTextureTransparencyChange = {
globalTextureTransparency = it
saveGlobalTextureTransparency(context, it)
},
onThemeSelected = { onThemeSelected = {
currentThemeId = it currentThemeId = it
saveReaderThemeId(context, it) saveReaderThemeId(context, it)

View file

@ -120,6 +120,7 @@ private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled" private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode" private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode" private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled" private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f const val DEFAULT_FONT_SIZE_VAL = 1.0f
@ -127,6 +128,7 @@ const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
const val DEFAULT_VERTICAL_MARGIN_VAL = 1.0f
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate" private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
private const val TTS_PITCH_KEY = "tts_pitch" private const val TTS_PITCH_KEY = "tts_pitch"
@ -177,12 +179,18 @@ enum class PageInfoMode(val id: Int, val title: String) {
HIDDEN(2, "Always Hide") HIDDEN(2, "Always Hide")
} }
enum class PageInfoPosition(val id: Int, val title: String) {
BOTTOM(0, "Bottom"),
TOP(1, "Top")
}
data class FormatSettings( data class FormatSettings(
val fontSize: Float, val fontSize: Float,
val lineHeight: Float, val lineHeight: Float,
val paragraphGap: Float, val paragraphGap: Float,
val imageSize: Float, val imageSize: Float,
val horizontalMargin: Float, val horizontalMargin: Float,
val verticalMargin: Float,
val font: ReaderFont, val font: ReaderFont,
val customPath: String?, val customPath: String?,
val textAlign: ReaderTextAlign val textAlign: ReaderTextAlign
@ -194,9 +202,11 @@ private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_" private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_" private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_" private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
private const val LOCAL_VERTICAL_MARGIN_PREFIX = "local_vertical_margin_"
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_" private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_" private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin" private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
private const val VERTICAL_MARGIN_KEY = "reader_vertical_margin"
fun loadFormatIsLocal(context: Context, bookId: String): Boolean { fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -216,6 +226,7 @@ fun saveLocalReaderSettings(
paragraphGap: Float, paragraphGap: Float,
imageSize: Float, imageSize: Float,
horizontalMargin: Float, horizontalMargin: Float,
verticalMargin: Float,
fontFamily: ReaderFont, fontFamily: ReaderFont,
customFontPath: String?, customFontPath: String?,
textAlign: ReaderTextAlign textAlign: ReaderTextAlign
@ -227,6 +238,7 @@ fun saveLocalReaderSettings(
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap) putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize) putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin) putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
putFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, verticalMargin)
if (customFontPath != null) { if (customFontPath != null) {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath") putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
} else { } else {
@ -258,6 +270,17 @@ fun loadPageInfoMode(context: Context): PageInfoMode {
return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
} }
fun savePageInfoPosition(context: Context, position: PageInfoPosition) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putInt(PAGE_INFO_POSITION_KEY, position.id) }
}
fun loadPageInfoPosition(context: Context): PageInfoPosition {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getInt(PAGE_INFO_POSITION_KEY, PageInfoPosition.BOTTOM.id)
return PageInfoPosition.entries.find { it.id == id } ?: PageInfoPosition.BOTTOM
}
fun savePullToTurn(context: Context, enabled: Boolean) { fun savePullToTurn(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) } prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
@ -288,6 +311,11 @@ fun loadHorizontalMargin(context: Context): Float {
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
} }
fun loadVerticalMargin(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(VERTICAL_MARGIN_KEY, DEFAULT_VERTICAL_MARGIN_VAL)
}
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings { fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -321,6 +349,12 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
loadHorizontalMargin(context) loadHorizontalMargin(context)
} }
val verticalMargin = if (isLocal && prefs.contains(LOCAL_VERTICAL_MARGIN_PREFIX + bookId)) {
prefs.getFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, DEFAULT_VERTICAL_MARGIN_VAL)
} else {
loadVerticalMargin(context)
}
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) { val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
} else { } else {
@ -346,6 +380,7 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
paragraphGap = paragraphGap, paragraphGap = paragraphGap,
imageSize = imageSize, imageSize = imageSize,
horizontalMargin = horizontalMargin, horizontalMargin = horizontalMargin,
verticalMargin = verticalMargin,
font = font, font = font,
customPath = customPath, customPath = customPath,
textAlign = textAlign textAlign = textAlign
@ -390,6 +425,7 @@ fun saveReaderSettings(
paragraphGap: Float, paragraphGap: Float,
imageSize: Float, imageSize: Float,
horizontalMargin: Float, horizontalMargin: Float,
verticalMargin: Float,
fontFamily: ReaderFont, fontFamily: ReaderFont,
customFontPath: String?, customFontPath: String?,
textAlign: ReaderTextAlign textAlign: ReaderTextAlign
@ -401,6 +437,7 @@ fun saveReaderSettings(
putFloat(PARAGRAPH_GAP_KEY, paragraphGap) putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
putFloat(IMAGE_SIZE_KEY, imageSize) putFloat(IMAGE_SIZE_KEY, imageSize)
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin) putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
putFloat(VERTICAL_MARGIN_KEY, verticalMargin)
if (customFontPath != null) { if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath") putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else { } else {
@ -454,6 +491,8 @@ fun ReaderTextFormatPanel(
onImageSizeChange: (Float) -> Unit, onImageSizeChange: (Float) -> Unit,
currentHorizontalMargin: Float, currentHorizontalMargin: Float,
onHorizontalMarginChange: (Float) -> Unit, onHorizontalMarginChange: (Float) -> Unit,
currentVerticalMargin: Float,
onVerticalMarginChange: (Float) -> Unit,
currentFont: ReaderFont, currentFont: ReaderFont,
currentCustomFontName: String?, currentCustomFontName: String?,
onFontOptionClick: () -> Unit, onFontOptionClick: () -> Unit,
@ -699,6 +738,20 @@ fun ReaderTextFormatPanel(
} }
} }
) )
FormatSlider(
label = stringResource(R.string.label_vertical_margin),
value = currentVerticalMargin,
onValueChange = onVerticalMarginChange,
valueRange = 0.0f..3.0f,
formatValue = {
when {
it <= 0.01f -> noneLabel
it in 0.99f..1.01f -> originalLabel
else -> "%.1fx".format(it)
}
}
)
} }
} }
} }
@ -830,6 +883,8 @@ fun VisualOptionsSheet(
onSystemUiModeChange: (SystemUiMode) -> Unit, onSystemUiModeChange: (SystemUiMode) -> Unit,
pageInfoMode: PageInfoMode, pageInfoMode: PageInfoMode,
onPageInfoModeChange: (PageInfoMode) -> Unit, onPageInfoModeChange: (PageInfoMode) -> Unit,
pageInfoPosition: PageInfoPosition,
onPageInfoPositionChange: (PageInfoPosition) -> Unit,
pullToTurnEnabled: Boolean, pullToTurnEnabled: Boolean,
onPullToTurnChange: (Boolean) -> Unit, onPullToTurnChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float, pullToTurnMultiplier: Float,
@ -884,6 +939,16 @@ fun VisualOptionsSheet(
getLabel = { it.title } getLabel = { it.title }
) )
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.visual_options_progress_bar_position), style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
OptionSegmentedControl(
options = PageInfoPosition.entries,
selectedOption = pageInfoPosition,
onOptionSelected = onPageInfoPositionChange,
getLabel = { it.title }
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Pull to change chapter // Pull to change chapter

View file

@ -20,6 +20,7 @@
package com.aryan.reader.epubreader package com.aryan.reader.epubreader
import timber.log.Timber import timber.log.Timber
import android.graphics.Color
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.Window import android.view.Window
@ -51,14 +52,20 @@ fun EpubReaderSystemUiController(
return@DisposableEffect onDispose {} return@DisposableEffect onDispose {}
} }
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
val originalStatusBarColor = window.statusBarColor
val originalNavigationBarColor = window.navigationBarColor
Timber.d("Applying immersive mode.") Timber.d("Applying immersive mode.")
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose { onDispose {
Timber.d("Restoring system UI.") Timber.d("Restoring system UI.")
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
window.statusBarColor = originalStatusBarColor
window.navigationBarColor = originalNavigationBarColor
insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars()) insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
insetsController.systemBarsBehavior = initialSystemBarsBehavior insetsController.systemBarsBehavior = initialSystemBarsBehavior

View file

@ -328,6 +328,8 @@ private fun handleVerticalAutoAdvance(
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title, chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() }, coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
chapterIndex = currentTtsChapterIndex, chapterIndex = currentTtsChapterIndex,
totalChapters = chapters.size,
continueSession = true,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -358,6 +360,8 @@ private fun handleVerticalAutoAdvance(
chapterTitle = chapters.getOrNull(nextIdx)?.title, chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() }, coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
chapterIndex = nextIdx, chapterIndex = nextIdx,
totalChapters = chapters.size,
continueSession = true,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -433,6 +437,8 @@ private fun handlePaginatedAutoAdvance(
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
chapterIndex = chapterToTry, chapterIndex = chapterToTry,
totalChapters = chapters.size,
continueSession = true,
ttsMode = ttsMode, ttsMode = ttsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token

View file

@ -0,0 +1,172 @@
package com.aryan.reader.feedback
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.outlined.FavoriteBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
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.navigation.NavHostController
import com.aryan.reader.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SupportProjectScreen(
navController: NavHostController
) {
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.support_project_title)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
Icon(
imageVector = Icons.Outlined.FavoriteBorder,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.support_project_heading),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.support_project_desc),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(48.dp))
SupportOptionCard(
title = stringResource(R.string.support_github_sponsor),
description = stringResource(R.string.support_github_sponsor_desc),
icon = {
Icon(
painter = painterResource(id = R.drawable.github),
contentDescription = stringResource(R.string.support_github_sponsor),
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary
)
},
onClick = {
uriHandler.openUri("https://github.com/sponsors/Aryan-Raj3112")
}
)
Spacer(modifier = Modifier.height(16.dp))
SupportOptionCard(
title = stringResource(R.string.support_patreon),
description = stringResource(R.string.support_patreon_desc),
icon = {
Icon(
imageVector = Icons.Outlined.FavoriteBorder,
contentDescription = stringResource(R.string.support_patreon),
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary
)
},
onClick = {
uriHandler.openUri("https://www.patreon.com/c/epistemereader")
}
)
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun SupportOptionCard(
title: String,
description: String,
icon: @Composable () -> Unit,
onClick: () -> Unit
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
icon()
Spacer(modifier = Modifier.width(20.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = stringResource(R.string.action_open),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View file

@ -38,6 +38,8 @@ data class OpdsAcquisition(
get() = when { get() = when {
mimeType.contains("epub") -> "EPUB" mimeType.contains("epub") -> "EPUB"
mimeType.contains("pdf") -> "PDF" mimeType.contains("pdf") -> "PDF"
mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD"
mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML"
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI" mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2" mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ" mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
@ -52,6 +54,7 @@ data class OpdsAcquisition(
"PDF" -> 4 "PDF" -> 4
"MOBI" -> 3 "MOBI" -> 3
"FB2" -> 2 "FB2" -> 2
"MD", "HTML" -> 2
"CBZ" -> 1 "CBZ" -> 1
"TXT" -> 0 "TXT" -> 0
else -> -1 else -> -1

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aryan.reader.resolveFileExtensionSuffixFromName
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Response
import okhttp3.Request import okhttp3.Request
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -93,16 +95,7 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
val body = response.body ?: throw Exception("Empty body") val body = response.body ?: throw Exception("Empty body")
val contentLength = body.contentLength() val contentLength = body.contentLength()
val ext = when (acquisition.formatName) { val ext = resolveOpdsDownloadExtension(acquisition, response)
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"TXT" -> ".txt"
else -> ".epub"
}
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50) val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext") val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
@ -148,6 +141,45 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
val candidates = listOfNotNull(
response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
Uri.parse(acquisition.url).lastPathSegment
)
candidates.forEach { candidate ->
resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
}
return when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"MD" -> ".md"
"HTML" -> ".html"
"TXT" -> ".txt"
else -> ".epub"
}
}
private fun extractContentDispositionFilename(contentDisposition: String): String? {
val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
?.trim()
?.trim('"')
}
init { init {
loadCatalogs() loadCatalogs()
} }

View file

@ -0,0 +1,81 @@
package com.aryan.reader.paginatedreader
import android.graphics.BitmapFactory
import androidx.compose.ui.text.font.FontFamily
import java.io.File
import java.net.URLDecoder
import java.nio.file.Paths
object AndroidHtmlResourceResolver : HtmlResourceResolver {
override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? {
if (src.isBlank()) return null
val decodedSrc = try {
URLDecoder.decode(src, "UTF-8")
} catch (_: Exception) {
src
}
val parentPath = File(chapterAbsPath).parent ?: ""
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
val fromRelativeFile = File(extractionBasePath, relativePath)
return try {
when {
fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
else -> null
}
} catch (_: Exception) {
null
}
}
override fun readText(path: String): String? {
return runCatching { File(path).readText() }.getOrNull()
}
override fun imageDimensions(path: String): Pair<Float?, Float?>? {
return runCatching {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, options)
if (options.outWidth > 0 && options.outHeight > 0) {
options.outWidth.toFloat() to options.outHeight.toFloat()
} else {
null
}
}.getOrNull()
}
}
object AndroidHtmlFontFamilyLoader : HtmlFontFamilyLoader {
override fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily> {
return loadFontFamilies(fontFaces, extractionBasePath)
}
}
fun androidHtmlToSemanticBlocks(
html: String,
cssRules: OptimizedCssRules,
textStyle: androidx.compose.ui.text.TextStyle,
chapterAbsPath: String,
extractionBasePath: String,
density: androidx.compose.ui.unit.Density,
fontFamilyMap: Map<String, FontFamily>,
constraints: androidx.compose.ui.unit.Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap()
): List<SemanticBlock> {
return htmlToSemanticBlocks(
html = html,
cssRules = cssRules,
textStyle = textStyle,
chapterAbsPath = chapterAbsPath,
extractionBasePath = extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
constraints = constraints,
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = mathSvgCache,
resourceResolver = AndroidHtmlResourceResolver,
fontFamilyLoader = AndroidHtmlFontFamilyLoader
)
}

View file

@ -120,7 +120,8 @@ class BookPaginator(
private val mathMLRenderer: MathMLRenderer, private val mathMLRenderer: MathMLRenderer,
private val userTextAlign: TextAlign?, private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float, private val paragraphGapMultiplier: Float,
private val imageSizeMultiplier: Float private val imageSizeMultiplier: Float,
private val verticalMarginMultiplier: Float
) : IPaginator { ) : IPaginator {
override var totalPageCount by mutableIntStateOf(0) override var totalPageCount by mutableIntStateOf(0)
private set private set
@ -185,6 +186,14 @@ class BookPaginator(
isLoading = true isLoading = true
Timber.d("Initialization started.") Timber.d("Initialization started.")
if (chapters.isEmpty()) {
totalPageCount = 0
pageCountsAreAccurate = true
isLoading = false
Timber.w("Paginator initialized with no chapters. Skipping pagination startup.")
return@launch
}
// 1. Book processing check (Keep existing logic) // 1. Book processing check (Keep existing logic)
val bookRecord = bookCacheDao.getProcessedBook(bookId) val bookRecord = bookCacheDao.getProcessedBook(bookId)
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) { if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
@ -214,7 +223,7 @@ class BookPaginator(
// 5. Prioritize CURRENT chapter only // 5. Prioritize CURRENT chapter only
// We no longer blindly queue neighbors immediately to keep startup fast. // We no longer blindly queue neighbors immediately to keep startup fast.
// We only queue the requested chapter. // We only queue the requested chapter.
val startChapter = initialChapterToPaginate.coerceIn(0, chapters.size - 1) val startChapter = initialChapterToPaginate.coerceIn(0, chapters.lastIndex)
// Trigger actual pagination for the current chapter to replace the estimate with reality // Trigger actual pagination for the current chapter to replace the estimate with reality
triggerPagination(startChapter, PRIORITY_HIGHEST) triggerPagination(startChapter, PRIORITY_HIGHEST)
@ -277,6 +286,7 @@ class BookPaginator(
append("-ta:$userTextAlign") append("-ta:$userTextAlign")
append("-pg:$paragraphGapMultiplier") append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier") append("-img:$imageSizeMultiplier")
append("-vm:$verticalMarginMultiplier")
} }
val hash = configString.hashCode() val hash = configString.hashCode()
return hash return hash
@ -503,7 +513,7 @@ class BookPaginator(
parsingCssRules = parsingCssRules.merge(bookCssResult.rules) parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
} }
val semanticBlocks = htmlToSemanticBlocks( val semanticBlocks = androidHtmlToSemanticBlocks(
html = processedHtml, html = processedHtml,
cssRules = parsingCssRules, cssRules = parsingCssRules,
textStyle = textStyle.copy(color = Color.Black), textStyle = textStyle.copy(color = Color.Black),
@ -792,6 +802,10 @@ class BookPaginator(
} }
private fun triggerPagination(chapterIndex: Int, priority: Int) { private fun triggerPagination(chapterIndex: Int, priority: Int) {
if (chapterIndex !in chapters.indices) {
Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
return
}
if (pageCache[chapterIndex] != null) { if (pageCache[chapterIndex] != null) {
Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.") Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
return return

View file

@ -163,10 +163,12 @@ class ContentStyler(
} }
is SemanticMath -> { is SemanticMath -> {
val svgContent = block.svgContent
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
val finalSvgContent = when { val finalSvgContent = when {
block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent block.isFromMathJax || nonBlankSvgContent == null -> svgContent
else -> { else -> {
val themedSvg = applyThemeToSvg(block.svgContent) val themedSvg = applyThemeToSvg(nonBlankSvgContent)
embedImagesInSvg(themedSvg) embedImagesInSvg(themedSvg)
} }
} }
@ -452,11 +454,11 @@ class ContentStyler(
} }
} }
if (span.linkHref != null) { span.linkHref?.let { linkHref ->
addStringAnnotation("URL", span.linkHref, span.start, span.end) addStringAnnotation("URL", linkHref, span.start, span.end)
} }
if (span.elementId != null) { span.elementId?.let { elementId ->
addStringAnnotation("ID", span.elementId, span.start, span.end) addStringAnnotation("ID", elementId, span.start, span.end)
} }
} }
} }

View file

@ -27,47 +27,6 @@ import androidx.compose.ui.text.font.FontWeight
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
/**
* A centralized mapper for handling conversions between generic CSS font family names
* and Compose's FontFamily objects.
*/
object FontFamilyMapper {
private val genericFontMap = mapOf(
"serif" to FontFamily.Serif,
"sans-serif" to FontFamily.SansSerif,
"monospace" to FontFamily.Monospace,
"cursive" to FontFamily.Cursive,
"default" to FontFamily.Default,
"system-ui" to FontFamily.Default,
"ui-sans-serif" to FontFamily.Default,
"ui-serif" to FontFamily.Default,
"ui-monospace" to FontFamily.Default,
"ui-rounded" to FontFamily.Default
)
/**
* Converts a string name (e.g., "serif") to a Compose [FontFamily].
*/
fun nameToFontFamily(name: String): FontFamily? {
return genericFontMap[name.trim().lowercase()]
}
/**
* Converts a Compose [FontFamily] back to its primary string name for serialization.
* Custom fonts are not serialized by name and will return null.
*/
fun fontFamilyToName(fontFamily: FontFamily): String? {
return when (fontFamily) {
FontFamily.Serif -> "serif"
FontFamily.SansSerif -> "sans-serif"
FontFamily.Monospace -> "monospace"
FontFamily.Cursive -> "cursive"
FontFamily.Default -> "default"
else -> null
}
}
}
private fun getCacheKeyForFont(bookId: String, fontPath: String): String { private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
val identifier = "$bookId:$fontPath" val identifier = "$bookId:$fontPath"
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray()) val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())

View file

@ -115,7 +115,7 @@ class LocatorConverter(
otherComplex = mergedOtherComplex otherComplex = mergedOtherComplex
) )
val semanticBlocks = htmlToSemanticBlocks( val semanticBlocks = androidHtmlToSemanticBlocks(
html = htmlToParse, html = htmlToParse,
cssRules = parsingCssRules, cssRules = parsingCssRules,
textStyle = TextStyle(), textStyle = TextStyle(),

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -147,7 +148,7 @@ import coil.compose.AsyncImage
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest.Builder import coil.request.ImageRequest.Builder
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderTexture import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.countWords import com.aryan.reader.countWords
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epubreader.HighlightColor import com.aryan.reader.epubreader.HighlightColor
@ -251,6 +252,12 @@ private fun headerFontScale(level: Int): Float = when (level) {
else -> 1.0f else -> 1.0f
} }
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
}
private fun createHeaderTextStyle( private fun createHeaderTextStyle(
baseStyle: TextStyle, baseStyle: TextStyle,
level: Int, level: Int,
@ -473,6 +480,51 @@ private fun computeImageRenderSizeDp(
return with(density) { widthPx.toDp() to heightPx.toDp() } return with(density) { widthPx.toDp() to heightPx.toDp() }
} }
private fun imageBlockContentAlignment(style: BlockStyle): Alignment {
return when {
style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd
style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart
else -> Alignment.Center
}
}
private fun tableCellImageModifier(
block: ImageBlock,
density: Density,
imageSizeMultiplier: Float
): Modifier {
val baseModifier = if (block.style.width.isSpecified && block.style.width > 0.dp) {
Modifier.width(block.style.width * imageSizeMultiplier)
} else {
Modifier.fillMaxWidth(imageSizeMultiplier.coerceIn(0f, 1f))
}
val intrinsicWidth = block.intrinsicWidth
val intrinsicHeight = block.intrinsicHeight
val sizedModifier = if (
intrinsicWidth != null &&
intrinsicHeight != null &&
intrinsicWidth > 0f &&
intrinsicHeight > 0f
) {
baseModifier.aspectRatio(intrinsicWidth / intrinsicHeight)
} else {
baseModifier.height(
if (block.expectedHeight > 0) {
with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }
} else {
250.dp
}
)
}
return if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
sizedModifier.widthIn(max = block.style.maxWidth * imageSizeMultiplier)
} else {
sizedModifier
}
}
@Composable @Composable
private fun WrappingContentLayout( private fun WrappingContentLayout(
block: WrappingContentBlock, block: WrappingContentBlock,
@ -682,6 +734,7 @@ fun PaginatedReaderScreen(
paragraphGapMultiplier: Float, paragraphGapMultiplier: Float,
imageSizeMultiplier: Float, imageSizeMultiplier: Float,
horizontalMarginMultiplier: Float, horizontalMarginMultiplier: Float,
verticalMarginMultiplier: Float,
fontFamily: FontFamily, fontFamily: FontFamily,
textAlign: ReaderTextAlign, textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?, ttsHighlightInfo: TtsHighlightInfo?,
@ -702,7 +755,8 @@ fun PaginatedReaderScreen(
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>, activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit, onUpdatePalette: (Int, HighlightColor) -> Unit,
activeTextureId: String? = null activeTextureId: String? = null,
activeTextureAlpha: Float = 0.55f
) { ) {
LaunchedEffect(userHighlights) { LaunchedEffect(userHighlights) {
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.") Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
@ -713,11 +767,7 @@ fun PaginatedReaderScreen(
val context = LocalContext.current val context = LocalContext.current
val textureBitmap = remember(activeTextureId) { val textureBitmap = remember(activeTextureId) {
activeTextureId?.let { id -> loadReaderTextureBitmap(context, activeTextureId)
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
ImageBitmap.imageResource(context.resources, resId)
}
}
} }
val textureModifier = if (textureBitmap != null) { val textureModifier = if (textureBitmap != null) {
@ -725,13 +775,13 @@ fun PaginatedReaderScreen(
val brush = ShaderBrush( val brush = ShaderBrush(
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated) ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
) )
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f) drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f))
} }
} else Modifier } else Modifier
var isNavigatingByLink by remember { mutableStateOf(false) } var isNavigatingByLink by remember { mutableStateOf(false) }
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) { BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
val baseTextStyle = MaterialTheme.typography.bodyLarge val baseTextStyle = MaterialTheme.typography.bodyLarge
@ -740,6 +790,7 @@ fun PaginatedReaderScreen(
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) } var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) } var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) } var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
var debouncedFontFamily by remember { mutableStateOf(fontFamily) } var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
var debouncedTextAlign by remember { mutableStateOf(textAlign) } var debouncedTextAlign by remember { mutableStateOf(textAlign) }
@ -781,7 +832,7 @@ fun PaginatedReaderScreen(
debouncedFontFamily debouncedFontFamily
) { ) {
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
baseTextStyle.copy( baseTextStyle.copy(
color = effectiveText, color = effectiveText,
@ -810,12 +861,13 @@ fun PaginatedReaderScreen(
} }
} }
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, fontFamily, textAlign) { LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || if (fontSizeMultiplier != debouncedFontSizeMult ||
lineHeightMultiplier != debouncedLineHeightMult || lineHeightMultiplier != debouncedLineHeightMult ||
paragraphGapMultiplier != debouncedParagraphGapMult || paragraphGapMultiplier != debouncedParagraphGapMult ||
imageSizeMultiplier != debouncedImageSizeMult || imageSizeMultiplier != debouncedImageSizeMult ||
horizontalMarginMultiplier != debouncedHorizontalMarginMult || horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
verticalMarginMultiplier != debouncedVerticalMarginMult ||
fontFamily != debouncedFontFamily || fontFamily != debouncedFontFamily ||
textAlign != debouncedTextAlign textAlign != debouncedTextAlign
) { ) {
@ -836,6 +888,7 @@ fun PaginatedReaderScreen(
debouncedParagraphGapMult = paragraphGapMultiplier debouncedParagraphGapMult = paragraphGapMultiplier
debouncedImageSizeMult = imageSizeMultiplier debouncedImageSizeMult = imageSizeMultiplier
debouncedHorizontalMarginMult = horizontalMarginMultiplier debouncedHorizontalMarginMult = horizontalMarginMultiplier
debouncedVerticalMarginMult = verticalMarginMultiplier
debouncedFontFamily = fontFamily debouncedFontFamily = fontFamily
debouncedTextAlign = textAlign debouncedTextAlign = textAlign
Timber.d("Debounce complete. Applying new format settings.") Timber.d("Debounce complete. Applying new format settings.")
@ -851,8 +904,28 @@ fun PaginatedReaderScreen(
} }
val density = LocalDensity.current val density = LocalDensity.current
val horizontalPadding = 16.dp * debouncedHorizontalMarginMult val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
val verticalPadding = 16.dp val requestedVerticalPadding = 16.dp * debouncedVerticalMarginMult
val effectiveReaderPadding =
remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) {
val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() }
val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() }
val minReadableWidthPx = with(density) { 96.dp.roundToPx() }
.coerceAtMost(this.constraints.maxWidth)
val minReadableHeightPx = with(density) { 160.dp.roundToPx() }
.coerceAtMost(this.constraints.maxHeight)
val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost(
((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0)
)
val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost(
((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0)
)
with(density) {
horizontalPaddingPx.toDp() to verticalPaddingPx.toDp()
}
}
val horizontalPadding = effectiveReaderPadding.first
val verticalPadding = effectiveReaderPadding.second
val textConstraints = val textConstraints =
remember(this.constraints, density, horizontalPadding, verticalPadding) { remember(this.constraints, density, horizontalPadding, verticalPadding) {
@ -860,9 +933,9 @@ fun PaginatedReaderScreen(
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() } val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
val finalConstraints = this.constraints.copy( val finalConstraints = this.constraints.copy(
minWidth = 0, minWidth = 0,
maxWidth = this.constraints.maxWidth - (2 * horizontalPaddingPx), maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
minHeight = 0, minHeight = 0,
maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx) maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
) )
finalConstraints finalConstraints
} }
@ -950,7 +1023,8 @@ fun PaginatedReaderScreen(
mathMLRenderer = mathMLRenderer, mathMLRenderer = mathMLRenderer,
userTextAlign = userTextAlign, userTextAlign = userTextAlign,
paragraphGapMultiplier = debouncedParagraphGapMult, paragraphGapMultiplier = debouncedParagraphGapMult,
imageSizeMultiplier = debouncedImageSizeMult imageSizeMultiplier = debouncedImageSizeMult,
verticalMarginMultiplier = debouncedVerticalMarginMult
) )
} }
@ -1168,7 +1242,10 @@ fun PaginatedReaderScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
activeHighlightPalette = activeHighlightPalette, activeHighlightPalette = activeHighlightPalette,
onUpdatePalette = onUpdatePalette, onUpdatePalette = onUpdatePalette,
effectiveText = effectiveText effectiveText = effectiveText,
pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
pageTextureBitmap = textureBitmap,
pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
) )
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
@ -1989,7 +2066,10 @@ internal fun PaginatedReaderContent(
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>, activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit, onUpdatePalette: (Int, HighlightColor) -> Unit,
isDarkTheme: Boolean isDarkTheme: Boolean,
pageTextureModifier: Modifier = Modifier,
pageTextureBitmap: ImageBitmap? = null,
pageTextureAlpha: Float = 0f
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
@ -2130,7 +2210,9 @@ internal fun PaginatedReaderContent(
pageIndex, pageIndex,
effectiveBg, effectiveBg,
isDarkTheme, isDarkTheme,
pageTurnTouchY pageTurnTouchY,
pageTextureBitmap,
pageTextureAlpha
) )
} else Modifier } else Modifier
@ -2284,7 +2366,7 @@ internal fun PaginatedReaderContent(
pendingCrossPageSelection = null pendingCrossPageSelection = null
} }
Box(modifier = Modifier.fillMaxSize().then(pageModifier)) { Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
detectTapGestures( detectTapGestures(
@ -2784,12 +2866,14 @@ internal fun PaginatedReaderContent(
val markerAreaModifier = val markerAreaModifier =
Modifier.width(32.dp) Modifier.width(32.dp)
.padding(end = 8.dp) .padding(end = 8.dp)
val itemMarkerImage = block.itemMarkerImage
val itemMarker = block.itemMarker
if (block.itemMarkerImage != null) { if (itemMarkerImage != null) {
val imageRequest = val imageRequest =
Builder(LocalContext.current).data( Builder(LocalContext.current).data(
File( File(
block.itemMarkerImage itemMarkerImage
) )
).crossfade(true).build() ).crossfade(true).build()
val imageSize = with(density) { val imageSize = with(density) {
@ -2805,9 +2889,9 @@ internal fun PaginatedReaderContent(
alignment = Alignment.CenterEnd, alignment = Alignment.CenterEnd,
contentScale = ContentScale.FillHeight contentScale = ContentScale.FillHeight
) )
} else if (block.itemMarker != null) { } else if (itemMarker != null) {
Text( Text(
text = block.itemMarker, text = itemMarker,
style = textStyle.copy( style = textStyle.copy(
textAlign = TextAlign.End textAlign = TextAlign.End
), ),
@ -3017,10 +3101,12 @@ internal fun PaginatedReaderContent(
} }
is MathBlock -> { is MathBlock -> {
val svgContent = block.svgContent?.takeIf { it.isNotBlank() }
Timber.d( Timber.d(
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}" "PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${svgContent != null}"
) )
if (!block.svgContent.isNullOrBlank()) { if (svgContent != null) {
val nonBlankSvgContent = svgContent
BoxWithConstraints( BoxWithConstraints(
modifier = paddingModifier modifier = paddingModifier
) { ) {
@ -3104,7 +3190,7 @@ internal fun PaginatedReaderContent(
val imageRequest = val imageRequest =
Builder(LocalContext.current).data( Builder(LocalContext.current).data(
SvgData( SvgData(
block.svgContent nonBlankSvgContent
) )
).listener( ).listener(
onError = { _, result -> onError = { _, result ->
@ -3189,7 +3275,10 @@ internal fun PaginatedReaderContent(
) )
}).crossfade(true).build() }).crossfade(true).build()
BoxWithConstraints(modifier = paddingModifier) { BoxWithConstraints(
modifier = paddingModifier,
contentAlignment = imageBlockContentAlignment(style)
) {
val scaledSize = computeImageRenderSizeDp( val scaledSize = computeImageRenderSizeDp(
block = block, block = block,
density = density, density = density,
@ -3357,9 +3446,10 @@ internal fun PaginatedReaderContent(
Row( Row(
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
if (blockInCell.itemMarker != null) { val itemMarker = blockInCell.itemMarker
if (itemMarker != null) {
Text( Text(
text = blockInCell.itemMarker, text = itemMarker,
style = cellTextStyle, style = cellTextStyle,
modifier = Modifier.padding( modifier = Modifier.padding(
end = 4.dp end = 4.dp
@ -3390,26 +3480,6 @@ internal fun PaginatedReaderContent(
} }
is ImageBlock -> { is ImageBlock -> {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val scaledSize = computeImageRenderSizeDp(
block = blockInCell,
density = density,
maxWidthDp = maxWidth,
imageSizeMultiplier = imageSizeMultiplier
)
val imageModifier = Modifier.then(
if (scaledSize != null) {
Modifier.width(scaledSize.first).height(scaledSize.second)
} else {
Modifier.fillMaxWidth().then(
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
} else {
Modifier.height(250.dp)
}
)
}
)
AsyncImage( AsyncImage(
model = Builder( model = Builder(
LocalContext.current LocalContext.current
@ -3421,9 +3491,12 @@ internal fun PaginatedReaderContent(
.build(), .build(),
contentDescription = blockInCell.altText, contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = imageModifier modifier = tableCellImageModifier(
block = blockInCell,
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
) )
}
} }
is TextContentBlock -> { is TextContentBlock -> {
@ -4101,10 +4174,12 @@ private fun RenderFlexChildBlock(
val markerAreaModifier = Modifier val markerAreaModifier = Modifier
.width(32.dp) .width(32.dp)
.padding(end = 8.dp) .padding(end = 8.dp)
val itemMarkerImage = childBlock.itemMarkerImage
val itemMarker = childBlock.itemMarker
if (childBlock.itemMarkerImage != null) { if (itemMarkerImage != null) {
val imageRequest = val imageRequest =
Builder(LocalContext.current).data(File(childBlock.itemMarkerImage)) Builder(LocalContext.current).data(File(itemMarkerImage))
.crossfade(true).build() .crossfade(true).build()
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() } val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
@ -4115,9 +4190,9 @@ private fun RenderFlexChildBlock(
alignment = Alignment.CenterEnd, alignment = Alignment.CenterEnd,
contentScale = ContentScale.FillHeight contentScale = ContentScale.FillHeight
) )
} else if (childBlock.itemMarker != null) { } else if (itemMarker != null) {
Text( Text(
text = childBlock.itemMarker, text = itemMarker,
style = textStyle.copy(textAlign = TextAlign.End), style = textStyle.copy(textAlign = TextAlign.End),
modifier = markerAreaModifier modifier = markerAreaModifier
) )
@ -4160,7 +4235,7 @@ private fun RenderFlexChildBlock(
ColorFilter.colorMatrix(ColorMatrix(matrix)) ColorFilter.colorMatrix(ColorMatrix(matrix))
} else null } else null
BoxWithConstraints { BoxWithConstraints(contentAlignment = imageBlockContentAlignment(style)) {
val scaledSize = computeImageRenderSizeDp( val scaledSize = computeImageRenderSizeDp(
block = childBlock, block = childBlock,
density = density, density = density,
@ -4278,26 +4353,6 @@ private fun RenderFlexChildBlock(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} else if (blockInCell is ImageBlock) { } else if (blockInCell is ImageBlock) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val scaledSize = computeImageRenderSizeDp(
block = blockInCell,
density = density,
maxWidthDp = maxWidth,
imageSizeMultiplier = imageSizeMultiplier
)
val imageModifier = Modifier.then(
if (scaledSize != null) {
Modifier.width(scaledSize.first).height(scaledSize.second)
} else {
Modifier.fillMaxWidth().then(
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
} else {
Modifier.height(250.dp)
}
)
}
)
AsyncImage( AsyncImage(
model = Builder(LocalContext.current).data( model = Builder(LocalContext.current).data(
File( File(
@ -4306,9 +4361,12 @@ private fun RenderFlexChildBlock(
).build(), ).build(),
contentDescription = blockInCell.altText, contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = imageModifier modifier = tableCellImageModifier(
block = blockInCell,
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
) )
}
} }
} }
} }
@ -4332,7 +4390,9 @@ private fun Modifier.realisticBookPage(
pageIndex: Int, pageIndex: Int,
paperColor: Color, paperColor: Color,
isDarkTheme: Boolean, isDarkTheme: Boolean,
touchY: Float? touchY: Float?,
textureBitmap: ImageBitmap? = null,
textureAlpha: Float = 0f
): Modifier = composed { ): Modifier = composed {
val frontPath = remember { Path() } val frontPath = remember { Path() }
@ -4360,9 +4420,19 @@ private fun Modifier.realisticBookPage(
.drawWithContent { .drawWithContent {
val drawStart = System.nanoTime() val drawStart = System.nanoTime()
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
fun drawPaperBackground() {
drawRect(color = paperColor)
if (textureBitmap != null && textureAlpha > 0f) {
drawRect(
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
blendMode = BlendMode.SrcOver,
alpha = textureAlpha
)
}
}
if (abs(pageOffset) < 0.001f) { if (abs(pageOffset) < 0.001f) {
drawRect(color = paperColor) drawPaperBackground()
drawContent() drawContent()
} }
else if (pageOffset < 0f && pageOffset > -1f) { else if (pageOffset < 0f && pageOffset > -1f) {
@ -4423,7 +4493,7 @@ private fun Modifier.realisticBookPage(
frontPath.close() frontPath.close()
clipPath(frontPath) { clipPath(frontPath) {
drawRect(color = paperColor) drawPaperBackground()
this@drawWithContent.drawContent() this@drawWithContent.drawContent()
} }
@ -4466,6 +4536,15 @@ private fun Modifier.realisticBookPage(
clipRect(0f, 0f, w, h) { clipRect(0f, 0f, w, h) {
clipPath(frontPath) { clipPath(frontPath) {
drawPath(reflectedScreenPath, color = paperColor) drawPath(reflectedScreenPath, color = paperColor)
if (textureBitmap != null && textureAlpha > 0f) {
clipPath(reflectedScreenPath) {
drawRect(
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
blendMode = BlendMode.SrcOver,
alpha = textureAlpha
)
}
}
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
drawPath(reflectedScreenPath, color = flapTint) drawPath(reflectedScreenPath, color = flapTint)
@ -4493,12 +4572,12 @@ private fun Modifier.realisticBookPage(
} }
} else { } else {
drawRect(color = paperColor) drawPaperBackground()
drawContent() drawContent()
} }
} }
else { else {
drawRect(color = paperColor) drawPaperBackground()
drawContent() drawContent()
} }
@ -4515,10 +4594,14 @@ fun Modifier.drawCssBorders(
blockStyle: BlockStyle, blockStyle: BlockStyle,
@Suppress("unused") density: Density @Suppress("unused") density: Density
): Modifier = this.drawBehind { ): Modifier = this.drawBehind {
val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f val borderTop = blockStyle.borderTop
val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f val borderRight = blockStyle.borderRight
val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f val borderBottom = blockStyle.borderBottom
val leftWidth = blockStyle.borderLeft?.width?.toPx() ?: 0f val borderLeft = blockStyle.borderLeft
val topWidth = borderTop?.width?.toPx() ?: 0f
val rightWidth = borderRight?.width?.toPx() ?: 0f
val bottomWidth = borderBottom?.width?.toPx() ?: 0f
val leftWidth = borderLeft?.width?.toPx() ?: 0f
val tlRadius = blockStyle.borderTopLeftRadius.toPx() val tlRadius = blockStyle.borderTopLeftRadius.toPx()
val trRadius = blockStyle.borderTopRightRadius.toPx() val trRadius = blockStyle.borderTopRightRadius.toPx()
@ -4550,9 +4633,9 @@ fun Modifier.drawCssBorders(
} }
// TOP // TOP
if (topWidth > 0f && blockStyle.borderTop != null) { if (topWidth > 0f && borderTop != null) {
val color = blockStyle.borderTop.color val color = borderTop.color
val effect = getPathEffect(blockStyle.borderTop.style, topWidth) val effect = getPathEffect(borderTop.style, topWidth)
val offset = topWidth / 2f val offset = topWidth / 2f
val startX = if (tlRadius > 0) tlRadius else 0f val startX = if (tlRadius > 0) tlRadius else 0f
@ -4568,9 +4651,9 @@ fun Modifier.drawCssBorders(
} }
// BOTTOM // BOTTOM
if (bottomWidth > 0f && blockStyle.borderBottom != null) { if (bottomWidth > 0f && borderBottom != null) {
val color = blockStyle.borderBottom.color val color = borderBottom.color
val effect = getPathEffect(blockStyle.borderBottom.style, bottomWidth) val effect = getPathEffect(borderBottom.style, bottomWidth)
val offset = size.height - (bottomWidth / 2f) val offset = size.height - (bottomWidth / 2f)
val startX = if (blRadius > 0) blRadius else 0f val startX = if (blRadius > 0) blRadius else 0f
@ -4586,9 +4669,9 @@ fun Modifier.drawCssBorders(
} }
// LEFT // LEFT
if (leftWidth > 0f && blockStyle.borderLeft != null) { if (leftWidth > 0f && borderLeft != null) {
val color = blockStyle.borderLeft.color val color = borderLeft.color
val effect = getPathEffect(blockStyle.borderLeft.style, leftWidth) val effect = getPathEffect(borderLeft.style, leftWidth)
val offset = leftWidth / 2f val offset = leftWidth / 2f
val startY = if (tlRadius > 0) tlRadius else 0f val startY = if (tlRadius > 0) tlRadius else 0f
@ -4604,9 +4687,9 @@ fun Modifier.drawCssBorders(
} }
// RIGHT // RIGHT
if (rightWidth > 0f && blockStyle.borderRight != null) { if (rightWidth > 0f && borderRight != null) {
val color = blockStyle.borderRight.color val color = borderRight.color
val effect = getPathEffect(blockStyle.borderRight.style, rightWidth) val effect = getPathEffect(borderRight.style, rightWidth)
val offset = size.width - (rightWidth / 2f) val offset = size.width - (rightWidth / 2f)
val startY = if (trRadius > 0) trRadius else 0f val startY = if (trRadius > 0) trRadius else 0f
@ -4621,9 +4704,9 @@ fun Modifier.drawCssBorders(
) )
} }
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && blockStyle.borderTop != null) { if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && borderTop != null) {
drawArc( drawArc(
color = blockStyle.borderTop.color, color = borderTop.color,
startAngle = 180f, sweepAngle = 90f, startAngle = 180f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(leftWidth/2f, topWidth/2f), topLeft = Offset(leftWidth/2f, topWidth/2f),
@ -4632,9 +4715,9 @@ fun Modifier.drawCssBorders(
) )
} }
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && blockStyle.borderTop != null) { if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && borderTop != null) {
drawArc( drawArc(
color = blockStyle.borderTop.color, color = borderTop.color,
startAngle = 270f, sweepAngle = 90f, startAngle = 270f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f), topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
@ -4643,9 +4726,9 @@ fun Modifier.drawCssBorders(
) )
} }
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && blockStyle.borderBottom != null) { if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && borderBottom != null) {
drawArc( drawArc(
color = blockStyle.borderBottom.color, color = borderBottom.color,
startAngle = 0f, sweepAngle = 90f, startAngle = 0f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)), topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
@ -4654,9 +4737,9 @@ fun Modifier.drawCssBorders(
) )
} }
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && blockStyle.borderBottom != null) { if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && borderBottom != null) {
drawArc( drawArc(
color = blockStyle.borderBottom.color, color = borderBottom.color,
startAngle = 90f, sweepAngle = 90f, startAngle = 90f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)), topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),

View file

@ -145,7 +145,8 @@ class PaginatedReaderViewModel : ViewModel() {
mathMLRenderer = mathMLRenderer, mathMLRenderer = mathMLRenderer,
userTextAlign = null, userTextAlign = null,
paragraphGapMultiplier = paragraphGapMultiplier, paragraphGapMultiplier = paragraphGapMultiplier,
imageSizeMultiplier = 1.0f imageSizeMultiplier = 1.0f,
verticalMarginMultiplier = 1.0f
) )
paginator = newPaginator paginator = newPaginator

View file

@ -198,7 +198,7 @@ abstract class BookCacheDao {
ConfigurationCache::class, ConfigurationCache::class,
AnchorIndexEntry::class AnchorIndexEntry::class
], ],
version = 8, version = 10,
exportSchema = false exportSchema = false
) )
abstract class BookCacheDatabase : RoomDatabase() { abstract class BookCacheDatabase : RoomDatabase() {

View file

@ -25,7 +25,7 @@ import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 8 const val LATEST_PROCESSING_VERSION = 10
@Entity(tableName = "processed_books") @Entity(tableName = "processed_books")
data class ProcessedBook( data class ProcessedBook(

View file

@ -39,7 +39,7 @@ import com.aryan.reader.paginatedreader.FontFaceInfo
import com.aryan.reader.paginatedreader.MathMLRenderer import com.aryan.reader.paginatedreader.MathMLRenderer
import com.aryan.reader.paginatedreader.OptimizedCssRules import com.aryan.reader.paginatedreader.OptimizedCssRules
import com.aryan.reader.paginatedreader.RenderResult import com.aryan.reader.paginatedreader.RenderResult
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
import com.aryan.reader.paginatedreader.loadFontFamilies import com.aryan.reader.paginatedreader.loadFontFamilies
import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.paginatedreader.semanticBlockModule
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -277,7 +277,7 @@ class BookProcessingWorker(
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}") Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
val semanticBlocks = htmlToSemanticBlocks( val semanticBlocks = androidHtmlToSemanticBlocks(
html = processedHtml, html = processedHtml,
cssRules = lightThemeCssRules, cssRules = lightThemeCssRules,
textStyle = textStyle, textStyle = textStyle,

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -68,6 +69,9 @@ fun MagnifierComposable(
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val magnifierWidthPx = size.width val magnifierWidthPx = size.width
val magnifierHeightPx = size.height val magnifierHeightPx = size.height
if (magnifierWidthPx <= 0f || magnifierHeightPx <= 0f || zoomFactor <= 0f) {
return@Canvas
}
Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap") Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
@ -109,8 +113,10 @@ fun MagnifierComposable(
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f) val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)") Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f)) val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, bitmapToUse.height.toFloat() - sourceRectHeight.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)") Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt() val finalSrcLeftInt = clampedSrcLeft.roundToInt()
@ -174,8 +180,10 @@ fun MagnifierComposable(
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f) val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)") Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f)) val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, sourceBitmap.height.toFloat() - sourceRectHeight.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)") Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt() val finalSrcLeftInt = clampedSrcLeft.roundToInt()

View file

@ -1,5 +1,7 @@
package com.aryan.reader.pdf package com.aryan.reader.pdf
import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype
object NativePdfiumBridge { object NativePdfiumBridge {
init { init {
System.loadLibrary("native-lib") System.loadLibrary("native-lib")
@ -31,9 +33,9 @@ object NativePdfiumBridge {
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? @JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
@JvmStatic external fun checkActionSupport(): Boolean @JvmStatic external fun checkActionSupport(): Boolean
const val ANNOT_TEXT = 1 // Sticky Note const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
const val ANNOT_LINK = 2 // Link const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK
const val ANNOT_HIGHLIGHT = 8 // Highlight const val ANNOT_HIGHLIGHT = PdfiumAnnotationSubtype.HIGHLIGHT
const val ANNOT_INK = 12 // Freehand drawing const val ANNOT_INK = PdfiumAnnotationSubtype.INK
const val ANNOT_WIDGET = 19 const val ANNOT_WIDGET = PdfiumAnnotationSubtype.WIDGET
} }

View file

@ -84,7 +84,9 @@ fun VerticalScrollbar(
if (viewportRatio >= 1f) return@derivedStateOf null if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2) val maxThumbHeight = viewportHeight / 2f
val minThumbHeight = minOf(80f, maxThumbHeight)
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
val firstItemIndex = listState.firstVisibleItemIndex val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset val firstItemOffset = listState.firstVisibleItemScrollOffset

View file

@ -69,9 +69,13 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.clipRect
@ -119,6 +123,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set import androidx.core.graphics.set
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBox
@ -127,6 +132,7 @@ import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrResult import com.aryan.reader.pdf.ocr.OcrResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -180,6 +186,13 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f) data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
private const val PDF_TILE_SIZE_DP = 256
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
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
private const val PDF_PAGINATION_PAN_FLING_MULTIPLIER = 0.72f
enum class LinkSource { enum class LinkSource {
ANNOTATION, TEXT_CONTENT ANNOTATION, TEXT_CONTENT
} }
@ -426,7 +439,10 @@ data class PageStaticData(
val colorFilter: StableHolder<ColorFilter?>, val colorFilter: StableHolder<ColorFilter?>,
val isDarkMode: Boolean, val isDarkMode: Boolean,
val excludeImages: Boolean, val excludeImages: Boolean,
val imageRects: StableHolder<List<android.graphics.Rect>> val imageRects: StableHolder<List<android.graphics.Rect>>,
val textureBitmap: StableHolder<ImageBitmap?>,
val textureAlpha: Float,
val textureBlendMode: BlendMode
) )
@Stable @Stable
@ -497,6 +513,7 @@ internal fun PdfPageComposable(
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null, onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false, excludeImages: Boolean = false,
onDoubleTap: ((Offset) -> Unit)? = null, onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false, isEditMode: Boolean = false,
@ -552,7 +569,7 @@ internal fun PdfPageComposable(
var isLoadingPage by remember { mutableStateOf(true) } var isLoadingPage by remember { mutableStateOf(true) }
var pageErrorMessage by remember { mutableStateOf<String?>(null) } var pageErrorMessage by remember { mutableStateOf<String?>(null) }
val density = LocalDensity.current val density = LocalDensity.current
LocalContext.current val context = LocalContext.current
val viewConfiguration = LocalViewConfiguration.current val viewConfiguration = LocalViewConfiguration.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var isStylusEraserOverride by remember { mutableStateOf(false) } var isStylusEraserOverride by remember { mutableStateOf(false) }
@ -564,6 +581,7 @@ internal fun PdfPageComposable(
var isTransforming by remember { mutableStateOf(false) } var isTransforming by remember { mutableStateOf(false) }
var scale by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) } var offset by remember { mutableStateOf(Offset.Zero) }
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(scale, offset) { LaunchedEffect(scale, offset) {
onZoomAndPanChanged?.invoke(scale, offset) onZoomAndPanChanged?.invoke(scale, offset)
@ -590,8 +608,10 @@ internal fun PdfPageComposable(
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) } var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
val tileSizeDp = 256.dp val tileSizeDp = PDF_TILE_SIZE_DP.dp
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() } val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
val latestEffectiveOffset by rememberUpdatedState(effectiveOffset)
SideEffect { SideEffect {
Timber.tag("PdfDrawPerf") Timber.tag("PdfDrawPerf")
@ -633,6 +653,8 @@ internal fun PdfPageComposable(
var actualBitmapHeightPx by remember { mutableIntStateOf(0) } var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
var currentPageRotation by remember { mutableIntStateOf(0) } var currentPageRotation by remember { mutableIntStateOf(0) }
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
val canvasWidthPx = remember { mutableFloatStateOf(0f) } val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) } val canvasHeightPx = remember { mutableFloatStateOf(0f) }
@ -687,6 +709,15 @@ internal fun PdfPageComposable(
activeTheme.backgroundColor activeTheme.backgroundColor
} }
} }
val textureBitmap = remember(activeTheme.textureId) {
loadReaderTextureBitmap(context, activeTheme.textureId)
}
val effectiveTextureAlpha = remember(activeTheme.textureId, activeTextureAlpha) {
if (activeTheme.textureId == null) 0f else activeTextureAlpha.coerceIn(0f, 1f)
}
val textureBlendMode = remember(activeTheme.textureId, activeTheme.isDark, activeTheme.id) {
if (activeTheme.isDark || activeTheme.id == "reverse") BlendMode.Screen else BlendMode.Multiply
}
val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) { val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) {
derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f } derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
@ -1146,13 +1177,13 @@ internal fun PdfPageComposable(
try { try {
val pagePtr = pageWrapper.getNativePointer() val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) { if (pagePtr != 0L) {
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr) val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
val imgRects = mutableListOf<android.graphics.Rect>() val imgRects = mutableListOf<android.graphics.Rect>()
val outRect = FloatArray(4) val outRect = FloatArray(4)
for (i in 0 until objCount) { for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) { if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
val pdfRectF = android.graphics.RectF( val pdfRectF = android.graphics.RectF(
min(outRect[0], outRect[2]), min(outRect[0], outRect[2]),
max(outRect[1], outRect[3]), max(outRect[1], outRect[3]),
@ -1180,26 +1211,26 @@ internal fun PdfPageComposable(
val pagePtr = pageWrapper.getNativePointer() val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) { if (pagePtr != 0L) {
val count = NativePdfiumBridge.getAnnotCount(pagePtr) val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) { if (count > 0) {
val count = NativePdfiumBridge.getAnnotCount(pagePtr) val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) { if (count > 0) {
val allAnnots = (0 until count).mapNotNull { i -> val allAnnots = (0 until count).mapNotNull { i ->
val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i) val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
if (subtype == annotLink) return@mapNotNull null if (subtype == annotLink) return@mapNotNull null
var contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents") var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
if (contents.isNullOrBlank()) { if (contents.isNullOrBlank()) {
contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "RC") contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
} }
val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM") val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT") val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T") val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i) val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
val pdfRectF = if (pdfRectArray != null) { val pdfRectF = if (pdfRectArray != null) {
android.graphics.RectF( android.graphics.RectF(
min(pdfRectArray[0], pdfRectArray[2]), min(pdfRectArray[0], pdfRectArray[2]),
@ -1311,8 +1342,7 @@ internal fun PdfPageComposable(
} }
LaunchedEffect( LaunchedEffect(
effectiveScale, needsTilingNow,
effectiveOffset,
actualBitmapWidthPx, actualBitmapWidthPx,
actualBitmapHeightPx, actualBitmapHeightPx,
canvasWidthPx.floatValue, canvasWidthPx.floatValue,
@ -1323,8 +1353,7 @@ internal fun PdfPageComposable(
virtualPage, virtualPage,
isActivePage isActivePage
) { ) {
val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage) if (!needsTilingNow) {
if (!needsTiling) {
if (tiles.isNotEmpty()) { if (tiles.isNotEmpty()) {
val oldTiles = tiles val oldTiles = tiles
tiles = emptyList() tiles = emptyList()
@ -1358,24 +1387,37 @@ internal fun PdfPageComposable(
snapshotFlow { snapshotFlow {
val rect = visibleScreenRect() val rect = visibleScreenRect()
if (rect == null) null val observedScale = latestEffectiveScale
else { if (isVerticalScroll && rect != null) {
val qTop = rect.top / (tileSizePx / 2) val qTop = rect.top / (tileSizePx / 2)
val qLeft = rect.left / (tileSizePx / 2) val qLeft = rect.left / (tileSizePx / 2)
val qBottom = rect.bottom / (tileSizePx / 2) val qBottom = rect.bottom / (tileSizePx / 2)
val qRight = rect.right / (tileSizePx / 2) val qRight = rect.right / (tileSizePx / 2)
listOf(qTop, qLeft, qBottom, qRight) listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
} else if (!isVerticalScroll) {
val observedOffset = latestEffectiveOffset
val pivotX = screenWidth / 2f
val pivotY = screenHeight / 2f
val pxTl = (((0 - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
val pyTl = (((0 - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
val pxBr = (((screenWidth - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
val pyBr = (((screenHeight - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
val qTop = pyTl.toInt() / (tileSizePx / 2)
val qLeft = pxTl.toInt() / (tileSizePx / 2)
val qBottom = pyBr.toInt() / (tileSizePx / 2)
val qRight = pxBr.toInt() / (tileSizePx / 2)
listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
} else {
null
} }
}.conflate().collectLatest { _ -> }.conflate().collectLatest { _ ->
delay(150)
val tileCalcStart = System.nanoTime() val tileCalcStart = System.nanoTime()
if (!isActive) return@collectLatest if (!isActive) return@collectLatest
if (isScrolling && effectiveScale > 1f) { val renderScale = latestEffectiveScale
return@collectLatest val renderOffset = latestEffectiveOffset
}
val currentVisibleRect = visibleScreenRect() val currentVisibleRect = visibleScreenRect()
@ -1404,14 +1446,14 @@ internal fun PdfPageComposable(
val pivotX = screenWidth / 2f val pivotX = screenWidth / 2f
val pivotY = screenHeight / 2f val pivotY = screenHeight / 2f
pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX pxTl = (((0 - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY pyTl = (((0 - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX pxBr = (((screenWidth - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
pyBr = (((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY pyBr = (((screenHeight - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
} }
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt()) val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
val inset = if (effectiveScale > 2f) 0 else -tileSizePx val inset = if (renderScale > 2f) 0 else -tileSizePx
visibleBitmapRect.inset(inset, inset) visibleBitmapRect.inset(inset, inset)
val requiredTileIds = mutableSetOf<Int>() val requiredTileIds = mutableSetOf<Int>()
@ -1431,8 +1473,10 @@ internal fun PdfPageComposable(
val currentTileIds = tiles.map { it.tileId }.toSet() val currentTileIds = tiles.map { it.tileId }.toSet()
val scaleTolerance = 0.05f val scaleTolerance = PDF_TILE_SCALE_TOLERANCE
val validCurrentTileIds = tiles.filter { abs(it.renderScale - effectiveScale) <= scaleTolerance }.map { it.tileId }.toSet() val validCurrentTileIds = tiles.filter { abs(it.renderScale - renderScale) <= scaleTolerance }.map { it.tileId }.toSet()
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
val tilesToRecycleIds = currentTileIds - requiredTileIds
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
if (duration > 2f) { if (duration > 2f) {
@ -1441,11 +1485,6 @@ internal fun PdfPageComposable(
) )
} }
if (requiredTileIds != validCurrentTileIds) {
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
val tilesToRecycleIds = currentTileIds - requiredTileIds
if (tilesToRecycleIds.isNotEmpty()) { if (tilesToRecycleIds.isNotEmpty()) {
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds } val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
tiles = tilesToKeep tiles = tilesToKeep
@ -1454,8 +1493,18 @@ internal fun PdfPageComposable(
} }
} }
if (isScrolling && renderScale > 1f) {
return@collectLatest
}
if (requiredTileIds != validCurrentTileIds) {
if (tilesToRenderIds.isNotEmpty()) { if (tilesToRenderIds.isNotEmpty()) {
withContext(Dispatchers.IO) { delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
if (!isActive) return@collectLatest
if (isScrolling && latestEffectiveScale > 1f) return@collectLatest
val renderedTiles = withContext(Dispatchers.IO) {
val newTiles = mutableListOf<PdfTile>()
tilesToRenderIds.forEach { tileId -> tilesToRenderIds.forEach { tileId ->
if (!isActive) return@forEach if (!isActive) return@forEach
@ -1469,14 +1518,18 @@ internal fun PdfPageComposable(
(col + 1) * tileSizePx, (col + 1) * tileSizePx,
(row + 1) * tileSizePx (row + 1) * tileSizePx
) )
val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1) val tileRenderScale = min(
renderScale,
PDF_MAX_TILE_BITMAP_SIZE_PX.toFloat() / tileSizePx.toFloat()
)
val tileRenderSize = (tileSizePx * tileRenderScale).toInt().coerceAtLeast(1)
val tileBitmap = PdfBitmapPool.get(tileRenderSize) val tileBitmap = PdfBitmapPool.get(tileRenderSize)
val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt() val fullPageRenderWidth = (actualBitmapWidthPx * tileRenderScale).toInt()
val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt() val fullPageRenderHeight = (actualBitmapHeightPx * tileRenderScale).toInt()
val tileRenderX = (col * tileSizePx * effectiveScale).toInt() val tileRenderX = (col * tileSizePx * tileRenderScale).toInt()
val tileRenderY = (row * tileSizePx * effectiveScale).toInt() val tileRenderY = (row * tileSizePx * tileRenderScale).toInt()
page?.renderPageBitmap( page?.renderPageBitmap(
bitmap = tileBitmap, bitmap = tileBitmap,
@ -1487,24 +1540,26 @@ internal fun PdfPageComposable(
renderAnnot = true renderAnnot = true
) )
val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale) newTiles += PdfTile(tileBitmap, tileRect, tileId, renderScale)
var handedOver = false }
try { newTiles
withContext(Dispatchers.Main) { }
val oldTile = tiles.find { it.tileId == tileId }
tiles = tiles.filter { it.tileId != tileId } + newTile
handedOver = true
oldTile?.let { if (!isActive) {
withContext(Dispatchers.IO) {
renderedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
return@collectLatest
}
if (renderedTiles.isNotEmpty()) {
val renderedIds = renderedTiles.map { it.tileId }.toSet()
val replacedTiles = tiles.filter { it.tileId in renderedIds }
tiles = tiles.filterNot { it.tileId in renderedIds } + renderedTiles
if (replacedTiles.isNotEmpty()) {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
PdfBitmapPool.recycle(it.bitmap) replacedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
}
} finally {
if (!handedOver) {
PdfBitmapPool.recycle(tileBitmap)
}
} }
} }
} }
@ -2799,7 +2854,7 @@ internal fun PdfPageComposable(
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr") Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint( val linkInfo = PdfiumEngineProvider.bridge.getLinkInfoAtPoint(
docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble() docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()
) )
@ -2818,7 +2873,7 @@ internal fun PdfPageComposable(
} }
} }
val clickHandled = NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()) val clickHandled = PdfiumEngineProvider.bridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
if (clickHandled) { if (clickHandled) {
return@withContext 2 return@withContext 2
} }
@ -3007,25 +3062,21 @@ internal fun PdfPageComposable(
awaitEachGesture { awaitEachGesture {
@Suppress("UnusedVariable", "Unused") val down = @Suppress("UnusedVariable", "Unused") val down =
awaitFirstDown(requireUnconsumed = false) awaitFirstDown(requireUnconsumed = false)
paginationPanFlingJob?.cancel()
paginationPanFlingJob = null
velocityTracker.resetTracking() velocityTracker.resetTracking()
var mode = 0 var mode = 0
var accumulatedZoom = 1f var accumulatedZoom = 1f
var accumulatedPan = Offset.Zero var accumulatedPan = Offset.Zero
var swipeAccumulatorX = 0f var swipeAccumulatorX = 0f
var velocityAccumulator = Offset.Zero
do { do {
val event = awaitPointerEvent() val event = awaitPointerEvent()
val canceled = event.changes.any { it.isConsumed } val canceled = event.changes.any { it.isConsumed }
val pointerCount = event.changes.size val pointerCount = event.changes.size
val currentCentroid = event.calculateCentroid(useCurrent = true)
if (pointerCount > 0 && currentCentroid != Offset.Unspecified) {
velocityTracker.addPosition(
event.changes[0].uptimeMillis, currentCentroid
)
}
if (!canceled) { if (!canceled) {
val rawPanChange = event.calculatePan() val rawPanChange = event.calculatePan()
val panChange = if (isScrollLocked && pointerCount == 1) { val panChange = if (isScrollLocked && pointerCount == 1) {
@ -3077,6 +3128,14 @@ internal fun PdfPageComposable(
Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)") Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)")
offset = Offset(newX, newY) offset = Offset(newX, newY)
if (event.changes.isNotEmpty() && panChange != Offset.Zero) {
velocityAccumulator += panChange
velocityTracker.addPosition(
event.changes[0].uptimeMillis,
velocityAccumulator
)
}
event.changes.forEach { event.changes.forEach {
if (it.positionChanged()) it.consume() if (it.positionChanged()) it.consume()
} }
@ -3189,41 +3248,57 @@ internal fun PdfPageComposable(
} }
} }
} else if (mode == 1 && scale > 1f) { } else if (mode == 1 && scale > 1f) {
val velocity = velocityTracker.calculateVelocity()
val contentWidth = actualBitmapWidthPx * scale val contentWidth = actualBitmapWidthPx * scale
val contentHeight = actualBitmapHeightPx * scale val contentHeight = actualBitmapHeightPx * scale
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
val startX = offset.x val velocity = velocityTracker.calculateVelocity()
val startY = offset.y val flingX = if (!isScrollLocked && abs(velocity.x) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
velocity.x * PDF_PAGINATION_PAN_FLING_MULTIPLIER
} else {
0f
}
val flingY = if (abs(velocity.y) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
velocity.y * PDF_PAGINATION_PAN_FLING_MULTIPLIER
} else {
0f
}
coroutineScope.launch { if (flingX == 0f && flingY == 0f) {
offset = Offset(
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
)
} else {
val startOffset = offset
paginationPanFlingJob = coroutineScope.launch {
try {
coroutineScope { coroutineScope {
launch { launch {
if (!isScrollLocked) { if (flingX != 0f) {
Animatable(startX).animateDecay( Animatable(startOffset.x).animateDecay(flingX, decay) {
velocity.x, decay offset = offset.copy(
) { x = value.coerceIn(-maxOffsetX, maxOffsetX)
val newX = value.coerceIn(
-maxOffsetX, maxOffsetX
) )
offset = offset.copy(x = newX)
} }
} }
} }
launch { launch {
Animatable(startY).animateDecay( if (flingY != 0f) {
velocity.y, decay Animatable(startOffset.y).animateDecay(flingY, decay) {
) { offset = offset.copy(
val newY = value.coerceIn( y = value.coerceIn(-maxOffsetY, maxOffsetY)
-maxOffsetY, maxOffsetY
) )
offset = offset.copy(y = newY)
} }
} }
} }
} }
} finally {
paginationPanFlingJob = null
}
}
}
} }
} }
} }
@ -3256,7 +3331,7 @@ internal fun PdfPageComposable(
} }
val buttons = currentEvent.buttons val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d( Timber.tag("StylusDebug").d(
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons" "Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
) )
@ -3881,7 +3956,10 @@ internal fun PdfPageComposable(
stableColorFilter, stableColorFilter,
isDarkMode, isDarkMode,
excludeImages, excludeImages,
stableImageRects stableImageRects,
textureBitmap,
effectiveTextureAlpha,
textureBlendMode
) { ) {
Timber.tag("PdfDrawPerf").v( Timber.tag("PdfDrawPerf").v(
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}" "STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
@ -3899,7 +3977,10 @@ internal fun PdfPageComposable(
colorFilter = stableColorFilter, colorFilter = stableColorFilter,
isDarkMode = isDarkMode, isDarkMode = isDarkMode,
excludeImages = excludeImages, excludeImages = excludeImages,
imageRects = stableImageRects imageRects = stableImageRects,
textureBitmap = StableHolder(textureBitmap),
textureAlpha = effectiveTextureAlpha,
textureBlendMode = textureBlendMode
) )
} }
@ -4263,7 +4344,10 @@ private fun PdfBitmapLayer(
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
isDarkMode: Boolean = false, isDarkMode: Boolean = false,
excludeImages: Boolean = false, excludeImages: Boolean = false,
imageRects: List<android.graphics.Rect> = emptyList() imageRects: List<android.graphics.Rect> = emptyList(),
textureBitmap: ImageBitmap? = null,
textureAlpha: Float = 0f,
textureBlendMode: BlendMode = BlendMode.Multiply
) { ) {
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) { Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) { translate(left = centeringOffsetX, top = centeringOffsetY) {
@ -4363,6 +4447,15 @@ private fun PdfBitmapLayer(
} }
} }
} }
if (textureBitmap != null && textureAlpha > 0f) {
drawRect(
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
size = Size(dstW.toFloat(), dstH.toFloat()),
blendMode = textureBlendMode,
alpha = textureAlpha
)
}
} }
} }
} }
@ -4884,7 +4977,10 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
colorFilter = data.colorFilter.item, colorFilter = data.colorFilter.item,
isDarkMode = data.isDarkMode, isDarkMode = data.isDarkMode,
excludeImages = data.excludeImages, excludeImages = data.excludeImages,
imageRects = data.imageRects.item imageRects = data.imageRects.item,
textureBitmap = data.textureBitmap.item,
textureAlpha = data.textureAlpha,
textureBlendMode = data.textureBlendMode
) )
} }

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.ReaderTheme import com.aryan.reader.ReaderTheme
import com.aryan.reader.ReaderTexture
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll" internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
@ -39,6 +40,8 @@ private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme" private const val PDF_THEME_KEY = "pdf_reader_theme"
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled" private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools" private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode" private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug" internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
@ -76,7 +79,13 @@ val PdfBuiltInThemes = listOf(
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true) ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
) )
internal fun loadPdfHiddenTools(context: Context): Set<String> { internal fun loadPdfHiddenTools(context: Context): Set<String> {
@ -89,6 +98,32 @@ internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) } prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
} }
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null)
?.split(',')
?.filter { it.isNotBlank() }
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
.orEmpty()
return (savedTools + PdfReaderTool.entries.filterNot { it in savedTools }).distinct()
}
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
}
internal fun loadPdfBottomTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val defaultBottomTools = PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
}
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) }
}
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> { internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return PdfHighlightColor.entries.associateWith { return PdfHighlightColor.entries.associateWith {
@ -139,7 +174,7 @@ internal fun loadPdfThemeId(context: Context): String {
} }
internal fun loadUseOnlineDict(context: Context): Boolean { internal fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true) return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
} }

View file

@ -1,8 +1,9 @@
@file:kotlin.OptIn(ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader.pdf package com.aryan.reader.pdf
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -11,99 +12,499 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.background
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LockOpen
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.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.epubreader.OptionSegmentedControl import com.aryan.reader.epubreader.OptionSegmentedControl
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
data class PdfFlatToolItem(
val id: String,
val type: PdfFlatItemType,
val tool: PdfReaderTool? = null,
val section: PdfToolbarSection? = null,
val title: String? = null
)
fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem> {
val result = mutableListOf<PdfFlatToolItem>()
val sectionMap = mutableMapOf<PdfToolbarSection, MutableList<PdfFlatToolItem>>()
PdfToolbarSection.entries.forEach { sectionMap[it] = mutableListOf() }
list.forEach { item ->
if (item.type == PdfFlatItemType.TOOL) {
item.section?.let { sectionMap[it]?.add(item) }
}
}
PdfToolbarSection.entries.forEach { section ->
result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
val tools = sectionMap[section] ?: emptyList()
if (tools.isEmpty()) {
result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
} else {
result.addAll(tools)
}
}
list.filter { it.type == PdfFlatItemType.MORE_HEADER || it.type == PdfFlatItemType.MORE_TOOL }.forEach {
result.add(it)
}
return result
}
class PdfDragDropState(
val lazyListState: LazyListState,
val onMove: (String, String) -> Unit
) {
var draggedItemId by mutableStateOf<String?>(null)
var dragOffset by mutableStateOf(Offset.Zero)
fun onDragStart(id: String) { draggedItemId = id; dragOffset = Offset.Zero }
fun onDrag(delta: Offset) {
val draggedId = draggedItemId ?: return
dragOffset += delta
val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
val currentItem = visibleItems.find { it.key == draggedId } ?: return
val center = currentItem.offset + dragOffset.y + currentItem.size / 2f
val targetItem = visibleItems.find { it.key != draggedId && center >= it.offset && center <= (it.offset + it.size) }
if (targetItem != null) {
onMove(draggedId, targetItem.key.toString())
dragOffset = dragOffset.copy(y = dragOffset.y - (targetItem.offset - currentItem.offset))
}
}
fun onDragEnd() { draggedItemId = null; dragOffset = Offset.Zero }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PdfCustomizeToolsSheet( fun PdfCustomizeToolsSheet(
hiddenTools: Set<String>, hiddenTools: Set<String>,
toolOrder: List<PdfReaderTool>,
bottomTools: Set<String>,
onUpdate: (Set<String>) -> Unit, onUpdate: (Set<String>) -> Unit,
onOrderUpdate: (List<PdfReaderTool>) -> Unit,
onPlacementUpdate: (Set<String>) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
ModalBottomSheet( val reorderableToolbarTools = setOf(
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
)
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
var flatItems by remember {
mutableStateOf<List<PdfFlatToolItem>>(
run {
val toolbarTools = toolOrder.filter { it in reorderableToolbarTools }
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
val moreTools = toolOrder.filter { it !in reorderableToolbarTools }
val list = mutableListOf<PdfFlatToolItem>()
PdfToolbarSection.entries.forEach { section ->
val tools = when(section) {
PdfToolbarSection.TOP -> topTools
PdfToolbarSection.BOTTOM -> bottomToolsList
PdfToolbarSection.HIDDEN -> hiddenToolsList
}
list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
if (tools.isEmpty()) {
list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
} else {
tools.forEach { tool ->
list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
}
}
}
list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, title = "More menu"))
moreTools.forEach { tool ->
list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
}
list
}
)
}
val commitDragDrop = {
val newHidden = localHiddenTools.filter { toolName ->
toolOrder.find { it.name == toolName } !in reorderableToolbarTools
}.toMutableSet()
val newBottom = mutableSetOf<String>()
val newOrder = mutableListOf<PdfReaderTool>()
flatItems.forEach { item ->
if (item.type == PdfFlatItemType.TOOL && item.tool != null) {
newOrder.add(item.tool)
if (item.section == PdfToolbarSection.HIDDEN) newHidden.add(item.tool.name)
if (item.section == PdfToolbarSection.BOTTOM) newBottom.add(item.tool.name)
}
}
val moreTools = flatItems.filter { it.type == PdfFlatItemType.MORE_TOOL }.mapNotNull { it.tool }
newOrder.addAll(moreTools)
localHiddenTools = newHidden
onUpdate(newHidden)
onPlacementUpdate(newBottom)
onOrderUpdate(newOrder)
}
val lazyListState = rememberLazyListState()
val dragDropState = remember {
PdfDragDropState(lazyListState) { fromKey, toKey ->
val fromIndex = flatItems.indexOfFirst { it.id == fromKey }
val toIndex = flatItems.indexOfFirst { it.id == toKey }
if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return@PdfDragDropState
val fromItem = flatItems[fromIndex]
if (fromItem.type != PdfFlatItemType.TOOL) return@PdfDragDropState
val toItem = flatItems[toIndex]
if (toItem.type == PdfFlatItemType.MORE_HEADER || toItem.type == PdfFlatItemType.MORE_TOOL) return@PdfDragDropState
val newList = flatItems.toMutableList()
val movedItem = newList.removeAt(fromIndex)
val newToIndex = newList.indexOfFirst { it.id == toKey }
val insertIndex = if (fromIndex < toIndex) newToIndex + 1 else newToIndex
newList.add(insertIndex, movedItem)
var actualSection = movedItem.section
for (i in insertIndex downTo 0) {
val item = newList[i]
if (item.type == PdfFlatItemType.SECTION_HEADER) {
actualSection = item.section
break
}
}
newList[insertIndex] = movedItem.copy(section = actualSection)
flatItems = newList
}
}
Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
contentWindowInsets = { WindowInsets.navigationBars } properties = DialogProperties(usePlatformDefaultWidth = false)
) {
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars),
color = MaterialTheme.colorScheme.surface
) {
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
Text( Text(
text = stringResource(R.string.title_customize_toolbar), text = stringResource(R.string.title_customize_toolbar),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
) )
Spacer(modifier = Modifier.height(8.dp)) IconButton(onClick = onDismiss) {
Text( Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
text = stringResource(R.string.desc_customize_toolbar), }
style = MaterialTheme.typography.bodyMedium, }
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(modifier = Modifier.fillMaxWidth()) { LazyColumn(
PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) -> state = lazyListState,
item { modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp)
) {
items(flatItems, key = { it.id }) { item ->
val isDragged = item.id == dragDropState.draggedItemId
val zIndex = if (isDragged) 1f else 0f
val elevation = if (isDragged) 8.dp else 0.dp
val scale = if (isDragged) 1.03f else 1f
val translationY = if (isDragged) dragDropState.dragOffset.y else 0f
Box(
modifier = Modifier
.fillMaxWidth()
.then(if (isDragged) Modifier else Modifier.animateItem())
.zIndex(zIndex)
.graphicsLayer {
this.translationY = translationY
this.scaleX = scale
this.scaleY = scale
this.shadowElevation = elevation.toPx()
}
) {
when (item.type) {
PdfFlatItemType.SECTION_HEADER -> {
Text( Text(
text = category, text = item.title ?: "",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
) )
} }
items(tools) { tool -> PdfFlatItemType.EMPTY_PLACEHOLDER -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(vertical = 4.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
PdfFlatItemType.TOOL -> {
PdfToolbarDragRow(
tool = item.tool!!,
isDragging = isDragged,
onDragStart = { dragDropState.onDragStart(item.id) },
onDrag = { dragDropState.onDrag(it) },
onDragEnd = {
dragDropState.onDragEnd()
flatItems = sanitizePdfPlaceholders(flatItems).toList()
commitDragDrop()
}
)
}
PdfFlatItemType.MORE_HEADER -> {
Text(
text = item.title ?: "More menu",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
)
}
PdfFlatItemType.MORE_TOOL -> {
PdfMoreToolVisibilityRow(
title = item.tool!!.title,
visible = !localHiddenTools.contains(item.tool.name),
onToggle = {
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
localHiddenTools - item.tool.name
} else {
localHiddenTools + item.tool.name
}
onUpdate(localHiddenTools)
}
)
}
}
}
}
}
}
}
}
}
@Composable
private fun PdfToolbarDragRow(
tool: PdfReaderTool,
isDragging: Boolean,
onDragStart: () -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit
) {
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
Row(
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
PdfToolPreviewIcon(tool)
Spacer(Modifier.width(16.dp))
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(
Icons.Default.Menu,
contentDescription = "Drag to reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(48.dp)
.padding(12.dp)
.pointerInput(tool) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
},
onDragEnd = onDragEnd,
onDragCancel = onDragEnd
)
}
)
}
}
}
@Composable
private fun PdfToolbarDragRow(
tool: PdfReaderTool,
isDragging: Boolean,
onBounds: (Rect) -> Unit,
onDragStart: (Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit
) {
var bounds by remember { mutableStateOf<Rect?>(null) }
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.onGloballyPositioned {
bounds = it.boundsInWindow()
onBounds(it.boundsInWindow())
}
.pointerInput(tool) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart(bounds?.center ?: Offset.Zero) },
onDragEnd = onDragEnd,
onDragCancel = onDragEnd,
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
}
)
},
shape = RoundedCornerShape(12.dp),
color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
PdfToolPreviewIcon(tool)
Spacer(Modifier.width(12.dp))
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(Icons.Default.Menu, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun PdfMoreToolVisibilityRow(
title: String,
visible: Boolean,
onToggle: () -> Unit
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { .clickable(onClick = onToggle)
val newSet = hiddenTools.toMutableSet()
if (newSet.contains(tool.name)) newSet.remove(tool.name)
else newSet.add(tool.name)
onUpdate(newSet)
}
.padding(vertical = 12.dp, horizontal = 8.dp), .padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = tool.title, text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
) modifier = Modifier.weight(1f)
Switch(
checked = !hiddenTools.contains(tool.name),
onCheckedChange = { isVisible ->
val newSet = hiddenTools.toMutableSet()
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
onUpdate(newSet)
}
) )
if (visible) {
Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
} }
} }
} }
}
} enum class PdfToolbarSection(val title: String) {
TOP("Top Bar"),
BOTTOM("Bottom Bar"),
HIDDEN("Hidden Tools")
}
@Composable
private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
when (tool) {
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
} }
} }

View file

@ -34,7 +34,9 @@ object PdfToHtmlGenerator {
} }
try { try {
val doc = pdfiumCore.newDocument(pfd) val doc = PdfiumEngineProvider.withPdfium {
pdfiumCore.newDocument(pfd)
}
val totalPages = doc.getPageCount() val totalPages = doc.getPageCount()
Timber.tag(TAG).d("Document loaded. Total pages: $totalPages") Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
@ -56,7 +58,9 @@ object PdfToHtmlGenerator {
writer.write(buildGlobalHtmlFooter()) writer.write(buildGlobalHtmlFooter())
} }
PdfiumEngineProvider.withPdfium {
doc.close() doc.close()
}
pfd.close() pfd.close()
Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms") Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
return@withContext true return@withContext true
@ -137,14 +141,14 @@ object PdfToHtmlGenerator {
val textPagePtr = getNativePointer(textPage) val textPagePtr = getNativePointer(textPage)
val imageElements = mutableListOf<ImageElement>() val imageElements = mutableListOf<ImageElement>()
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr) val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
for (i in 0 until objCount) { for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) {
val bbox = FloatArray(4) val bbox = FloatArray(4)
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) { if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
val topY = bbox[3] val topY = bbox[3]
val dimens = IntArray(2) val dimens = IntArray(2)
val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens) val pixels = PdfiumEngineProvider.bridge.extractImagePixels(pagePtr, i, dimens)
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) { if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
try { try {
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888) val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
@ -175,11 +179,11 @@ object PdfToHtmlGenerator {
val flags: IntArray? val flags: IntArray?
val charBoxes: FloatArray? val charBoxes: FloatArray?
synchronized(NativePdfiumBridge::class.java) { synchronized(PdfiumEngineProvider.lock) {
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount) sizes = PdfiumEngineProvider.bridge.getPageFontSizes(textPagePtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount) weights = PdfiumEngineProvider.bridge.getPageFontWeights(textPagePtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount) flags = PdfiumEngineProvider.bridge.getPageFontFlags(textPagePtr, actualCount)
charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount) charBoxes = PdfiumEngineProvider.bridge.getPageCharBoxes(textPagePtr, actualCount)
} }
if (sizes == null || weights == null || flags == null) { if (sizes == null || weights == null || flags == null) {

View file

@ -18,13 +18,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -33,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
@ -41,9 +43,23 @@ import com.aryan.reader.R
import com.aryan.reader.SearchState import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton import com.aryan.reader.TooltipIconButton
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
import kotlin.collections.isNotEmpty import kotlin.collections.isNotEmpty
private val pdfToolbarTools = setOf(
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
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun PdfTopBar( internal fun PdfTopBar(
@ -60,6 +76,8 @@ internal fun PdfTopBar(
totalPages: Int, totalPages: Int,
pagerStatePageCount: Int, pagerStatePageCount: Int,
hiddenTools: Set<String>, hiddenTools: Set<String>,
toolOrder: List<PdfReaderTool>,
bottomTools: Set<String>,
isScrollLocked: Boolean, isScrollLocked: Boolean,
isEditMode: Boolean, isEditMode: Boolean,
displayMode: DisplayMode, displayMode: DisplayMode,
@ -83,6 +101,16 @@ internal fun PdfTopBar(
onShowCustomizeTools: () -> Unit, onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit, onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit, onShowVisualOptions: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit,
isTtsPlayingOrLoading: Boolean,
showAllTextHighlights: Boolean,
isHighlightingLoading: Boolean,
tapToNavigateEnabled: Boolean, tapToNavigateEnabled: Boolean,
onToggleTapToNavigate: () -> Unit, onToggleTapToNavigate: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit, onChangeDisplayMode: (DisplayMode) -> Unit,
@ -153,34 +181,88 @@ internal fun PdfTopBar(
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator") modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
) )
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) { toolOrder
TooltipIconButton( .filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
.forEach { tool ->
when (tool) {
PdfReaderTool.THEME -> TooltipIconButton(
text = stringResource(R.string.tooltip_theme), text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc), description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel onClick = onShowThemePanel
) { ) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc), description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock onClick = onToggleScrollLock
) { ) {
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} PdfReaderTool.DICTIONARY -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary), text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc), description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings onClick = onShowDictionarySettings
) { ) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
PdfReaderTool.SLIDER -> TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading
) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
}
PdfReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
}
PdfReaderTool.SEARCH -> TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights
) {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onShowAiHub
) {
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
}
}
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
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)
}
else -> Unit
}
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
@ -197,14 +279,25 @@ internal fun PdfTopBar(
Box { Box {
var showMoreMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) }
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_more_options), text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc), description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true }) { onClick = {
showHiddenToolsExpanded = false
showMoreMenu = true
}) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options)) Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
} }
DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) { DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = {
showHiddenToolsExpanded = false
showMoreMenu = false
}
) {
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.title_customize_toolbar)) }, text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = { showMoreMenu = false; onShowCustomizeTools() }, onClick = { showMoreMenu = false; onShowCustomizeTools() },
@ -212,6 +305,47 @@ internal fun PdfTopBar(
) )
HorizontalDivider() HorizontalDivider()
if (hiddenToolbarTools.isNotEmpty()) {
DropdownMenuItem(
text = { Text("Hidden tools") },
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
)
}
)
if (showHiddenToolsExpanded) {
hiddenToolbarTools.forEach { tool ->
HiddenPdfToolMenuItem(
tool = tool,
isTtsPlayingOrLoading = isTtsPlayingOrLoading,
showAllTextHighlights = showAllTextHighlights,
isHighlightingLoading = isHighlightingLoading,
isEditMode = isEditMode,
isTtsSessionActive = isTtsSessionActive,
closeMenu = {
showHiddenToolsExpanded = false
showMoreMenu = false
},
onShowThemePanel = onShowThemePanel,
onToggleScrollLock = onToggleScrollLock,
onShowDictionarySettings = onShowDictionarySettings,
onShowSlider = onShowSlider,
onShowToc = onShowToc,
onSearchClick = onSearchClick,
onToggleHighlights = onToggleHighlights,
onShowAiHub = onShowAiHub,
onToggleEditMode = onToggleEditMode,
onToggleTts = onToggleTts
)
}
}
HorizontalDivider()
}
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) { if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_ocr_language)) }, text = { Text(stringResource(R.string.menu_ocr_language)) },
@ -402,6 +536,72 @@ internal fun PdfTopBar(
} }
} }
@Composable
private fun HiddenPdfToolMenuItem(
tool: PdfReaderTool,
isTtsPlayingOrLoading: Boolean,
showAllTextHighlights: Boolean,
isHighlightingLoading: Boolean,
isEditMode: Boolean,
isTtsSessionActive: Boolean,
closeMenu: () -> Unit,
onShowThemePanel: () -> Unit,
onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit
) {
val enabled = when (tool) {
PdfReaderTool.SLIDER,
PdfReaderTool.TOC,
PdfReaderTool.SEARCH -> !isTtsPlayingOrLoading
else -> true
}
DropdownMenuItem(
text = { Text(tool.title) },
enabled = enabled,
onClick = {
closeMenu()
when (tool) {
PdfReaderTool.THEME -> onShowThemePanel()
PdfReaderTool.LOCK_PANNING -> onToggleScrollLock()
PdfReaderTool.DICTIONARY -> onShowDictionarySettings()
PdfReaderTool.SLIDER -> onShowSlider()
PdfReaderTool.TOC -> onShowToc()
PdfReaderTool.SEARCH -> onSearchClick()
PdfReaderTool.HIGHLIGHT_ALL -> onToggleHighlights()
PdfReaderTool.AI_FEATURES -> onShowAiHub()
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
else -> Unit
}
},
leadingIcon = {
when (tool) {
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.HIGHLIGHT_ALL -> {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(20.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
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)
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
}
}
)
}
@Composable @Composable
fun ReflowProgressOverlay( fun ReflowProgressOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -447,6 +647,89 @@ fun ReflowProgressOverlay(
} }
} }
@Composable
fun PdfJumpHistoryBar(
modifier: Modifier = Modifier,
showStandardBars: Boolean,
searchStateActive: Boolean,
backPage: Int?,
forwardPage: Int?,
onBack: () -> Unit,
onForward: () -> Unit,
onClear: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars && !searchStateActive && (backPage != null || forwardPage != 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 = backPage != 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 = backPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
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 = forwardPage != null,
modifier = Modifier.weight(1f)
) {
Text(
text = forwardPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
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)
)
}
}
}
}
}
@Composable @Composable
fun PdfBottomBar( fun PdfBottomBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -455,14 +738,17 @@ fun PdfBottomBar(
systemUiMode: SystemUiMode, systemUiMode: SystemUiMode,
navBarHeightDp: Dp, navBarHeightDp: Dp,
hiddenTools: Set<String>, hiddenTools: Set<String>,
toolOrder: List<PdfReaderTool>,
bottomTools: Set<String>,
isTtsPlayingOrLoading: Boolean, isTtsPlayingOrLoading: Boolean,
showAllTextHighlights: Boolean, showAllTextHighlights: Boolean,
isHighlightingLoading: Boolean, isHighlightingLoading: Boolean,
isEditMode: Boolean, isEditMode: Boolean,
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
ttsErrorMessage: String?, ttsErrorMessage: String?,
jumpBackPage: Int?, onShowThemePanel: () -> Unit,
onJumpBack: () -> Unit, onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit,
onShowSlider: () -> Unit, onShowSlider: () -> Unit,
onShowToc: () -> Unit, onShowToc: () -> Unit,
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
@ -490,35 +776,32 @@ fun PdfBottomBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
if (jumpBackPage != null) { toolOrder
TooltipIconButton( .filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
text = stringResource(R.string.action_jump_back_to_page, jumpBackPage + 1), .forEach { tool ->
description = stringResource(R.string.desc_return_to_previous_page), when (tool) {
onClick = onJumpBack PdfReaderTool.THEME -> TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel
) { ) {
Column( Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
horizontalAlignment = Alignment.CenterHorizontally, }
verticalArrangement = Arrangement.Center PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
text = stringResource(R.string.tooltip_lock_pan),
description = stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock
) { ) {
Icon( Icon(Icons.Default.LockOpen, contentDescription = stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icons.AutoMirrored.Filled.Undo,
contentDescription = stringResource(R.string.content_desc_jump_back),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Text(
text = "${jumpBackPage + 1}",
fontSize = 10.sp,
lineHeight = 10.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
} }
PdfReaderTool.DICTIONARY -> TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings
) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} PdfReaderTool.SLIDER -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider), text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc), description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider, onClick = onShowSlider,
@ -526,9 +809,7 @@ fun PdfBottomBar(
) { ) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider)) Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
} }
} PdfReaderTool.TOC -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_toc), text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc), description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc, onClick = onShowToc,
@ -537,10 +818,7 @@ fun PdfBottomBar(
) { ) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents)) Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
} }
} PdfReaderTool.SEARCH -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_search), text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc), description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick, onClick = onSearchClick,
@ -549,9 +827,7 @@ fun PdfBottomBar(
) { ) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
} }
} PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights), text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc), description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights onClick = onToggleHighlights
@ -559,9 +835,7 @@ fun PdfBottomBar(
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp)) if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
} }
} PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_ai), text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc), description = stringResource(R.string.tooltip_ai_desc),
@ -570,25 +844,22 @@ fun PdfBottomBar(
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai)) Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
} }
} }
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode), text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc), description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode onClick = onToggleEditMode
) { ) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
} }
} PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start), text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc), description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts onClick = onToggleTts
) { ) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
} }
else -> Unit
}
} }
if (BuildConfig.FLAVOR != "oss") { if (BuildConfig.FLAVOR != "oss") {

View file

@ -193,6 +193,7 @@ internal fun PdfVerticalReader(
state: VerticalPdfReaderState, state: VerticalPdfReaderState,
pdfDocument: StableHolder<ReaderDocument>, pdfDocument: StableHolder<ReaderDocument>,
activeTheme: com.aryan.reader.ReaderTheme, activeTheme: com.aryan.reader.ReaderTheme,
activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false, excludeImages: Boolean = false,
totalPages: Int, totalPages: Int,
virtualPages: List<VirtualPage> = emptyList(), virtualPages: List<VirtualPage> = emptyList(),
@ -988,7 +989,7 @@ internal fun PdfVerticalReader(
} }
val buttons = currentEvent.buttons val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d( Timber.tag("StylusDebug").d(
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons" "VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
) )
@ -1688,6 +1689,7 @@ internal fun PdfVerticalReader(
virtualPage = virtualPage, virtualPage = virtualPage,
totalPages = totalPages, totalPages = totalPages,
activeTheme = activeTheme, activeTheme = activeTheme,
activeTextureAlpha = activeTextureAlpha,
excludeImages = excludeImages, excludeImages = excludeImages,
externalScale = highResScale, externalScale = highResScale,
onScaleChanged = {}, onScaleChanged = {},

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,120 @@
package com.aryan.reader.pdf
import com.aryan.reader.shared.pdf.PdfiumBridge
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
internal object PdfiumEngineProvider {
private val pdfiumMutex = Mutex()
val bridge: PdfiumBridge
get() = AndroidPdfiumBridge
val lock: Any = this
suspend fun <T> withPdfium(block: suspend () -> T): T =
pdfiumMutex.withLock { block() }
fun <T> withPdfiumBlocking(block: () -> T): T =
runBlocking {
pdfiumMutex.withLock { block() }
}
}
private object AndroidPdfiumBridge : PdfiumBridge {
override fun getFontSize(textPagePtr: Long, index: Int): Double =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getFontSize(textPagePtr, index)
}
override fun getFontWeight(textPagePtr: Long, index: Int): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getFontWeight(textPagePtr, index)
}
override fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageFontSizes(textPagePtr, count)
}
override fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageFontWeights(textPagePtr, count)
}
override fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageFontFlags(textPagePtr, count)
}
override fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageCharBoxes(textPagePtr, count)
}
override fun getAnnotCount(pagePtr: Long): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotCount(pagePtr)
}
override fun getAnnotSubtype(pagePtr: Long, index: Int): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotSubtype(pagePtr, index)
}
override fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotRect(pagePtr, index)
}
override fun getAnnotString(pagePtr: Long, index: Int, key: String): String? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotString(pagePtr, index, key)
}
override fun getPageObjectCount(pagePtr: Long): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageObjectCount(pagePtr)
}
override fun getPageObjectType(pagePtr: Long, index: Int): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageObjectType(pagePtr, index)
}
override fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)
}
override fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.extractImagePixels(pagePtr, index, dimens)
}
override fun performClick(pagePtr: Long, x: Double, y: Double): Boolean =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.performClick(pagePtr, x, y)
}
override fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getLinkInfoAtPoint(docPtr, pagePtr, x, y)
}
override fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotSubtypeAtPoint(pagePtr, x, y)
}
override fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotRectAtPoint(pagePtr, x, y)
}
override fun checkActionSupport(): Boolean =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.checkActionSupport()
}
}

View file

@ -28,6 +28,7 @@ import me.zhanghai.android.libarchive.ArchiveException
import okhttp3.Request import okhttp3.Request
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipFile import java.util.zip.ZipFile
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
@ -88,42 +89,97 @@ object DocumentFactory {
ArchiveDocumentWrapper(cacheFile) ArchiveDocumentWrapper(cacheFile)
} else { } else {
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF") val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
PdfDocumentWrapper(pdfiumCore.newDocument(pfd, password)) PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
} }
} }
} }
// ================= PDF IMPLEMENTATION ================= // ================= PDF IMPLEMENTATION =================
private inline fun closePdfiumResource(tag: String, closeBlock: () -> Unit) {
try {
closeBlock()
} catch (e: IllegalStateException) {
if (e.message == "Already closed") {
Timber.tag(tag).d(e, "Ignoring duplicate Pdfium close")
} else {
throw e
}
}
}
class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument { class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
override suspend fun getPageCount() = pdfDocument.getPageCount() private val isClosed = AtomicBoolean(false)
override suspend fun getPageCount() = PdfiumEngineProvider.withPdfium {
pdfDocument.getPageCount()
}
override suspend fun openPage(pageIndex: Int): ReaderPage? { override suspend fun openPage(pageIndex: Int): ReaderPage? {
val page = pdfDocument.openPage(pageIndex) ?: return null if (isClosed.get()) return null
val page = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
} ?: return null
return PdfPageWrapper(page) return PdfPageWrapper(page)
} }
override suspend fun getTableOfContents() = pdfDocument.getFixedTableOfContents()
override fun close() { pdfDocument.close() } override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
pdfDocument.getFixedTableOfContents()
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfDocumentWrapper") { pdfDocument.close() }
}
}
} }
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage { class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
override suspend fun getPageWidthPoint() = pdfPage.getPageWidthPoint() private val isClosed = AtomicBoolean(false)
override suspend fun getPageHeightPoint() = pdfPage.getPageHeightPoint()
override suspend fun getPageRotation() = pdfPage.getPageRotation() override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageWidthPoint()
}
override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageHeightPoint()
}
override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageRotation()
}
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) { override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
PdfiumEngineProvider.withPdfium {
if (!isClosed.get()) {
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot) pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
} }
}
}
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) = override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords) PdfiumEngineProvider.withPdfium {
if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
}
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) = override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY) PdfiumEngineProvider.withPdfium {
if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
}
override suspend fun openTextPage(): ReaderTextPage = PdfTextPageWrapper(pdfPage.openTextPage()) override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage())
}
override suspend fun getLinks(): List<ReaderLink> { override suspend fun getLinks(): List<ReaderLink> {
return pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) } return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) {
emptyList()
} else {
pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
}
}
} }
override fun getNativePointer(): Long { override fun getNativePointer(): Long {
@ -159,29 +215,80 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
return 0L return 0L
} }
override fun close() { pdfPage.close() } override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
}
}
} }
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage { class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
override suspend fun textPageCountChars() = textPage.textPageCountChars() private val isClosed = AtomicBoolean(false)
override suspend fun textPageGetText(startIndex: Int, count: Int) = textPage.textPageGetText(startIndex, count)
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) } override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance) if (isClosed.get()) 0 else textPage.textPageCountChars()
override suspend fun textPageGetCharBox(index: Int) = textPage.textPageGetCharBox(index) }
override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetText(startIndex, count)
}
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
}
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
}
override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetCharBox(index)
}
override suspend fun textPageGetUnicode(index: Int): Int { override suspend fun textPageGetUnicode(index: Int): Int {
return textPage.textPageGetUnicode(index).code return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
} }
}
override suspend fun loadWebLink(): ReaderWebLinks? { override suspend fun loadWebLink(): ReaderWebLinks? {
val links = textPage.loadWebLink() ?: return null val links = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.loadWebLink()
} ?: return null
return object : ReaderWebLinks { return object : ReaderWebLinks {
override suspend fun countWebLinks() = links.countWebLinks() private val isClosed = AtomicBoolean(false)
override suspend fun getURL(linkIndex: Int, maxLength: Int) = links.getURL(linkIndex, maxLength)
override suspend fun countRects(linkIndex: Int) = links.countRects(linkIndex) override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = links.getRect(linkIndex, rectIndex) if (isClosed.get()) 0 else links.countWebLinks()
override fun close() { links.close() } }
override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else links.getURL(linkIndex, maxLength)
}
override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else links.countRects(linkIndex)
}
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex)
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfWebLinksWrapper") { links.close() }
}
}
}
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
} }
} }
override fun close() { textPage.close() }
} }
// ================= CBZ, CBR, CB7 IMPLEMENTATION ================= // ================= CBZ, CBR, CB7 IMPLEMENTATION =================

View file

@ -34,6 +34,7 @@ import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.flatMap import androidx.paging.flatMap
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.PdfiumEngineProvider
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
@ -171,6 +172,7 @@ class PdfTextRepository(context: Context) {
var ocrUsed = false var ocrUsed = false
try { try {
PdfiumEngineProvider.withPdfium {
document.openPage(pageIndex)?.use { page -> document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars() val count = textPage.textPageCountChars()
@ -182,12 +184,15 @@ class PdfTextRepository(context: Context) {
} }
} }
} }
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Native extraction failed for page $pageIndex") Timber.tag(TAG).e(e, "Native extraction failed for page $pageIndex")
} }
if (text.isBlank()) { if (text.isBlank()) {
var bitmap: android.graphics.Bitmap? = null
try { try {
PdfiumEngineProvider.withPdfium {
document.openPage(pageIndex)?.use { page -> document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080 val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint() val ptrWidth = page.getPageWidthPoint()
@ -197,16 +202,23 @@ class PdfTextRepository(context: Context) {
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat() val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1) val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
val bitmap = createBitmap(targetWidth, targetHeight) bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
}
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onOcrModelDownloading) }
}
bitmap?.let {
try {
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
text = visionText?.text ?: "" text = visionText?.text ?: ""
bitmap.recycle()
ocrUsed = true ocrUsed = true
} finally {
it.recycle()
bitmap = null
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
bitmap?.recycle()
Timber.tag(TAG).e(e, "OCR failed for page $pageIndex") Timber.tag(TAG).e(e, "OCR failed for page $pageIndex")
} }
} }
@ -270,11 +282,13 @@ class PdfTextRepository(context: Context) {
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean { suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
PdfiumEngineProvider.withPdfium {
document.openPage(pageIndex)?.use { page -> document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage -> page.openTextPage().use { textPage ->
textPage.textPageCountChars() > 0 textPage.textPageCountChars() > 0
} }
} ?: false } ?: false
}
} catch (_: Exception) { } catch (_: Exception) {
false false
} }

View file

@ -34,6 +34,7 @@ import java.io.File
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -247,11 +248,16 @@ class BaseTtsSynthesizer(private val context: Context) {
try { try {
withTimeout(startTimeout) { withTimeout(startTimeout) {
startSignal.await() select {
startSignal.onAwait { }
resultDeferred.onAwait { }
}
} }
} catch (_: TimeoutCancellationException) { } catch (_: TimeoutCancellationException) {
Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.") Timber.w(
throw ZombieEngineException() "BaseTts: onStart not received within ${startTimeout}ms for $utteranceId. " +
"Continuing to wait for onDone because some engines omit or delay onStart for file synthesis."
)
} }
try { try {
@ -296,5 +302,4 @@ class BaseTtsSynthesizer(private val context: Context) {
Timber.d("TextToSpeech engine shut down.") Timber.d("TextToSpeech engine shut down.")
} }
private class ZombieEngineException : Exception("Engine failed to start")
} }

View file

@ -39,6 +39,7 @@ import androidx.media3.session.SessionToken
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.epubreader.loadTtsPitch import com.aryan.reader.epubreader.loadTtsPitch
import com.aryan.reader.epubreader.loadTtsSpeechRate import com.aryan.reader.epubreader.loadTtsSpeechRate
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.tts.TtsPlaybackManager.TtsState import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
@ -72,7 +73,7 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name) val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
?: TtsPlaybackManager.TtsMode.BASE.name ?: TtsPlaybackManager.TtsMode.BASE.name
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
return if (isCloudAllowed) { return if (isCloudAllowed) {
try { try {
@ -105,8 +106,14 @@ class TtsController(context: Context) : Player.Listener {
} }
fun connect() { fun connect() {
if (mediaController != null || controllerFuture != null) return if (mediaController != null || controllerFuture != null) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TtsController.connect skipped. hasController=${mediaController != null}, hasFuture=${controllerFuture != null}"
)
return
}
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("TtsController.connect building MediaController.")
val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java)) val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
val future = MediaController.Builder(context, sessionToken).buildAsync() val future = MediaController.Builder(context, sessionToken).buildAsync()
controllerFuture = future controllerFuture = future
@ -129,10 +136,14 @@ class TtsController(context: Context) : Player.Listener {
mediaController?.addListener(this) mediaController?.addListener(this)
Timber.d("MediaController connected.") Timber.d("MediaController connected.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"MediaController connected. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, customLayout=${controller.customLayout.size}"
)
updateStateFromController() updateStateFromController()
startPolling() startPolling()
} catch (e: Exception) { } catch (e: Exception) {
Timber.w("Failed to connect MediaController: ${e.message}") Timber.w("Failed to connect MediaController: ${e.message}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "MediaController connection failed.")
if (controllerFuture == future) { if (controllerFuture == future) {
controllerFuture = null controllerFuture = null
} }
@ -158,15 +169,21 @@ class TtsController(context: Context) : Player.Listener {
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
chapterIndex: Int? = null, chapterIndex: Int? = null,
totalChapters: Int? = null,
continueSession: Boolean = false,
ttsMode: TtsPlaybackManager.TtsMode, ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER", playbackSource: String = "READER",
authToken: String? = null authToken: String? = null
) { ) {
if (chunks.isEmpty()) { if (chunks.isEmpty()) {
Timber.w("TtsController: start called with empty chunks!") Timber.w("TtsController: start called with empty chunks!")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
return return
} }
Timber.d("UI sending START command with mode: $ttsMode") Timber.d("UI sending START command with mode: $ttsMode")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
)
val textList = ArrayList(chunks.map { it.text }) val textList = ArrayList(chunks.map { it.text })
val cfiList = ArrayList(chunks.map { it.sourceCfi }) val cfiList = ArrayList(chunks.map { it.sourceCfi })
@ -181,6 +198,8 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_CHAPTER_TITLE, chapterTitle) putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri) putString(KEY_COVER_IMAGE_URI, coverImageUri)
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) } chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
putBoolean(KEY_CONTINUE_SESSION, continueSession)
putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource) putString(KEY_PLAYBACK_SOURCE, playbackSource)
putString(KEY_AUTH_TOKEN, authToken) putString(KEY_AUTH_TOKEN, authToken)
@ -188,7 +207,15 @@ class TtsController(context: Context) : Player.Listener {
putFloat("playback_pitch", loadTtsPitch(context)) putFloat("playback_pitch", loadTtsPitch(context))
} }
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}") Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
mediaController?.sendCustomCommand(START_TTS_COMMAND, args) val controller = mediaController
if (controller == null) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
} else {
val result = controller.sendCustomCommand(START_TTS_COMMAND, args)
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"START command sent. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, resultDone=${result.isDone}"
)
}
} }
fun pause() { fun pause() {
@ -240,6 +267,9 @@ class TtsController(context: Context) : Player.Listener {
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Controller onEvents. playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}, events=$events"
)
updateStateFromController() updateStateFromController()
} }
@ -247,7 +277,9 @@ class TtsController(context: Context) : Player.Listener {
mediaController?.let { controller -> mediaController?.let { controller ->
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
val currentMediaItem = controller.currentMediaItem val currentMediaItem = controller.currentMediaItem
val currentTextFromMediaItem = currentMediaItem?.mediaMetadata?.subtitle?.toString() val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId) val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false) val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
@ -255,9 +287,13 @@ class TtsController(context: Context) : Player.Listener {
val sessionFinished = customState.getBoolean("sessionFinished", false) val sessionFinished = customState.getBoolean("sessionFinished", false)
val playbackSource = customState.getString("playbackSource") val playbackSource = customState.getString("playbackSource")
val serviceBookTitle = customState.getString("bookTitle") val serviceBookTitle = customState.getString("bookTitle")
val serviceChapterTitle = customState.getString("chapterTitle")
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 } val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 }
val serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1)
val serviceTotalChunks = customState.getInt("totalChunks", 0)
val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 }
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
val sourceCfi = mediaItemExtras?.getString("sourceCfi") val sourceCfi = mediaItemExtras?.getString("sourceCfi")
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1 val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
val currentWordSourceCfi = customState.getString("currentWordSourceCfi") val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
@ -279,11 +315,24 @@ class TtsController(context: Context) : Player.Listener {
} else { } else {
if (isLoading) currentState.bookTitle else serviceBookTitle if (isLoading) currentState.bookTitle else serviceBookTitle
}, },
chapterTitle = if (isPlaybackActive || isLoading) {
serviceChapterTitle ?: currentState.chapterTitle
} else {
serviceChapterTitle
},
chapterIndex = if (isPlaybackActive || isLoading) { chapterIndex = if (isPlaybackActive || isLoading) {
serviceChapterIndex ?: currentState.chapterIndex serviceChapterIndex ?: currentState.chapterIndex
} else { } else {
serviceChapterIndex serviceChapterIndex
}, },
totalChapters = if (isPlaybackActive || isLoading) {
serviceTotalChapters ?: currentState.totalChapters
} else {
serviceTotalChapters
},
currentChunkIndex = serviceCurrentChunkIndex,
totalChunks = serviceTotalChunks,
bookProgressPercent = serviceBookProgressPercent,
speakerId = serviceSpeaker, speakerId = serviceSpeaker,
sourceCfi = if (isPlaybackActive) { sourceCfi = if (isPlaybackActive) {
sourceCfi sourceCfi

View file

@ -48,6 +48,7 @@ import androidx.core.net.toUri
import com.aryan.reader.paginatedreader.TimedWord import com.aryan.reader.paginatedreader.TimedWord
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY) val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY) val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
@ -57,6 +58,7 @@ private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UP
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY) val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY) val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY) val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY)
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS" const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS" const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
@ -71,6 +73,8 @@ const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE" const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX" const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
const val KEY_TOTAL_CHAPTERS = "KEY_TOTAL_CHAPTERS"
const val KEY_CONTINUE_SESSION = "KEY_CONTINUE_SESSION"
private const val PREFETCH_LOOKAHEAD = 3 private const val PREFETCH_LOOKAHEAD = 3
@ -102,7 +106,12 @@ class TtsPlaybackManager(
val currentText: String? = null, val currentText: String? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val bookTitle: String? = null, val bookTitle: String? = null,
val chapterTitle: String? = null,
val chapterIndex: Int? = null, val chapterIndex: Int? = null,
val totalChapters: Int? = null,
val currentChunkIndex: Int = -1,
val totalChunks: Int = 0,
val bookProgressPercent: Int? = null,
val speakerId: String = DEFAULT_SPEAKER_ID, val speakerId: String = DEFAULT_SPEAKER_ID,
val sourceCfi: String? = null, val sourceCfi: String? = null,
val startOffsetInSource: Int = -1, val startOffsetInSource: Int = -1,
@ -124,6 +133,8 @@ class TtsPlaybackManager(
private var chapterTitle: String? = null private var chapterTitle: String? = null
private var coverImageUri: String? = null private var coverImageUri: String? = null
private var currentTtsMode = TtsMode.CLOUD private var currentTtsMode = TtsMode.CLOUD
private var chapterIndex: Int? = null
private var totalChapters: Int? = null
init { init {
player.addListener(this) player.addListener(this)
@ -146,6 +157,9 @@ class TtsPlaybackManager(
session: MediaSession, session: MediaSession,
controller: MediaSession.ControllerInfo controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult { ): MediaSession.ConnectionResult {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"MediaSession onConnect. package=${controller.packageName}, uid=${controller.uid}"
)
val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(START_TTS_COMMAND) .add(START_TTS_COMMAND)
.add(STOP_TTS_COMMAND) .add(STOP_TTS_COMMAND)
@ -186,6 +200,9 @@ class TtsPlaybackManager(
START_TTS_COMMAND -> { START_TTS_COMMAND -> {
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList() val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
Timber.d("TtsService: START command received. Size: ${chunks.size}") Timber.d("TtsService: START command received. Size: ${chunks.size}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"START command received. chunks=${chunks.size}, continueSession=${args.getBoolean(KEY_CONTINUE_SESSION, false)}, source=${args.getString(KEY_PLAYBACK_SOURCE)}, mode=${args.getString(KEY_TTS_MODE)}, chapterIndex=${args.getInt(KEY_CHAPTER_INDEX, -1)}, totalChapters=${args.getInt(KEY_TOTAL_CHAPTERS, -1)}"
)
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS) val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS) val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID) val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
@ -193,6 +210,7 @@ class TtsPlaybackManager(
val chapterTitle = args.getString(KEY_CHAPTER_TITLE) val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI) val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 } val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
val totalChapters = args.getInt(KEY_TOTAL_CHAPTERS, -1).takeIf { it > 0 }
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name) val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE) val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD } val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
@ -208,10 +226,11 @@ class TtsPlaybackManager(
val authToken = args.getString(KEY_AUTH_TOKEN) val authToken = args.getString(KEY_AUTH_TOKEN)
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}") Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args) handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, totalChapters, ttsMode, playbackSource, args)
} }
STOP_TTS_COMMAND -> { STOP_TTS_COMMAND -> {
Timber.d("Received STOP command.") Timber.d("Received STOP command.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("STOP command received.")
handleStopTts(userInitiated = true) handleStopTts(userInitiated = true)
} }
CHANGE_SPEAKER_COMMAND -> { CHANGE_SPEAKER_COMMAND -> {
@ -342,17 +361,20 @@ class TtsPlaybackManager(
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
chapterIndex: Int?, chapterIndex: Int?,
totalChapters: Int?,
ttsMode: TtsMode, ttsMode: TtsMode,
playbackSource: String?, playbackSource: String?,
args: Bundle // Added this parameter args: Bundle // Added this parameter
) { ) {
if (chunks.isEmpty()) { if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.") _ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
return return
} }
// --- YOUR SNIPPET START --- // --- YOUR SNIPPET START ---
val authToken = args.getString(KEY_AUTH_TOKEN) val authToken = args.getString(KEY_AUTH_TOKEN)
val continueSession = args.getBoolean(KEY_CONTINUE_SESSION, false)
val speed = args.getFloat("playback_speed", 1f) val speed = args.getFloat("playback_speed", 1f)
val pitch = args.getFloat("playback_pitch", 1f) val pitch = args.getFloat("playback_pitch", 1f)
@ -365,27 +387,54 @@ class TtsPlaybackManager(
} }
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}") Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStartTts. continueSession=$continueSession, chunks=${chunks.size}, book='${bookTitle.orEmpty().take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters, mode=$ttsMode, playbackSource=$playbackSource"
)
if (!continueSession) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("New TTS session. Calling handleStopTts(clearState=false) before start.")
handleStopTts(clearState = false) handleStopTts(clearState = false)
}
textChunks = chunks textChunks = chunks
currentSpeakerId = speakerId currentSpeakerId = speakerId
currentTtsMode = ttsMode currentTtsMode = ttsMode
this.bookTitle = bookTitle this.bookTitle = bookTitle
this.chapterTitle = chapterTitle this.chapterTitle = chapterTitle
this.coverImageUri = coverImageUri this.coverImageUri = coverImageUri
this.chapterIndex = chapterIndex
this.totalChapters = totalChapters
onResetContext()
loadedChunks.clear() loadedChunks.clear()
lastPrefetchIndex = -1 lastPrefetchIndex = -1
_ttsState.value = TtsState( _ttsState.value = TtsState(
isLoading = true, isLoading = true,
bookTitle = bookTitle, bookTitle = bookTitle,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = -1,
totalChunks = chunks.size,
bookProgressPercent = calculateBookProgressPercent(-1),
speakerId = speakerId, speakerId = speakerId,
playbackSource = playbackSource, playbackSource = playbackSource,
ttsMode = ttsMode.name ttsMode = ttsMode.name,
currentText = if (continueSession) _ttsState.value.currentText else null
) )
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TTS state set to loading. bookProgress=${_ttsState.value.bookProgressPercent}, currentTextRetained=${_ttsState.value.currentText != null}"
)
if (continueSession) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Continuation start. Cancelling prefetch/tracking but keeping player session alive until replacement media is ready.")
preparationJob?.cancel()
wordTrackingJob?.cancel()
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
clearPlaylistForContinuation()
}
currentAuthToken = authToken currentAuthToken = authToken
preparationJob = scope.launch { preparationJob = scope.launch {
@ -411,6 +460,73 @@ class TtsPlaybackManager(
Timber.d("Speaker changed to $newSpeakerId (pending next start)") Timber.d("Speaker changed to $newSpeakerId (pending next start)")
} }
private fun currentChunkIndexFromPlayer(): Int {
return player.currentMediaItem?.mediaId?.toIntOrNull()
?: player.currentMediaItemIndex
}
private fun calculateBookProgressPercent(chunkIndex: Int): Int? {
val chapter = chapterIndex ?: return null
val chapterCount = totalChapters?.takeIf { it > 0 } ?: return null
val safeChunkProgress = if (textChunks.isNotEmpty() && chunkIndex >= 0) {
((chunkIndex + 1).toDouble() / textChunks.size.toDouble()).coerceIn(0.0, 1.0)
} else {
0.0
}
return (((chapter.toDouble() + safeChunkProgress) / chapterCount.toDouble()) * 100.0)
.roundToInt()
.coerceIn(0, 100)
}
private fun markSessionFinishedNaturally(chunkIndex: Int) {
val currentState = _ttsState.value
if (currentState.isLoading && currentState.currentChunkIndex == -1) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Ignoring stale streamed completion while a continuation session is loading."
)
return
}
val safeChunkIndex = if (textChunks.isNotEmpty()) {
chunkIndex.coerceIn(0, textChunks.lastIndex)
} else {
-1
}
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i(
"Setting sessionFinished = true for naturally completed streamed TTS. chunk=$safeChunkIndex, totalChunks=${textChunks.size}"
)
_ttsState.value = _ttsState.value.copy(
isPlaying = false,
isLoading = false,
currentChunkIndex = safeChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(safeChunkIndex),
currentWordSourceCfi = null,
currentWordStartOffset = -1,
sessionFinished = true
)
}
private fun clearPlaylistForContinuation() {
val filesToDelete = audioFiles.values.toList()
val streamsToRemove = chunkStreamIds.values.toList()
audioFiles.clear()
chunkStreamIds.clear()
loadedChunks.clear()
lastPrefetchIndex = -1
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Cleared continuation temp resources. oldFiles=${filesToDelete.size}, oldStreams=${streamsToRemove.size}"
)
scope.launch(Dispatchers.IO) {
filesToDelete.forEach { deleteTempFile(it) }
streamsToRemove.forEach { StreamRegistry.remove(it) }
}
}
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) { private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
val firstChunk = textChunks.getOrNull(startAtIndex) val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) { if (firstChunk == null) {
@ -420,6 +536,9 @@ class TtsPlaybackManager(
val chunkStartTime = System.currentTimeMillis() val chunkStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).") Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
)
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken) val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms") Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
@ -464,17 +583,30 @@ class TtsPlaybackManager(
} }
player.playWhenReady = playWhenReady player.playWhenReady = playWhenReady
Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms") Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Player prepared for TTS. mediaId=${mediaItem.mediaId}, title='${mediaItem.mediaMetadata.title}', playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}, mediaItems=${player.mediaItemCount}"
)
_ttsState.value = _ttsState.value.copy( _ttsState.value = _ttsState.value.copy(
isLoading = false, isLoading = false,
isPlaying = playWhenReady, isPlaying = playWhenReady,
currentText = serverText, currentText = serverText,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = startAtIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(startAtIndex),
sessionFinished = false,
sourceCfi = updatedChunk.sourceCfi, sourceCfi = updatedChunk.sourceCfi,
startOffsetInSource = updatedChunk.startOffsetInSource startOffsetInSource = updatedChunk.startOffsetInSource
) )
} }
prefetchNextChunkAudio(startAtIndex) prefetchNextChunkAudio(startAtIndex)
} else { } else {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.") _ttsState.value = _ttsState.value.copy(
isLoading = false,
errorMessage = ttsAudioData.error ?: "Failed to load audio."
)
} }
} }
@ -509,6 +641,9 @@ class TtsPlaybackManager(
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) { private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated") Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
)
onResetContext() onResetContext()
preparationJob?.cancel() preparationJob?.cancel()
wordTrackingJob?.cancel() wordTrackingJob?.cancel()
@ -527,6 +662,11 @@ class TtsPlaybackManager(
player.stop() player.stop()
player.clearMediaItems() player.clearMediaItems()
textChunks = emptyList() textChunks = emptyList()
bookTitle = null
chapterTitle = null
coverImageUri = null
chapterIndex = null
totalChapters = null
lastPrefetchIndex = -1 lastPrefetchIndex = -1
prefetchLoopJob?.cancel() prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.values.forEach { it.cancel() }
@ -541,17 +681,27 @@ class TtsPlaybackManager(
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val newPlaylistIndex = player.currentMediaItemIndex val newPlaylistIndex = player.currentMediaItemIndex
Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason") Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onMediaItemTransition. playlistIndex=$newPlaylistIndex, mediaId=${mediaItem?.mediaId}, reason=$reason, title='${mediaItem?.mediaMetadata?.title}', playbackState=${player.playbackState}, isPlaying=${player.isPlaying}"
)
if (newPlaylistIndex == C.INDEX_UNSET) return if (newPlaylistIndex == C.INDEX_UNSET) return
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
val newText = mediaItem.mediaMetadata.subtitle?.toString()
val extras = mediaItem.mediaMetadata.extras val extras = mediaItem.mediaMetadata.extras
val newText = extras?.getString("ttsText") ?: mediaItem.mediaMetadata.subtitle?.toString()
val sourceCfi = extras?.getString("sourceCfi") val sourceCfi = extras?.getString("sourceCfi")
val startOffset = extras?.getInt("startOffset", -1) ?: -1 val startOffset = extras?.getInt("startOffset", -1) ?: -1
_ttsState.value = _ttsState.value.copy( _ttsState.value = _ttsState.value.copy(
currentText = newText, currentText = newText,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
sessionFinished = false,
sourceCfi = sourceCfi, sourceCfi = sourceCfi,
startOffsetInSource = startOffset startOffsetInSource = startOffset
) )
@ -582,6 +732,9 @@ class TtsPlaybackManager(
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onIsPlayingChanged. isPlaying=$isPlaying, playbackState=${player.playbackState}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
)
var nextState = _ttsState.value.copy(isPlaying = isPlaying) var nextState = _ttsState.value.copy(isPlaying = isPlaying)
if (isPlaying) { if (isPlaying) {
@ -599,14 +752,22 @@ class TtsPlaybackManager(
currentWordStartOffset = -1 currentWordStartOffset = -1
) )
val currentChunkIndex = player.currentMediaItemIndex val currentChunkIndex = currentChunkIndexFromPlayer()
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1 val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
if (player.playbackState == Player.STATE_ENDED) { if (player.playbackState == Player.STATE_ENDED) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Player reached ENDED. currentChunkIndex=$currentChunkIndex, isLastChunk=$isLastChunkInSession, totalChunks=${textChunks.size}, sessionFinishedWillBeSet=${isLastChunkInSession || textChunks.isEmpty()}"
)
if (isLastChunkInSession || textChunks.isEmpty()) { if (isLastChunkInSession || textChunks.isEmpty()) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
nextState = nextState.copy(sessionFinished = true) nextState = nextState.copy(
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
sessionFinished = true
)
} else { } else {
val nextIdx = currentChunkIndex + 1 val nextIdx = currentChunkIndex + 1
val isPrefetching = prefetchingJobs.containsKey(nextIdx) val isPrefetching = prefetchingJobs.containsKey(nextIdx)
@ -625,6 +786,7 @@ class TtsPlaybackManager(
if (!isPlaying && player.playbackState == Player.STATE_IDLE) { if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) { if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) {
Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)") Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Auto-stopping from IDLE/not-loading path.")
handleStopTts(userInitiated = true) handleStopTts(userInitiated = true)
} else { } else {
Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}") Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}")
@ -634,6 +796,7 @@ class TtsPlaybackManager(
override fun onPlayerError(error: androidx.media3.common.PlaybackException) { override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}") Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}") _ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
handleStopTts(userInitiated = true) handleStopTts(userInitiated = true)
} }
@ -719,10 +882,13 @@ class TtsPlaybackManager(
player.addMediaItem(insertPosition, nextMediaItem) player.addMediaItem(insertPosition, nextMediaItem)
} }
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) { val currentChunkIndex = currentChunkIndexFromPlayer()
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
player.seekToNextMediaItem() player.seekToNextMediaItem()
player.play() player.play()
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) { } else if (wasLoading && isImmediateNextChunk) {
_ttsState.value = _ttsState.value.copy(isLoading = false) _ttsState.value = _ttsState.value.copy(isLoading = false)
} }
} }
@ -768,7 +934,10 @@ class TtsPlaybackManager(
if (player.hasNextMediaItem()) { if (player.hasNextMediaItem()) {
player.seekToNextMediaItem() player.seekToNextMediaItem()
} else { } else {
player.stop() val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull()
?: currentChunkIndexFromPlayer()
markSessionFinishedNaturally(finishedChunkIndex)
player.pause()
} }
} }
} }
@ -811,7 +980,37 @@ class TtsPlaybackManager(
} }
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem { private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
val progress = calculateBookProgressPercent(index)
val chunkLabel = if (textChunks.isNotEmpty()) {
"Chunk ${index + 1}/${textChunks.size}"
} else {
null
}
val chapterLabel = buildString {
val chapter = chapterIndex
val chapterCount = totalChapters
if (chapter != null && chapterCount != null) {
append("Chapter ${chapter + 1} of $chapterCount")
if (!chapterTitle.isNullOrBlank()) append(": $chapterTitle")
} else if (!chapterTitle.isNullOrBlank()) {
append(chapterTitle)
}
if (progress != null) {
if (isNotEmpty()) append(" - ")
append("$progress%")
}
if (chunkLabel != null) {
if (isNotEmpty()) append(" - ")
append(chunkLabel)
}
}.ifBlank { chapterTitle ?: chunkLabel ?: "TTS" }
val chunkPreview = text
.replace(Regex("\\s+"), " ")
.trim()
.take(180)
val extras = Bundle().apply { val extras = Bundle().apply {
putString("ttsText", text)
putString("sourceCfi", chunk.sourceCfi) putString("sourceCfi", chunk.sourceCfi)
putInt("startOffset", chunk.startOffsetInSource) putInt("startOffset", chunk.startOffsetInSource)
if (chunk.timedWords.isNotEmpty()) { if (chunk.timedWords.isNotEmpty()) {
@ -823,9 +1022,11 @@ class TtsPlaybackManager(
} }
val metadata = MediaMetadata.Builder() val metadata = MediaMetadata.Builder()
.setArtist(bookTitle) .setTitle(bookTitle ?: chapterLabel)
.setTitle(chapterTitle) .setDisplayTitle(bookTitle ?: chapterLabel)
.setSubtitle(text) .setArtist(chapterLabel)
.setSubtitle(chunkPreview)
.setDescription(chunkPreview)
.setArtworkUri(coverImageUri?.toUri()) .setArtworkUri(coverImageUri?.toUri())
.setTrackNumber(index + 1) .setTrackNumber(index + 1)
.setTotalTrackCount(textChunks.size) .setTotalTrackCount(textChunks.size)
@ -865,7 +1066,12 @@ class TtsPlaybackManager(
putBoolean("isLoading", state.isLoading) putBoolean("isLoading", state.isLoading)
putString("errorMessage", state.errorMessage) putString("errorMessage", state.errorMessage)
putString("bookTitle", state.bookTitle) putString("bookTitle", state.bookTitle)
putString("chapterTitle", state.chapterTitle)
putInt("chapterIndex", state.chapterIndex ?: -1) putInt("chapterIndex", state.chapterIndex ?: -1)
putInt("totalChapters", state.totalChapters ?: -1)
putInt("currentChunkIndex", state.currentChunkIndex)
putInt("totalChunks", state.totalChunks)
putInt("bookProgressPercent", state.bookProgressPercent ?: -1)
putString("speakerId", state.speakerId) putString("speakerId", state.speakerId)
putBoolean("sessionEndedByStop", state.sessionEndedByStop) putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi) putString("currentWordSourceCfi", state.currentWordSourceCfi)
@ -905,5 +1111,8 @@ class TtsPlaybackManager(
else -> "UNKNOWN" else -> "UNKNOWN"
} }
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName") Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onPlaybackStateChanged. state=$stateName, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
)
} }
} }

View file

@ -30,6 +30,9 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.loadAiByokSettings
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -236,16 +239,31 @@ class TtsService : MediaSessionService() {
private lateinit var cacheManager: TtsCacheManager private lateinit var cacheManager: TtsCacheManager
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
val playerState = if (::player.isInitialized) {
"playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
} else {
"player=uninitialized"
}
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onUpdateNotification called. startInForegroundRequired=$startInForegroundRequired, hasPostNotifications=$hasNotificationPermission, $playerState"
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
if (startInForegroundRequired) { if (startInForegroundRequired) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
stopSelf() stopSelf()
} else {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
} }
return return
} }
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
super.onUpdateNotification(session, startInForegroundRequired) super.onUpdateNotification(session, startInForegroundRequired)
} }
@ -279,7 +297,12 @@ class TtsService : MediaSessionService() {
data class Error(val message: String) : GeminiWsEvent() data class Error(val message: String) : GeminiWsEvent()
} }
suspend fun ensureConnected(serverUrl: String, speaker: String, authToken: String?) = connectionMutex.withLock { suspend fun ensureConnected(
serverUrl: String,
speaker: String,
authToken: String?,
directGeminiApiKey: String? = null
) = connectionMutex.withLock {
if (webSocket != null) { if (webSocket != null) {
if (connectedSpeaker == speaker) { if (connectedSpeaker == speaker) {
val isSetup = try { setupDeferred.await() } catch(_: Exception) { false } val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
@ -290,11 +313,15 @@ class TtsService : MediaSessionService() {
webSocket = null webSocket = null
} }
val url = if (!directGeminiApiKey.isNullOrBlank()) {
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$directGeminiApiKey"
} else {
val sanitizedUrl = serverUrl.removeSuffix("/") val sanitizedUrl = serverUrl.removeSuffix("/")
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://") val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}" "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
}
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: $url") Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: ${if (!directGeminiApiKey.isNullOrBlank()) "Gemini BYOK" else url}")
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
val connectedDeferred = CompletableDeferred<Boolean>() val connectedDeferred = CompletableDeferred<Boolean>()
@ -316,7 +343,7 @@ class TtsService : MediaSessionService() {
val setupMsg = JSONObject().apply { val setupMsg = JSONObject().apply {
put("setup", JSONObject().apply { put("setup", JSONObject().apply {
put("model", "models/gemini-3.1-flash-live-preview") put("model", "models/$GEMINI_CLOUD_TTS_MODEL")
put("systemInstruction", JSONObject().apply { put("systemInstruction", JSONObject().apply {
put("parts", org.json.JSONArray().apply { put("parts", org.json.JSONArray().apply {
put(JSONObject().apply { put(JSONObject().apply {
@ -552,8 +579,17 @@ class TtsService : MediaSessionService() {
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null) TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
} else { } else {
try { try {
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken) val directGeminiApiKey = if (isByokCloudTtsAvailable(this@TtsService)) {
loadAiByokSettings(this@TtsService).geminiKey
} else {
null
}
if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.")
} else {
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
liveClient.generateChunk(text, cachedFile) liveClient.generateChunk(text, cachedFile)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed") Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service") TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
@ -567,6 +603,11 @@ class TtsService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.d("TtsService created.") Timber.d("TtsService created.")
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TtsService onCreate. sdk=${Build.VERSION.SDK_INT}, hasPostNotifications=$hasNotificationPermission"
)
cacheManager = TtsCacheManager(this) cacheManager = TtsCacheManager(this)
@ -622,6 +663,7 @@ class TtsService : MediaSessionService() {
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory)) .setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
.build() .build()
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
playbackManager = TtsPlaybackManager( playbackManager = TtsPlaybackManager(
player = player, player = player,
@ -634,21 +676,30 @@ class TtsService : MediaSessionService() {
.build() .build()
mediaSession?.let { playbackManager.setMediaSession(it) } mediaSession?.let { playbackManager.setMediaSession(it) }
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("MediaSession created and attached to playback manager. sessionAvailable=${mediaSession != null}")
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
)
if (!player.playWhenReady) { if (!player.playWhenReady) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
stopSelf() stopSelf()
} }
Timber.d("onTaskRemoved called, stopping service.") Timber.d("onTaskRemoved called, stopping service.")
} }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onGetSession. package=${controllerInfo.packageName}, sessionAvailable=${mediaSession != null}"
)
return mediaSession return mediaSession
} }
override fun onDestroy() { override fun onDestroy() {
Timber.d("TtsService is being destroyed.") Timber.d("TtsService is being destroyed.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
baseTtsSynthesizer.shutdown() baseTtsSynthesizer.shutdown()
playbackManager.release() playbackManager.release()
mediaSession?.run { mediaSession?.run {

View file

@ -102,6 +102,7 @@
<!-- "Google Drive" is a brand name — do not translate. --> <!-- "Google Drive" is a brand name — do not translate. -->
<string name="drawer_backup_desc">Upload books from your synced folders to Google Drive.</string> <string name="drawer_backup_desc">Upload books from your synced folders to Google Drive.</string>
<string name="drawer_custom_fonts">Custom Fonts</string> <string name="drawer_custom_fonts">Custom Fonts</string>
<string name="drawer_support_project">Support the Project</string>
<string name="drawer_help_feedback">Help &amp; Feedback</string> <string name="drawer_help_feedback">Help &amp; Feedback</string>
<string name="drawer_sign_out">Sign Out</string> <string name="drawer_sign_out">Sign Out</string>
@ -317,6 +318,15 @@
<string name="email_support">Email Support</string> <string name="email_support">Email Support</string>
<string name="email_support_desc">Contact us directly via email for any other inquiries.</string> <string name="email_support_desc">Contact us directly via email for any other inquiries.</string>
<!-- Support Project -->
<string name="support_project_title">Support the Project</string>
<string name="support_project_heading">Help keep Episteme moving</string>
<string name="support_project_desc">Your support helps me keep maintaining and Improving Episteme for everyone!!!</string>
<string name="support_github_sponsor">Sponsor on GitHub</string>
<string name="support_github_sponsor_desc">Support development directly through GitHub Sponsors. As a thank you, you get a README shoutout in the project repo.</string>
<string name="support_patreon">Join on Patreon</string>
<string name="support_patreon_desc">As a thank you for backing the app, Patreon supporters get extra content and benefits: sneak peeks at what I am working on, early screenshots and updates, votes that help shape how new features should look and work, and a README shoutout in the project repo.</string>
<!-- Dialogs --> <!-- Dialogs -->
<!-- "Episteme Pro" is the product tier name — do not translate "Episteme". --> <!-- "Episteme Pro" is the product tier name — do not translate "Episteme". -->
<string name="dialog_unlock_pro">Unlock Episteme Pro</string> <string name="dialog_unlock_pro">Unlock Episteme Pro</string>
@ -573,10 +583,14 @@
<!-- Common.kt: ReaderThemePanel & ThemeBuilderView --> <!-- Common.kt: ReaderThemePanel & ThemeBuilderView -->
<string name="reading_themes">Reading Themes</string> <string name="reading_themes">Reading Themes</string>
<string name="theme_presets">Presets</string> <string name="theme_presets">Presets</string>
<string name="theme_textured_presets">Textured Presets</string>
<string name="theme_my_themes">My Themes</string> <string name="theme_my_themes">My Themes</string>
<string name="theme_my_textured_themes">My Textured Themes</string>
<!-- "+" is a literal plus icon reference in the description. --> <!-- "+" is a literal plus icon reference in the description. -->
<string name="theme_no_custom">No custom themes yet. Tap \'+\' to create one.</string> <string name="theme_no_custom">No custom themes yet. Tap \'+\' to create one.</string>
<string name="theme_no_textured_custom">No custom textured themes yet.</string>
<string name="theme_new">New Theme</string> <string name="theme_new">New Theme</string>
<string name="theme_new_textured">New Textured Theme</string>
<string name="theme_edit">Edit Theme</string> <string name="theme_edit">Edit Theme</string>
<string name="theme_name">Theme Name</string> <string name="theme_name">Theme Name</string>
<!-- Short motivational quote used as preview text in the theme builder. --> <!-- Short motivational quote used as preview text in the theme builder. -->
@ -587,6 +601,10 @@
<string name="theme_low_contrast_warning">⚠️ Low contrast! This might cause eye strain.</string> <string name="theme_low_contrast_warning">⚠️ Low contrast! This might cause eye strain.</string>
<string name="theme_page_color">Page Color</string> <string name="theme_page_color">Page Color</string>
<string name="theme_text_color">Text Color</string> <string name="theme_text_color">Text Color</string>
<string name="theme_texture">Texture</string>
<string name="theme_texture_none">None</string>
<string name="theme_texture_upload">Upload</string>
<string name="theme_texture_transparency">Texture Transparency</string>
<string name="theme_color_live_preview">Live Preview</string> <string name="theme_color_live_preview">Live Preview</string>
<!-- Short phrase shown in the live color preview area of the theme builder. --> <!-- Short phrase shown in the live color preview area of the theme builder. -->
<string name="theme_color_preview_text">Reading is dreaming.</string> <string name="theme_color_preview_text">Reading is dreaming.</string>
@ -808,7 +826,8 @@
<string name="visual_options_system_ui">System UI (Status &amp; Navigation Bars)</string> <string name="visual_options_system_ui">System UI (Status &amp; Navigation Bars)</string>
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string> <string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
<string name="visual_options_progress_bar">Progress Bar</string> <string name="visual_options_progress_bar">Progress Bar</string>
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator at the bottom of the screen.</string> <string name="visual_options_progress_bar_desc">The reading progress and chapter indicator on the reading screen.</string>
<string name="visual_options_progress_bar_position">Position</string>
<!-- "Seamless Chapter Transition" is a reading feature name. --> <!-- "Seamless Chapter Transition" is a reading feature name. -->
<string name="visual_options_seamless_chapter">Seamless Chapter Transition</string> <string name="visual_options_seamless_chapter">Seamless Chapter Transition</string>
<string name="visual_options_seamless_chapter_desc">Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.</string> <string name="visual_options_seamless_chapter_desc">Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.</string>
@ -871,6 +890,7 @@
<string name="label_paragraph_gap">Paragraph Gap</string> <string name="label_paragraph_gap">Paragraph Gap</string>
<string name="label_image_size">Image Size</string> <string name="label_image_size">Image Size</string>
<string name="label_horizontal_margin">Horizontal Margin</string> <string name="label_horizontal_margin">Horizontal Margin</string>
<string name="label_vertical_margin">Vertical Margin</string>
<string name="label_none">None</string> <string name="label_none">None</string>
<!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. --> <!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. -->
<string name="label_original">Orig</string> <string name="label_original">Orig</string>
@ -1214,6 +1234,7 @@
<string name="msg_downloading_bubble_zoom_model_progress">Downloading Bubble Zoom model… %1$d%%</string> <string name="msg_downloading_bubble_zoom_model_progress">Downloading Bubble Zoom model… %1$d%%</string>
<string name="content_desc_exit_slider_navigation">Exit slider navigation</string> <string name="content_desc_exit_slider_navigation">Exit slider navigation</string>
<string name="content_desc_jump_back">Jump Back</string> <string name="content_desc_jump_back">Jump Back</string>
<string name="content_desc_jump_forward">Jump Forward</string>
<string name="content_desc_scroll_to_reading_page">Scroll to reading page</string> <string name="content_desc_scroll_to_reading_page">Scroll to reading page</string>
<string name="content_desc_annotated_page">Annotated Page</string> <string name="content_desc_annotated_page">Annotated Page</string>
<string name="content_desc_close_image">Close Image</string> <string name="content_desc_close_image">Close Image</string>
@ -1261,9 +1282,9 @@
<string name="label_short">Short</string> <string name="label_short">Short</string>
<string name="label_long">Long</string> <string name="label_long">Long</string>
<!-- Compact TTS speed label. %1$s = formatted multiplier, e.g. 1.2. --> <!-- Compact TTS speed label. %1$s = formatted multiplier, e.g. 1.2. -->
<string name="tts_speed_short">Spd: %1$sx</string> <string name="tts_speed_short">Speed: %1$sx</string>
<!-- Compact TTS pitch label. %1$s = formatted multiplier, e.g. 1.0. --> <!-- Compact TTS pitch label. %1$s = formatted multiplier, e.g. 1.0. -->
<string name="tts_pitch_short">Ptch: %1$sx</string> <string name="tts_pitch_short">Pitch: %1$sx</string>
<string name="content_desc_play_pause">Play/Pause</string> <string name="content_desc_play_pause">Play/Pause</string>
<string name="content_desc_reset_speed">Reset Speed</string> <string name="content_desc_reset_speed">Reset Speed</string>
<string name="content_desc_reset_pitch">Reset Pitch</string> <string name="content_desc_reset_pitch">Reset Pitch</string>

View file

@ -0,0 +1,28 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Test
class FileTypeResolverTest {
@Test
fun `transparent txt suffix preserves supported inner extension`() {
assertEquals(FileType.MD, resolveFileTypeFromName("notes.md.txt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("chapter.html.txt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("snippet.js.txt"))
assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt"))
}
@Test
fun `plain txt remains txt when inner extension is unsupported`() {
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
}
@Test
fun `extension suffix preserves transparent txt wrapper`() {
assertEquals(".md.txt", resolveFileExtensionSuffixFromName("notes.md.txt"))
assertEquals(".html.txt", resolveFileExtensionSuffixFromName("chapter.html.txt"))
assertEquals(".txt", resolveFileExtensionSuffixFromName("notes.txt"))
}
}

View file

@ -1,6 +1,9 @@
// Top-level build file where you can add configuration options common to all subprojects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.compose.multiplatform) apply false
} }

View file

@ -0,0 +1,55 @@
import org.gradle.api.tasks.JavaExec
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.multiplatform)
}
kotlin {
jvm("desktop")
jvmToolchain(21)
sourceSets {
val desktopMain by getting {
dependencies {
implementation(project(":shared"))
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("net.java.dev.jna:jna:5.17.0")
}
}
}
}
compose.desktop {
application {
mainClass = "com.aryan.reader.desktop.MainKt"
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
nativeDistributions {
targetFormats(TargetFormat.Exe, TargetFormat.Msi)
packageName = "Episteme"
packageVersion = "1.0.0"
description = "Episteme desktop shell"
vendor = "Aryan Reader"
}
}
}
afterEvaluate {
tasks.withType<JavaExec>().configureEach {
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
if (System.getProperty("os.name").contains("Mac")) {
jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED")
jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
}
}
}

View file

@ -0,0 +1,282 @@
package com.aryan.reader.desktop
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.OptimizedCssRules
import com.aryan.reader.paginatedreader.UserAgentStylesheet
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter
import java.io.File
import java.util.Base64
import java.util.UUID
import java.util.zip.ZipFile
object DesktopEpubLoader {
fun load(file: File): SharedEpubBook {
ZipFile(file).use { zip ->
val container = zip.readText("META-INF/container.xml")
val opfPath = container
.substringAfter("full-path=\"", missingDelimiterValue = "")
.substringBefore("\"")
.ifBlank { error("EPUB container does not point to an OPF package.") }
val opf = zip.readText(opfPath)
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
.let { if (it.isBlank()) "" else "$it/" }
val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
val author = opf.tagText("creator").ifBlank { null }
val manifest = parseManifest(opf)
val cssByPath = loadCss(zip, manifest, basePath)
val cssRules = parseCssRules(cssByPath)
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
.findAll(opf)
.mapNotNull { match -> manifest[match.groupValues[1]] }
.toList()
val chapterPaths = spine.ifEmpty {
manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
}
val chapters = chapterPaths.mapIndexedNotNull { index, href ->
val path = normalizeZipPath(basePath + href)
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
val text = htmlToText(html)
val semanticBlocks = runCatching {
htmlToSemanticBlocks(
html = resourceReadyHtml,
cssRules = cssRules,
textStyle = TextStyle(fontSize = 18.sp),
chapterAbsPath = path,
extractionBasePath = "",
density = Density(1f),
fontFamilyMap = emptyMap(),
constraints = Constraints(maxWidth = 980, maxHeight = 720)
)
}.getOrElse { emptyList() }
if (text.isBlank()) {
null
} else {
SharedEpubChapter(
id = "chapter_$index",
title = html.tagText("h1")
.ifBlank { html.tagText("h2") }
.ifBlank { html.tagText("title") }
.ifBlank { "Chapter ${index + 1}" },
plainText = text,
semanticBlocks = semanticBlocks,
htmlContent = resourceReadyHtml.extractBodyOrSelf(),
baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
)
}
}
return SharedEpubBook(
id = file.absolutePath,
fileName = file.name,
title = title,
author = author,
css = cssByPath,
chapters = chapters.ifEmpty {
listOf(
SharedEpubChapter(
id = UUID.randomUUID().toString(),
title = title,
plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
)
)
}
)
}
}
private fun parseManifest(opf: String): Map<String, String> {
return Regex("<item\\s+[^>]*>").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 id to href
}.toMap()
}
private fun loadCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
return manifest.values
.filter { it.endsWith(".css", ignoreCase = true) }
.mapNotNull { href ->
val path = normalizeZipPath(basePath + href)
val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
if (css.isBlank()) null else path to css
}
.toMap()
}
private fun parseCssRules(cssByPath: Map<String, String>): OptimizedCssRules {
val constraints = Constraints(maxWidth = 980, maxHeight = 720)
val baseRules = CssParser.parse(
cssContent = UserAgentStylesheet.default,
cssPath = null,
baseFontSizeSp = 18f,
density = 1f,
constraints = constraints,
isDarkTheme = false
).rules
return cssByPath.entries
.fold(baseRules) { rules, (path, css) ->
if (css.isBlank()) {
rules
} else {
rules.merge(
CssParser.parse(
cssContent = css,
cssPath = path,
baseFontSizeSp = 18f,
density = 1f,
constraints = constraints,
isDarkTheme = false
).rules
)
}
}
}
private fun ZipFile.readText(path: String): String {
val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
return getInputStream(entry).bufferedReader().use { it.readText() }
}
private fun ZipFile.readTextOrNull(path: String): String? {
val entry = getEntry(path) ?: return null
return getInputStream(entry).bufferedReader().use { it.readText() }
}
private fun String.attr(name: String): String {
return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
}
private fun String.tagText(tag: String): String {
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.get(1)
?.let(::htmlToText)
.orEmpty()
}
private fun normalizeZipPath(path: String): String {
val parts = ArrayDeque<String>()
path.split('/').forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
val attr = match.groupValues[1]
val raw = match.groupValues[2]
if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
return@replace match.value
}
val dataUri = zip.toDataUri(raw, chapterPath)
if (dataUri != null) "$attr=\"$dataUri\"" else match.value
}
}
private fun String.looksLikeEmbeddableResource(): Boolean {
return substringBefore('#')
.substringBefore('?')
.substringAfterLast('.', "")
.lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
}
private fun String.sanitizeReaderHtml(): String {
return replace(Regex("(?is)<script\\b.*?</script>"), "")
.replace(Regex("(?is)<object\\b.*?</object>"), "")
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
}
private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
val raw = match.groupValues[2].trim()
val dataUri = zip.toDataUri(raw, cssPath)
if (dataUri != null) "url('$dataUri')" else match.value
}
}
private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
val ref = rawRef.substringBefore('#').trim()
if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
val entry = getEntry(path) ?: return null
val bytes = getInputStream(entry).use { it.readBytes() }
return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
}
private fun mimeType(path: String): String {
return when (path.substringAfterLast('.', "").lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
"ttf" -> "font/ttf"
"otf" -> "font/otf"
"woff" -> "font/woff"
"woff2" -> "font/woff2"
"css" -> "text/css"
"js" -> "text/javascript"
else -> "application/octet-stream"
}
}
private fun String.extractBodyOrSelf(): String {
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
.find(this)
?.groupValues
?.get(1)
?.trim()
?: this
}
private fun htmlToText(html: String): String {
return html
.replace(Regex("(?is)<script.*?</script>"), "")
.replace(Regex("(?is)<style.*?</style>"), "")
.replace(Regex("(?i)<br\\s*/?>"), "\n")
.replace(Regex("(?i)</p\\s*>"), "\n\n")
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
.replace(Regex("<[^>]+>"), " ")
.decodeEntities()
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
.replace(Regex(" *\\n *"), "\n")
.replace(Regex("\\n{3,}"), "\n\n")
.trim()
}
private fun String.decodeEntities(): String {
return replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
}
.replace(Regex("&#(\\d+);")) { match ->
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
}
}
}

View file

@ -0,0 +1,205 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.Tag
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.io.File
data class DesktopLibrarySnapshot(
val books: List<BookItem> = emptyList(),
val shelfRecords: List<ShelfRecord> = emptyList(),
val shelfRefs: List<BookShelfRef> = emptyList(),
val tags: List<Tag> = emptyList()
)
class DesktopLibraryDatabase(
private val databaseFile: File = defaultDatabaseFile()
) {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
fun load(): DesktopLibrarySnapshot {
if (!databaseFile.exists()) return DesktopLibrarySnapshot()
val root = runCatching {
json.parseToJsonElement(databaseFile.readText()).jsonObject
}.getOrNull() ?: return DesktopLibrarySnapshot()
return DesktopLibrarySnapshot(
books = root.array("books").mapNotNull { it.asBookItemOrNull() },
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
tags = root.array("tags").mapNotNull { it.asTagOrNull() }
)
}
fun save(snapshot: DesktopLibrarySnapshot) {
databaseFile.parentFile?.mkdirs()
val root = JsonObject(
mapOf(
"schemaVersion" to JsonPrimitive(1),
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
)
)
databaseFile.writeText(root.toString())
}
companion object {
fun defaultDatabaseFile(): File {
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
return File(baseDir, "Episteme/library.json")
}
}
}
private fun JsonObject.array(name: String): List<JsonElement> {
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
}
private fun JsonObject.string(name: String): String? {
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
}
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
return this[name]?.jsonPrimitive?.longOrNull ?: fallback
}
private fun JsonObject.float(name: String): Float? {
return this[name]?.jsonPrimitive?.floatOrNull
}
private fun JsonObject.double(name: String): Double? {
return this[name]?.jsonPrimitive?.doubleOrNull
}
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
}
private fun JsonElement.asBookItemOrNull(): BookItem? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val id = obj.string("id") ?: return null
val displayName = obj.string("displayName") ?: return null
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
return BookItem(
id = id,
path = obj.string("path"),
type = type,
displayName = displayName,
timestamp = obj.long("timestamp"),
title = obj.string("title"),
author = obj.string("author"),
progressPercentage = obj.float("progressPercentage"),
isRecent = obj.boolean("isRecent", true),
fileSize = obj.long("fileSize"),
sourceFolder = obj.string("sourceFolder"),
seriesName = obj.string("seriesName"),
seriesIndex = obj.double("seriesIndex"),
tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
)
}
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return ShelfRecord(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
isSmart = obj.boolean("isSmart", false),
smartRulesJson = obj.string("smartRulesJson")
)
}
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return BookShelfRef(
bookId = obj.string("bookId") ?: return null,
shelfId = obj.string("shelfId") ?: return null,
addedAt = obj.long("addedAt")
)
}
private fun JsonElement.asTagOrNull(): Tag? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return Tag(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
)
}
private fun BookItem.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"path" to path.asJson(),
"type" to JsonPrimitive(type.name),
"displayName" to JsonPrimitive(displayName),
"timestamp" to JsonPrimitive(timestamp),
"title" to title.asJson(),
"author" to author.asJson(),
"progressPercentage" to progressPercentage.asJson(),
"isRecent" to JsonPrimitive(isRecent),
"fileSize" to JsonPrimitive(fileSize),
"sourceFolder" to sourceFolder.asJson(),
"seriesName" to seriesName.asJson(),
"seriesIndex" to seriesIndex.asJson(),
"tags" to JsonArray(tags.map { it.toJsonObject() })
)
)
}
private fun ShelfRecord.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"isSmart" to JsonPrimitive(isSmart),
"smartRulesJson" to smartRulesJson.asJson()
)
)
}
private fun BookShelfRef.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"bookId" to JsonPrimitive(bookId),
"shelfId" to JsonPrimitive(shelfId),
"addedAt" to JsonPrimitive(addedAt)
)
)
}
private fun Tag.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"color" to color.asJson()
)
)
}
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull

View file

@ -0,0 +1,239 @@
package com.aryan.reader.desktop
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.sun.jna.Library
import com.sun.jna.Memory
import com.sun.jna.Native
import com.sun.jna.Pointer
import java.awt.image.BufferedImage
import java.io.File
import java.nio.ByteOrder
import kotlin.math.roundToInt
data class DesktopPdfDocument(
val path: String,
val title: String,
val pageCount: Int,
val pageSizes: List<DesktopPdfPageSize>,
val textPages: List<String>
) {
fun close() {
DesktopPdfium.closeDocument(path)
}
}
data class DesktopPdfPageSize(
val width: Float,
val height: Float
)
data class DesktopPdfPageRender(
val image: ImageBitmap,
val width: Int,
val height: Int
)
object DesktopPdfium {
private const val FPDF_ANNOT = 0x01
private const val FPDF_LCD_TEXT = 0x02
private const val FPDF_RENDER_NO_SMOOTHTEXT = 0x1000
private const val FPDF_BITMAP_BGRA = 4
private val pdfiumDll: File by lazy(::resolvePdfiumDll)
private val zoomSpec = PdfZoomSpec()
private val api: PdfiumLibrary by lazy {
require(pdfiumDll.exists()) {
"Missing Pdfium DLL. Expected pdfium-v8-win-x64 under third_party/pdfium/win-x64-v8/bin/pdfium.dll."
}
Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java)
}
private var initialized = false
private val openDocuments = LinkedHashMap<String, Pointer>()
fun isAvailable(): Boolean = pdfiumDll.exists()
fun load(file: File, password: String? = null): DesktopPdfDocument {
initLibrary()
val document = api.FPDF_LoadDocument(file.absolutePath, password)
?: error("Pdfium could not open ${file.name}. It may be encrypted or unsupported.")
val pageCount = api.FPDF_GetPageCount(document)
openDocuments[file.absolutePath] = document
val pageSizes = (0 until pageCount).map { pageIndex ->
loadPage(document, pageIndex).usePointer { page ->
DesktopPdfPageSize(
width = api.FPDF_GetPageWidthF(page),
height = api.FPDF_GetPageHeightF(page)
)
}
}
val textPages = (0 until pageCount).map { pageIndex ->
extractPageText(document, pageIndex)
}
return DesktopPdfDocument(
path = file.absolutePath,
title = file.nameWithoutExtension,
pageCount = pageCount,
pageSizes = pageSizes,
textPages = textPages
)
}
fun closeDocument(path: String) {
openDocuments.remove(path)?.let(api::FPDF_CloseDocument)
}
fun renderPage(
document: DesktopPdfDocument,
pageIndex: Int,
scale: Float,
renderAnnotations: Boolean = true
): DesktopPdfPageRender {
val nativeDocument = openDocuments[document.path] ?: error("PDF document is not open.")
val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.")
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1)
val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1)
val stride = width * 4
val memory = Memory((stride * height).toLong())
memory.clear(memory.size())
val bitmap = api.FPDFBitmap_CreateEx(width, height, FPDF_BITMAP_BGRA, memory, stride)
?: error("Pdfium could not allocate render bitmap.")
try {
api.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, -1)
loadPage(nativeDocument, pageIndex).usePointer { page ->
val flags = FPDF_LCD_TEXT or
(if (renderAnnotations) FPDF_ANNOT else FPDF_RENDER_NO_SMOOTHTEXT)
api.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, flags)
}
return DesktopPdfPageRender(
image = memory.toBufferedImage(width, height, stride).toComposeImageBitmap(),
width = width,
height = height
)
} finally {
api.FPDFBitmap_Destroy(bitmap)
}
}
private fun extractPageText(document: Pointer, pageIndex: Int): String {
return runCatching {
loadPage(document, pageIndex).usePointer { page ->
val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer ""
try {
val charCount = api.FPDFText_CountChars(textPage)
if (charCount <= 0) return@usePointer ""
val buffer = Memory(((charCount + 1) * 2L))
val written = api.FPDFText_GetText(textPage, 0, charCount, buffer)
if (written <= 0) {
""
} else {
buffer.getCharArray(0, written).concatToString().trimEnd('\u0000')
}
} finally {
api.FPDFText_ClosePage(textPage)
}
}
}.getOrDefault("")
}
private fun loadPage(document: Pointer, pageIndex: Int): PointerResource {
val page = api.FPDF_LoadPage(document, pageIndex)
?: error("Pdfium could not open page ${pageIndex + 1}.")
return PointerResource(page, api::FPDF_ClosePage)
}
private fun initLibrary() {
if (!initialized) {
api.FPDF_InitLibrary()
initialized = true
}
}
private fun resolvePdfiumDll(): File {
val overridePath = System.getProperty("reader.pdfium.dll")
?: System.getenv("READER_PDFIUM_DLL")
if (!overridePath.isNullOrBlank()) {
return File(overridePath).absoluteFile
}
val relativePath = listOf("third_party", "pdfium", "win-x64-v8", "bin", "pdfium.dll")
.joinToString(File.separator)
val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile }
.take(6)
.toList()
return roots
.map { File(it, relativePath).absoluteFile }
.firstOrNull { it.exists() }
?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile
}
private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage {
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN)
val pixels = IntArray(width * height)
for (y in 0 until height) {
buffer.position(y * stride)
for (x in 0 until width) {
val b = buffer.get().toInt() and 0xFF
val g = buffer.get().toInt() and 0xFF
val r = buffer.get().toInt() and 0xFF
val a = buffer.get().toInt() and 0xFF
pixels[y * width + x] = (a shl 24) or (r shl 16) or (g shl 8) or b
}
}
image.setRGB(0, 0, width, height, pixels, 0, width)
return image
}
private class PointerResource(
private val pointer: Pointer,
private val closer: (Pointer) -> Unit
) {
fun <T> usePointer(block: (Pointer) -> T): T {
try {
return block(pointer)
} finally {
closer(pointer)
}
}
}
@Suppress("FunctionName")
private interface PdfiumLibrary : Library {
fun FPDF_InitLibrary()
fun FPDF_LoadDocument(filePath: String, password: String?): Pointer?
fun FPDF_CloseDocument(document: Pointer)
fun FPDF_GetPageCount(document: Pointer): Int
fun FPDF_LoadPage(document: Pointer, pageIndex: Int): Pointer?
fun FPDF_ClosePage(page: Pointer)
fun FPDF_GetPageWidthF(page: Pointer): Float
fun FPDF_GetPageHeightF(page: Pointer): Float
fun FPDFBitmap_CreateEx(width: Int, height: Int, format: Int, firstScan: Pointer, stride: Int): Pointer?
fun FPDFBitmap_FillRect(bitmap: Pointer, left: Int, top: Int, width: Int, height: Int, color: Int)
fun FPDFBitmap_Destroy(bitmap: Pointer)
fun FPDF_RenderPageBitmap(
bitmap: Pointer,
page: Pointer,
startX: Int,
startY: Int,
sizeX: Int,
sizeY: Int,
rotate: Int,
flags: Int
)
fun FPDFText_LoadPage(page: Pointer): Pointer?
fun FPDFText_ClosePage(textPage: Pointer)
fun FPDFText_CountChars(textPage: Pointer): Int
fun FPDFText_GetText(textPage: Pointer, startIndex: Int, count: Int, result: Pointer): Int
}
}

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ androidxAnnotationJvm = "1.9.1"
androidxTestRunner = "1.6.2" androidxTestRunner = "1.6.2"
material3WindowSizeClassAndroid = "1.3.2" material3WindowSizeClassAndroid = "1.3.2"
credentials = "1.5.0" credentials = "1.5.0"
composeMultiplatform = "1.8.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -59,8 +59,10 @@ androidx-credentials = { group = "androidx.credentials", name = "credentials", v
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" } kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
# Add plugins required by pdfiumandroid # Add plugins required by pdfiumandroid
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }

View file

@ -17,8 +17,12 @@ dependencyResolutionManagement {
google() google()
mavenCentral() mavenCentral()
maven("https://jitpack.io") maven("https://jitpack.io")
maven("https://jogamp.org/deployment/maven")
maven("https.jitpack.io")
} }
} }
rootProject.name = "Reader" rootProject.name = "Reader"
include(":app") include(":app")
include(":shared")
include(":desktopApp")

49
shared/build.gradle.kts Normal file
View file

@ -0,0 +1,49 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
id("com.android.library")
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.multiplatform)
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
}
kotlin {
androidTarget()
jvm("desktop")
jvmToolchain(21)
sourceSets {
val commonMain by getting
val androidMain by getting
val desktopMain by getting
val readerJvmMain by creating {
dependsOn(commonMain)
dependencies {
implementation("org.jsoup:jsoup:1.17.2")
}
}
androidMain.dependsOn(readerJvmMain)
desktopMain.dependsOn(readerJvmMain)
commonMain.dependencies {
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(compose.ui)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
android {
namespace = "com.aryan.reader.shared"
compileSdk = 36
defaultConfig {
minSdk = 26
}
}

View file

@ -0,0 +1,3 @@
package com.aryan.reader.shared
actual fun currentTimestamp(): Long = System.currentTimeMillis()

View file

@ -21,7 +21,6 @@ package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
@ -35,23 +34,45 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import timber.log.Timber
import java.io.File
import java.util.regex.Pattern
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val IMPORTANT_SPECIFICITY_BOOST = 10_000 private const val IMPORTANT_SPECIFICITY_BOOST = 10_000
private object ReaderCssLog {
fun d(@Suppress("UNUSED_PARAMETER") message: String) = Unit
fun w(@Suppress("UNUSED_PARAMETER") message: String) = Unit
fun w(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
fun e(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
}
private fun Color.luminance(): Float { private fun Color.luminance(): Float {
if (!this.isSpecified) return 0f if (!this.isSpecified) return 0f
return (0.299f * red + 0.587f * green + 0.114f * blue) return (0.299f * red + 0.587f * green + 0.114f * blue)
} }
private fun resolveCssRelativePath(cssPath: String, rawSrc: String): String {
if (rawSrc.startsWith("/") || rawSrc.contains("://")) return rawSrc
val normalizedBase = cssPath.replace('\\', '/').substringBeforeLast('/', "")
val parts = ArrayDeque<String>()
(if (normalizedBase.isBlank()) rawSrc else "$normalizedBase/$rawSrc")
.replace('\\', '/')
.split('/')
.forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
object CssParser { object CssParser {
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL) private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex() private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
private val ID_SELECTOR_PATTERN = Pattern.compile("#[^\\s,]+") private val ID_SELECTOR_REGEX = Regex("#[^\\s,]+")
private val CLASS_ATTRIBUTE_SELECTOR_PATTERN = Pattern.compile("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+") private val CLASS_ATTRIBUTE_SELECTOR_REGEX = Regex("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
private val TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN = Pattern.compile("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+") private val TYPE_PSEUDO_ELEMENT_SELECTOR_REGEX = Regex("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+")
private data class FontSource(val url: String, val format: String?) private data class FontSource(val url: String, val format: String?)
// Regex to identify simple, single-part selectors for fast categorization // Regex to identify simple, single-part selectors for fast categorization
@ -65,7 +86,7 @@ object CssParser {
"thick" to 5.dp "thick" to 5.dp
) )
internal fun adaptColorForTheme( fun adaptColorForTheme(
color: Color, color: Color,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isBackground: Boolean, isBackground: Boolean,
@ -113,16 +134,7 @@ object CssParser {
return color return color
} }
val hsl = FloatArray(3) return themeText.takeIf { it.isSpecified } ?: color
androidx.core.graphics.ColorUtils.colorToHSL(color.toArgb(), hsl)
if (bgLuminance < 0.5f) {
hsl[2] = hsl[2].coerceAtLeast(0.7f)
} else {
hsl[2] = hsl[2].coerceAtMost(0.3f)
}
return Color(androidx.core.graphics.ColorUtils.HSLToColor(hsl))
} }
} }
@ -143,7 +155,7 @@ object CssParser {
reassembled = true reassembled = true
} }
if (reassembled) { if (reassembled) {
Timber.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'") ReaderCssLog.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
} }
result.add(current) result.add(current)
} }
@ -151,21 +163,9 @@ object CssParser {
} }
private fun calculateSpecificity(selector: String): Int { private fun calculateSpecificity(selector: String): Int {
val ids = ID_SELECTOR_PATTERN.matcher(selector).run { val ids = ID_SELECTOR_REGEX.findAll(selector).count()
var count = 0 val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_REGEX.findAll(selector).count()
while (find()) count++ val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_REGEX.findAll(selector).count()
count
}
val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val specificity = ids * 100 + classesAndAttributes * 10 + elementsAndPseudos val specificity = ids * 100 + classesAndAttributes * 10 + elementsAndPseudos
return specificity return specificity
} }
@ -200,13 +200,13 @@ object CssParser {
} }
cleanedCss = mediaQueryRegex.replace(cleanedCss, "") cleanedCss = mediaQueryRegex.replace(cleanedCss, "")
Timber.d("CssParser: Checking for @font-face rules...") ReaderCssLog.d("CssParser: Checking for @font-face rules...")
val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss) val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss)
if (!fontFaceMatches.any()) { if (!fontFaceMatches.any()) {
Timber.d("CssParser: No @font-face rules found by regex.") ReaderCssLog.d("CssParser: No @font-face rules found by regex.")
} }
fontFaceMatches.forEach { match -> fontFaceMatches.forEach { match ->
Timber.d("CssParser: Found a @font-face block. Parsing its properties.") ReaderCssLog.d("CssParser: Found a @font-face block. Parsing its properties.")
val properties = match.groupValues[1] val properties = match.groupValues[1]
parseFontFace(properties, cssPath)?.let { fontFaces.add(it) } parseFontFace(properties, cssPath)?.let { fontFaces.add(it) }
} }
@ -269,10 +269,10 @@ object CssParser {
val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase() val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase()
val srcString = propsMap["src"] val srcString = propsMap["src"]
Timber.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString") ReaderCssLog.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
if (fontFamily == null || srcString == null) { if (fontFamily == null || srcString == null) {
Timber.w("Incomplete @font-face rule: missing font-family or src.") ReaderCssLog.w("Incomplete @font-face rule: missing font-family or src.")
return null return null
} }
@ -280,25 +280,25 @@ object CssParser {
val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part -> val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part ->
val trimmedPart = part.trim() val trimmedPart = part.trim()
Timber.d("Processing src part: '$trimmedPart'") ReaderCssLog.d("Processing src part: '$trimmedPart'")
urlWithFormatRegex.find(trimmedPart)?.let { urlWithFormatRegex.find(trimmedPart)?.let {
Timber.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}") ReaderCssLog.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}")
FontSource(url = it.groupValues[2], format = it.groupValues[4].lowercase().removeSurrounding("'")) FontSource(url = it.groupValues[2], format = it.groupValues[4].lowercase().removeSurrounding("'"))
} ?: URL_REGEX.find(trimmedPart)?.let { } ?: URL_REGEX.find(trimmedPart)?.let {
val url = it.groupValues[2] val url = it.groupValues[2]
Timber.d("Matched url() only. URL: '$url'") ReaderCssLog.d("Matched url() only. URL: '$url'")
val format = when { val format = when {
url.startsWith("data:", ignoreCase = true) -> { url.startsWith("data:", ignoreCase = true) -> {
val mediaType = url.substringAfter("data:").substringBefore(';') val mediaType = url.substringAfter("data:").substringBefore(';')
Timber.d("Data URI detected. Media type: '$mediaType'") ReaderCssLog.d("Data URI detected. Media type: '$mediaType'")
when { when {
mediaType.contains("opentype") -> "opentype" mediaType.contains("opentype") -> "opentype"
mediaType.contains("truetype") -> "truetype" mediaType.contains("truetype") -> "truetype"
mediaType.contains("woff2") -> "woff2" mediaType.contains("woff2") -> "woff2"
mediaType.contains("woff") -> "woff" mediaType.contains("woff") -> "woff"
else -> { else -> {
Timber.w("Unknown data URI media type: $mediaType") ReaderCssLog.w("Unknown data URI media type: $mediaType")
null null
} }
} }
@ -308,11 +308,11 @@ object CssParser {
url.endsWith(".otf", ignoreCase = true) -> "opentype" url.endsWith(".otf", ignoreCase = true) -> "opentype"
url.endsWith(".ttf", ignoreCase = true) -> "truetype" url.endsWith(".ttf", ignoreCase = true) -> "truetype"
else -> { else -> {
Timber.w("Could not determine format from URL: $url") ReaderCssLog.w("Could not determine format from URL: $url")
null null
} }
} }
Timber.d("Determined format: '$format'") ReaderCssLog.d("Determined format: '$format'")
if (format != null) { if (format != null) {
FontSource(url = url, format = format) FontSource(url = url, format = format)
} else { } else {
@ -322,7 +322,7 @@ object CssParser {
} }
if (sources.isEmpty()) { if (sources.isEmpty()) {
Timber.w("Could not parse any valid source from @font-face src: $srcString") ReaderCssLog.w("Could not parse any valid source from @font-face src: $srcString")
return null return null
} }
@ -337,14 +337,13 @@ object CssParser {
}!! }!!
val rawSrc = preferredSource.url val rawSrc = preferredSource.url
Timber.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'") ReaderCssLog.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) { val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) {
try { try {
val cssParentDir = File(cssPath).parent ?: "" resolveCssRelativePath(cssPath, rawSrc)
File(cssParentDir, rawSrc).normalize().path
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'") ReaderCssLog.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'")
rawSrc // Fallback to the raw path on error rawSrc // Fallback to the raw path on error
} }
} else { } else {
@ -1038,10 +1037,20 @@ object CssParser {
val r = (colorLong and 0xF00) shr 8 val r = (colorLong and 0xF00) shr 8
val g = (colorLong and 0x0F0) shr 4 val g = (colorLong and 0x0F0) shr 4
val b = colorLong and 0x00F val b = colorLong and 0x00F
Color(Color(0xFF000000 or ((r * 17) shl 16) or ((g * 17) shl 8) or (b * 17)).toArgb()) Color((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), 255)
} }
6 -> Color(Color(0xFF000000 or colorLong).toArgb()) // #RRGGBB 6 -> Color(
8 -> Color(Color(colorLong).toArgb()) // #AARRGGBB ((colorLong shr 16) and 0xFF).toInt(),
((colorLong shr 8) and 0xFF).toInt(),
(colorLong and 0xFF).toInt(),
255
) // #RRGGBB
8 -> Color(
((colorLong shr 16) and 0xFF).toInt(),
((colorLong shr 8) and 0xFF).toInt(),
(colorLong and 0xFF).toInt(),
((colorLong shr 24) and 0xFF).toInt()
) // #AARRGGBB
else -> null else -> null
} }
} }

View file

@ -0,0 +1,39 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.font.FontFamily
/**
* Shared mapper for generic CSS font-family names.
*
* Platform-specific font loaders can still resolve embedded/custom font files and add their own
* FontFamily instances, but generic CSS families should behave consistently everywhere.
*/
object FontFamilyMapper {
private val genericFontMap = mapOf(
"serif" to FontFamily.Serif,
"sans-serif" to FontFamily.SansSerif,
"monospace" to FontFamily.Monospace,
"cursive" to FontFamily.Cursive,
"default" to FontFamily.Default,
"system-ui" to FontFamily.Default,
"ui-sans-serif" to FontFamily.Default,
"ui-serif" to FontFamily.Default,
"ui-monospace" to FontFamily.Default,
"ui-rounded" to FontFamily.Default
)
fun nameToFontFamily(name: String): FontFamily? {
return genericFontMap[name.trim().lowercase()]
}
fun fontFamilyToName(fontFamily: FontFamily): String? {
return when (fontFamily) {
FontFamily.Serif -> "serif"
FontFamily.SansSerif -> "sans-serif"
FontFamily.Monospace -> "monospace"
FontFamily.Cursive -> "cursive"
FontFamily.Default -> "default"
else -> null
}
}
}

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