v1.0.47 (#279)
* 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)
8
.gitignore
vendored
|
|
@ -8,7 +8,7 @@
|
|||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
|
|
@ -18,3 +18,9 @@ local.properties
|
|||
google-services.json
|
||||
.kotlin/
|
||||
.idea/
|
||||
.gradle/
|
||||
third_party/pdfium/
|
||||
*.tgz
|
||||
kcef-bundle/
|
||||
cache/
|
||||
worker/
|
||||
20
.idea/gradle.xml
generated
Normal 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>
|
||||
|
|
@ -24,14 +24,13 @@ kotlin {
|
|||
android {
|
||||
namespace = "com.aryan.reader"
|
||||
compileSdk = 36
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.aryan.reader"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 46
|
||||
versionName = "1.0.46"
|
||||
versionCode = 51
|
||||
versionName = "1.0.47"
|
||||
|
||||
resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru")
|
||||
|
||||
|
|
@ -154,6 +153,8 @@ android {
|
|||
//noinspection UseTomlInstead
|
||||
dependencies {
|
||||
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
|
@ -190,7 +191,7 @@ dependencies {
|
|||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
|
||||
//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
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
|
||||
//noinspection GradleDependency
|
||||
|
|
@ -241,6 +242,11 @@ dependencies {
|
|||
|
||||
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")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@
|
|||
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 themeStyleElement = document.getElementById(styleId);
|
||||
|
||||
|
|
@ -207,8 +207,14 @@
|
|||
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
|
||||
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
|
||||
? `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;';
|
||||
|
||||
var css = `
|
||||
|
|
@ -517,7 +523,65 @@
|
|||
}
|
||||
}, 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";
|
||||
console.log(
|
||||
logTag +
|
||||
|
|
@ -534,7 +598,9 @@
|
|||
", ImageSize: " +
|
||||
imageSize +
|
||||
", HorizontalMargin: " +
|
||||
horizontalMargin
|
||||
horizontalMargin +
|
||||
", VerticalMargin: " +
|
||||
verticalMargin
|
||||
);
|
||||
|
||||
var dynamicStyleId = "dynamicReaderStyles";
|
||||
|
|
@ -551,12 +617,16 @@
|
|||
var newGap = parseFloat(paragraphGap);
|
||||
var newImageSize = parseFloat(imageSize);
|
||||
var newHorizontalMargin = parseFloat(horizontalMargin);
|
||||
var newVerticalMargin = parseFloat(verticalMargin);
|
||||
|
||||
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(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 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(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 1.0;
|
||||
|
||||
rememberReaderImageAnchors();
|
||||
|
||||
var fontCss = "";
|
||||
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
|
||||
|
|
@ -609,11 +679,14 @@
|
|||
}
|
||||
|
||||
var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin);
|
||||
var verticalPaddingPx = Math.max(0, 16 * newVerticalMargin);
|
||||
var horizontalMarginCss = `
|
||||
body {
|
||||
box-sizing: border-box !important;
|
||||
padding-left: ${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;
|
||||
max-width: 100% !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");
|
||||
applyReaderImageAnchors();
|
||||
setTimeout(applyReaderImageAnchors, 80);
|
||||
|
||||
setTimeout(
|
||||
function () {
|
||||
|
|
@ -2170,6 +2255,7 @@
|
|||
if (window.checkImagesForDiagnosis) {
|
||||
setTimeout(window.checkImagesForDiagnosis, 100);
|
||||
}
|
||||
setTimeout(applyReaderImageAnchors, 80);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
BIN
app/src/main/assets/textures/classy_fabric.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/assets/textures/ep_naturalblack.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app/src/main/assets/textures/ep_naturalwhite.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/assets/textures/grey_wash_wall.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/assets/textures/light-veneer.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/assets/textures/retina_wood.webp
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
app/src/main/assets/textures/retro_intro.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -52,7 +52,7 @@ static FPDFLink_GetDest_t get_dest_func = nullptr;
|
|||
static FPDFAction_GetDest_t get_action_dest_func = nullptr;
|
||||
static FPDFDest_GetDestPageIndex_t get_dest_page_index_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_GetAction_t get_link_action_func = nullptr;
|
||||
static FPDFAction_GetType_t get_action_type_func = nullptr;
|
||||
|
|
@ -169,20 +169,23 @@ static bool init_pdfium() {
|
|||
|
||||
extern "C" JNIEXPORT jdouble JNICALL
|
||||
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);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
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);
|
||||
}
|
||||
|
||||
// Bulk extraction for blazing fast formatting processing
|
||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||
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);
|
||||
jfloat *fill = new jfloat[count];
|
||||
|
|
@ -196,7 +199,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclas
|
|||
|
||||
extern "C" JNIEXPORT jintArray JNICALL
|
||||
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);
|
||||
jint *fill = new jint[count];
|
||||
|
|
@ -210,7 +214,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jcl
|
|||
|
||||
extern "C" JNIEXPORT jintArray JNICALL
|
||||
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);
|
||||
jint *fill = new jint[count];
|
||||
|
|
@ -226,7 +231,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas
|
|||
|
||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||
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;
|
||||
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
|
||||
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;
|
||||
|
||||
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
|
||||
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));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
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;
|
||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
||||
if (index >= object_count) return 0;
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
if (!init_pdfium() || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
|
||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
return obj ? get_object_type_func(obj) : 0;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
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;
|
||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
||||
if (index >= object_count) return JNI_FALSE;
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
if (!init_pdfium() || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
|
||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
if (!obj) return JNI_FALSE;
|
||||
|
||||
|
|
@ -326,9 +331,13 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en
|
|||
|
||||
extern "C" JNIEXPORT jintArray JNICALL
|
||||
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;
|
||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
||||
if (index >= object_count) return nullptr;
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
if (!init_pdfium() || !get_object_func || !get_object_type_func || !get_image_bitmap_func ||
|
||||
!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);
|
||||
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
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
init_pdfium();
|
||||
// Return true if we have ANY way to handle actions
|
||||
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
|
||||
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;
|
||||
|
||||
void* page = reinterpret_cast<void*>(pagePtr);
|
||||
|
|
@ -412,6 +423,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
|
|||
|
||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||
std::lock_guard<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;
|
||||
|
||||
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
|
||||
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;
|
||||
return get_annot_count_func(reinterpret_cast<void*>(pagePtr));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
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;
|
||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
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
|
||||
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;
|
||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
if (!annot) return nullptr;
|
||||
|
|
@ -460,7 +474,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl
|
|||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE;
|
||||
|
||||
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
|
||||
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()) {
|
||||
LOGE("PdfLinkDiagnostic: init_pdfium failed.");
|
||||
return nullptr;
|
||||
|
|
|
|||
314
app/src/main/java/com/aryan/reader/AiSettingsScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -43,13 +43,18 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.aryan.reader.epubreader.EpubReaderScreen
|
||||
import com.aryan.reader.feedback.FeedbackScreen
|
||||
import com.aryan.reader.feedback.SupportProjectScreen
|
||||
import com.aryan.reader.pdf.PdfViewerScreen
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
object AppDestinations {
|
||||
const val MAIN_ROUTE = "main"
|
||||
|
|
@ -57,19 +62,74 @@ object AppDestinations {
|
|||
const val EPUB_READER_ROUTE = "epub_reader"
|
||||
const val PRO_SCREEN_ROUTE = "pro_screen"
|
||||
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 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) {
|
||||
navigate(route) {
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
popUpTo(graph.startDestinationId) {
|
||||
saveState = true
|
||||
if (!isReadyForBackStackChange()) {
|
||||
Timber.d("Skipping navigation to $route because the current entry is not resumed yet.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
navigate(route) {
|
||||
launchSingleTop = true
|
||||
popUpTo(graph.startDestinationId) {
|
||||
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)
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@Composable
|
||||
|
|
@ -80,34 +140,31 @@ fun AppNavigation(
|
|||
) {
|
||||
Timber.d("AppNavigation composable invoked.")
|
||||
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) {
|
||||
try {
|
||||
when (uiState.selectedFileType) {
|
||||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
|
||||
if (uiState.selectedPdfUri != null) {
|
||||
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
|
||||
if (uiState.selectedEpubBook != null) {
|
||||
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
|
||||
navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
|
||||
when (uiState.selectedFileType) {
|
||||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
|
||||
if (uiState.selectedPdfUri != null) {
|
||||
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.w(e, "Navigation transition already in progress, ignoring.")
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
|
||||
if (uiState.selectedEpubBook != null) {
|
||||
if (currentRoute != AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,7 +210,7 @@ fun AppNavigation(
|
|||
}
|
||||
},
|
||||
onNavigateToPro = {
|
||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
},
|
||||
viewModel = viewModel
|
||||
)
|
||||
|
|
@ -226,7 +283,7 @@ fun AppNavigation(
|
|||
}
|
||||
},
|
||||
onNavigateToPro = {
|
||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
|
||||
},
|
||||
onRenderModeChange = viewModel::setRenderMode,
|
||||
customFonts = customFonts,
|
||||
|
|
@ -276,7 +333,7 @@ fun AppNavigation(
|
|||
composable(route = AppDestinations.PRO_SCREEN_ROUTE) {
|
||||
ProScreen(
|
||||
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) {
|
||||
FontsScreen(
|
||||
viewModel = viewModel,
|
||||
onBackClick = { navController.popBackStack() }
|
||||
onBackClick = { navController.popBackStackIfReady() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = AppDestinations.AI_SETTINGS_SCREEN_ROUTE) {
|
||||
AiSettingsScreen(
|
||||
onBackClick = { navController.popBackStackIfReady() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
127
app/src/main/java/com/aryan/reader/AppUiModels.kt
Normal 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(),
|
||||
)
|
||||
94
app/src/main/java/com/aryan/reader/FileTypeResolver.kt
Normal 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
|
||||
}
|
||||
|
|
@ -24,11 +24,11 @@ import android.content.Context
|
|||
import timber.log.Timber
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.WorkManager
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -53,11 +53,15 @@ class FolderSyncWorker(
|
|||
const val WORK_NAME = "FolderSyncWorker"
|
||||
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
|
||||
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()
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val workerStart = ReaderPerfLog.nowNanos()
|
||||
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 jsonString = prefs.getString("synced_folders_list_json", null)
|
||||
|
|
@ -87,17 +91,31 @@ class FolderSyncWorker(
|
|||
}
|
||||
|
||||
if (folders.isEmpty()) {
|
||||
Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.")
|
||||
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||
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) {
|
||||
syncMutex.withLock {
|
||||
var allSuccess = true
|
||||
|
||||
for ((uriString, allowedTypes) in folders) {
|
||||
for ((uriString, allowedTypes) in foldersToProcess) {
|
||||
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
||||
if (!success) allSuccess = false
|
||||
}
|
||||
|
|
@ -107,12 +125,21 @@ class FolderSyncWorker(
|
|||
val array = org.json.JSONArray(jsonString)
|
||||
val now = System.currentTimeMillis()
|
||||
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()) }
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -121,8 +148,24 @@ class FolderSyncWorker(
|
|||
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
||||
if (folderUriString.isBlank()) return true
|
||||
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 {
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
appContext.contentResolver.takePersistableUriPermission(
|
||||
folderUri,
|
||||
|
|
@ -137,17 +180,31 @@ class FolderSyncWorker(
|
|||
return false
|
||||
}
|
||||
|
||||
Timber.tag("FolderSync").d("Phase 0: Migrating legacy root sidecars to subfolder...")
|
||||
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
|
||||
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped")
|
||||
|
||||
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
|
||||
val folderMetadataMap = ReaderPerfLog.measureSuspend(
|
||||
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 = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap()
|
||||
val preloadedSidecars = mutableMapOf<String, Pair<Long, String>>()
|
||||
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) ->
|
||||
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
val existingItem = existingFolderBooksById[bookId]
|
||||
|
||||
if (existingItem != null) {
|
||||
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
||||
|
|
@ -166,40 +223,28 @@ class FolderSyncWorker(
|
|||
isRecent = remoteMeta.isRecent || existingItem.isRecent,
|
||||
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
||||
)
|
||||
recentFilesRepository.addRecentFile(itemToUpdate)
|
||||
remoteMetadataUpdates.add(itemToUpdate)
|
||||
} else {
|
||||
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...")
|
||||
val processedBookIds = mutableSetOf<String>()
|
||||
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
if (remoteMetadataUpdates.isNotEmpty()) {
|
||||
recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
|
||||
dbFlushes++
|
||||
ReaderPerfLog.d(
|
||||
"FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
|
||||
)
|
||||
}
|
||||
|
||||
for (book in existingFolderBooks) {
|
||||
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)) {
|
||||
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
|
||||
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
|
||||
} else {
|
||||
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
|
||||
}
|
||||
}
|
||||
if (metadataOnly) {
|
||||
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
books = existingFolderBooks,
|
||||
phase = "metadata-only"
|
||||
)
|
||||
}
|
||||
|
||||
if (!metadataOnly) {
|
||||
|
|
@ -207,7 +252,17 @@ class FolderSyncWorker(
|
|||
val contentResolver = appContext.contentResolver
|
||||
val foundBookIds = mutableSetOf<String>()
|
||||
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 dirQueue = ArrayDeque<String>()
|
||||
|
|
@ -223,7 +278,13 @@ class FolderSyncWorker(
|
|||
|
||||
while (dirQueue.isNotEmpty()) {
|
||||
if (isStopped) break
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
val currentDocId = dirQueue.removeFirst()
|
||||
dirsScanned++
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||
|
||||
try {
|
||||
|
|
@ -234,10 +295,17 @@ class FolderSyncWorker(
|
|||
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
|
||||
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||
|
||||
while (cursor.moveToNext() && !isStopped) {
|
||||
while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
|
||||
val docId = cursor.getString(idCol)
|
||||
val name = cursor.getString(nameCol) ?: ""
|
||||
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 (!name.startsWith(".") && name != "EpistemeSyncData") {
|
||||
|
|
@ -249,13 +317,15 @@ class FolderSyncWorker(
|
|||
|
||||
val type = getFileType(name, mimeType)
|
||||
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
|
||||
supportedBooksSeen++
|
||||
val stableId = buildStableBookId(name, rootDocId, docId)
|
||||
foundBookIds.add(stableId)
|
||||
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||
val docUriString = docUri.toString()
|
||||
var existingItem = existingItemsMap[stableId]
|
||||
|
||||
if (existingItem != null && existingItem.uriString != docUri.toString()) {
|
||||
if (existingItem != null && existingItem.uriString != docUriString) {
|
||||
val collidedItem = existingItem
|
||||
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
|
||||
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
|
||||
|
|
@ -273,12 +343,10 @@ class FolderSyncWorker(
|
|||
}
|
||||
|
||||
if (existingItem == null) {
|
||||
val oldItem = existingItemsMap.values.find {
|
||||
it.bookId != stableId && (
|
||||
it.uriString == docUri.toString() ||
|
||||
it.bookId.startsWith("local_${name}_")
|
||||
)
|
||||
}
|
||||
val oldItem = existingItemsByUri[docUriString]?.takeIf { it.bookId != stableId }
|
||||
?: legacyItemsByName[name]?.firstOrNull {
|
||||
it.bookId != stableId
|
||||
}
|
||||
if (oldItem != null) {
|
||||
val oldId = oldItem.bookId
|
||||
Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId")
|
||||
|
|
@ -291,6 +359,7 @@ class FolderSyncWorker(
|
|||
preloadedSidecars = preloadedSidecars,
|
||||
existingItemsMap = existingItemsMap
|
||||
)
|
||||
legacyItemsByName[name]?.remove(oldItem)
|
||||
existingItem = existingItemsMap[stableId]
|
||||
}
|
||||
}
|
||||
|
|
@ -324,6 +393,7 @@ class FolderSyncWorker(
|
|||
fileSize = size
|
||||
)
|
||||
newOrUpdatedItems.add(newItem)
|
||||
newBooks++
|
||||
} else {
|
||||
var needsUpdate = false
|
||||
var updatedItem = existingItem
|
||||
|
|
@ -331,7 +401,11 @@ class FolderSyncWorker(
|
|||
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
|
||||
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
|
||||
recentFilesRepository.clearLocalCachesForBook(stableId)
|
||||
updatedItem = updatedItem.copy(fileSize = size, lastModifiedTimestamp = lastModified)
|
||||
updatedItem = updatedItem.copy(
|
||||
fileSize = size,
|
||||
lastModifiedTimestamp = lastModified,
|
||||
folderTextMetadataParsed = false
|
||||
)
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
|
|
@ -347,32 +421,26 @@ class FolderSyncWorker(
|
|||
|
||||
if (needsUpdate) {
|
||||
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)
|
||||
dbFlushes++
|
||||
scanDbFlushes++
|
||||
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) {
|
||||
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)
|
||||
dbFlushes++
|
||||
scanDbFlushes++
|
||||
newOrUpdatedItems.clear()
|
||||
}
|
||||
|
||||
if (!isStopped) {
|
||||
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
||||
if (!isStopped && !stoppedForUnlinkedFolder) {
|
||||
val idsToRemove = existingItemsMap.keys.filter { it !in foundBookIds }
|
||||
|
||||
if (idsToRemove.isNotEmpty()) {
|
||||
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
|
||||
|
|
@ -398,16 +469,50 @@ class FolderSyncWorker(
|
|||
}
|
||||
}
|
||||
|
||||
if (!isStopped) {
|
||||
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
||||
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||
MetadataExtractionWorker.WORK_NAME,
|
||||
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
metaRequest
|
||||
if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) {
|
||||
val booksForAnnotationSync = ReaderPerfLog.measureSuspend(
|
||||
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(
|
||||
MetadataExtractionWorker.WORK_NAME,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
metaRequest
|
||||
)
|
||||
} else {
|
||||
ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -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? {
|
||||
val lowerName = name.lowercase()
|
||||
return when {
|
||||
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
|
||||
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
|
||||
mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT
|
||||
mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT
|
||||
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX
|
||||
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
|
||||
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
|
||||
return when (mimeType) {
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||
else -> resolveFileTypeFromName(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import androidx.compose.material.icons.filled.MoreVert
|
|||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.outlined.AccountCircle
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
|
|
@ -134,6 +135,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
|
|
@ -151,11 +153,17 @@ internal fun Context.findActivity(): Activity? = when (this) {
|
|||
else -> null
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
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 customTabUriHandler = remember { CustomTabUriHandler(context) }
|
||||
var showCloseAllTabsDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -163,14 +171,15 @@ fun HomeScreen(
|
|||
|
||||
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val recentFilesForHome = uiState.recentFiles.filter { it.isRecent }
|
||||
val openTabs = uiState.openTabs
|
||||
val selectedContextItems = uiState.contextualActionItems
|
||||
val isContextualModeActive = selectedContextItems.isNotEmpty()
|
||||
val screenModel = remember(uiState) { uiState.toHomeScreenModel() }
|
||||
val recentFilesForHome = screenModel.recentFiles
|
||||
val openTabs = screenModel.openTabs
|
||||
val selectedContextItems = screenModel.selectedItems
|
||||
val isContextualModeActive = screenModel.isContextualModeActive
|
||||
val scope = rememberCoroutineScope()
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val deviceLimitState = uiState.deviceLimitState
|
||||
val deviceLimitState = screenModel.deviceLimitState
|
||||
|
||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
var showClearCloudDataDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -304,6 +313,12 @@ fun HomeScreen(
|
|||
navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
|
||||
}
|
||||
},
|
||||
onAiSettingsClick = {
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
|
||||
}
|
||||
},
|
||||
navController = navController,
|
||||
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
||||
)
|
||||
|
|
@ -341,7 +356,10 @@ fun HomeScreen(
|
|||
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
||||
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
|
||||
onLanguageClick = { showLanguageDialog = true },
|
||||
onExportLogsClick = { viewModel.exportLogsToFile(context) }
|
||||
onExportLogsClick = { viewModel.exportLogsToFile(context) },
|
||||
onToggleHideReaderAi = {
|
||||
saveHideReaderAiFeatures(context, !loadHideReaderAiFeatures(context))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ContextualTopAppBar(
|
||||
|
|
@ -367,8 +385,8 @@ fun HomeScreen(
|
|||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) {
|
||||
if (uiState.recentFiles.isEmpty()) {
|
||||
if (screenModel.isEmpty) {
|
||||
if (screenModel.isLibraryEmpty) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.your_library_empty),
|
||||
message = stringResource(R.string.your_library_empty_desc),
|
||||
|
|
@ -528,7 +546,8 @@ fun HomeScreen(
|
|||
uiState = uiState,
|
||||
onThemeModeChanged = viewModel::setAppThemeMode,
|
||||
onContrastOptionChanged = viewModel::setAppContrastOption,
|
||||
onTextDimFactorChanged = viewModel::setAppTextDimFactor,
|
||||
onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight,
|
||||
onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark,
|
||||
onSeedColorChanged = viewModel::setAppSeedColor,
|
||||
onCustomThemeAdded = viewModel::addCustomAppTheme,
|
||||
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
|
||||
|
|
@ -596,6 +615,9 @@ private fun RecentFilesContent(
|
|||
hasSyncedFolder: Boolean
|
||||
) {
|
||||
val canRefresh = isSyncEnabled || hasSyncedFolder
|
||||
val selectedItemUris = remember(selectedContextItems) {
|
||||
selectedContextItems.mapNotNullTo(mutableSetOf()) { it.uriString }
|
||||
}
|
||||
|
||||
val content = @Composable {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
|
|
@ -608,7 +630,7 @@ private fun RecentFilesContent(
|
|||
isTabsEnabled = isTabsEnabled,
|
||||
onTabCloseClick = onTabCloseClick,
|
||||
onCloseAllTabsClick = onCloseAllTabsClick,
|
||||
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
|
||||
selectedItemUris = selectedItemUris,
|
||||
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||
onItemClick = onItemClick,
|
||||
onItemLongClick = onItemLongClick,
|
||||
|
|
@ -996,10 +1018,13 @@ fun DefaultTopAppBar(
|
|||
onTestPanelDetectionClick: () -> Unit,
|
||||
onTestSpeechBubbleDetectionClick: () -> Unit,
|
||||
onLanguageClick: () -> Unit,
|
||||
onExportLogsClick: () -> Unit
|
||||
onExportLogsClick: () -> Unit,
|
||||
onToggleHideReaderAi: () -> Unit
|
||||
) {
|
||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||
var showLimitMenu by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
var hideReaderAiFeatures by remember { mutableStateOf(loadHideReaderAiFeatures(context)) }
|
||||
|
||||
CustomTopAppBar(title = { }, navigationIcon = {
|
||||
IconButton(onClick = onDrawerClick) {
|
||||
|
|
@ -1086,6 +1111,20 @@ fun DefaultTopAppBar(
|
|||
showOptionsMenu = false
|
||||
})
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
||||
onClick = {
|
||||
onToggleHideReaderAi()
|
||||
hideReaderAiFeatures = !hideReaderAiFeatures
|
||||
showOptionsMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (hideReaderAiFeatures) {
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
||||
onClearCache()
|
||||
|
|
@ -1141,6 +1180,7 @@ private fun AppDrawerContent(
|
|||
onUpgradeClick: () -> Unit,
|
||||
onSyncUpsellClick: () -> Unit,
|
||||
onFontsClick: () -> Unit,
|
||||
onAiSettingsClick: () -> Unit,
|
||||
navController: NavHostController,
|
||||
onFolderSyncToggle: (Boolean) -> Unit
|
||||
) {
|
||||
|
|
@ -1311,6 +1351,26 @@ private fun AppDrawerContent(
|
|||
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(
|
||||
icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.drawer_help_feedback)) },
|
||||
|
|
@ -1692,7 +1752,8 @@ fun AppThemeBottomSheet(
|
|||
uiState: ReaderScreenState,
|
||||
onThemeModeChanged: (AppThemeMode) -> Unit,
|
||||
onContrastOptionChanged: (AppContrastOption) -> Unit,
|
||||
onTextDimFactorChanged: (Float) -> Unit,
|
||||
onTextDimFactorLightChanged: (Float) -> Unit,
|
||||
onTextDimFactorDarkChanged: (Float) -> Unit,
|
||||
onSeedColorChanged: (Color?) -> Unit,
|
||||
onCustomThemeAdded: (CustomAppTheme) -> Unit,
|
||||
onCustomThemeDeleted: (String) -> Unit,
|
||||
|
|
@ -1756,24 +1817,68 @@ fun AppThemeBottomSheet(
|
|||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(stringResource(R.string.app_theme_text_brightness), 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.appTextDimFactor,
|
||||
onValueChange = onTextDimFactorChanged,
|
||||
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))
|
||||
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)
|
||||
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 = if (uiState.appThemeMode == AppThemeMode.DARK) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight,
|
||||
onValueChange = if (uiState.appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else 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(24.dp))
|
||||
|
|
|
|||
86
app/src/main/java/com/aryan/reader/LibraryModels.kt
Normal 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()
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
|
@ -131,6 +132,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
|
|
@ -141,6 +143,8 @@ import com.aryan.reader.opds.OpdsEntry
|
|||
import com.aryan.reader.opds.OpdsViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import timber.log.Timber
|
||||
|
|
@ -154,20 +158,26 @@ private fun getBookCountString(count: Int): String {
|
|||
return pluralStringResource(id = R.plurals.book_count, count, count)
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
viewModel: MainViewModel,
|
||||
) {
|
||||
val compStart = remember { System.currentTimeMillis() }
|
||||
LaunchedEffect(Unit) {
|
||||
ReaderPerfLog.d("LibraryScreen initial composition ${System.currentTimeMillis() - compStart}ms")
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selectedItems = uiState.contextualActionItems
|
||||
val isContextualModeActive = selectedItems.isNotEmpty()
|
||||
val selectedShelves = uiState.contextualActionShelfIds
|
||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||
val sortOrder = uiState.sortOrder
|
||||
val shelves = uiState.shelves
|
||||
val rawLibraryFiles = uiState.rawLibraryFiles
|
||||
val screenModel = remember(uiState) { uiState.toLibraryScreenModel() }
|
||||
val selectedItems = screenModel.selectedItems
|
||||
val isContextualModeActive = screenModel.isContextualModeActive
|
||||
val selectedShelves = screenModel.selectedShelves
|
||||
val isShelfContextualModeActive = screenModel.isShelfContextualModeActive
|
||||
val sortOrder = screenModel.sortOrder
|
||||
val shelves = screenModel.shelves
|
||||
val rawLibraryFiles = screenModel.rawLibraryFiles
|
||||
val tabTitles = remember {
|
||||
buildList {
|
||||
add(context.getString(R.string.tab_all_books))
|
||||
|
|
@ -183,21 +193,13 @@ fun LibraryScreen(
|
|||
pageCount = { tabTitles.size }
|
||||
)
|
||||
|
||||
val containsFolderItems = remember(selectedItems) {
|
||||
selectedItems.any { it.sourceFolderUri != null }
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.libraryScreenStartPage) {
|
||||
if (pagerState.currentPage != uiState.libraryScreenStartPage) {
|
||||
pagerState.animateScrollToPage(uiState.libraryScreenStartPage)
|
||||
}
|
||||
}
|
||||
val containsFolderItems = screenModel.containsFolderItemsInSelection
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var showFilterSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val isSearchActive = uiState.isSearchActive
|
||||
val searchQuery = uiState.searchQuery
|
||||
val isSearchActive = screenModel.isSearchActive
|
||||
val searchQuery = screenModel.searchQuery
|
||||
|
||||
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
|
|
@ -250,6 +252,8 @@ fun LibraryScreen(
|
|||
|
||||
LaunchedEffect(pagerState) {
|
||||
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collect { page ->
|
||||
viewModel.setLibraryScreenPage(page)
|
||||
}
|
||||
|
|
@ -401,6 +405,7 @@ fun LibraryScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun ShelfScreen(
|
||||
viewModel: MainViewModel,
|
||||
|
|
@ -577,6 +582,7 @@ fun LibraryScreenContent(
|
|||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
val searchFocusRequester = remember { FocusRequester() }
|
||||
val selectedBookIds = remember(selectedItems) { selectedItems.mapTo(mutableSetOf()) { it.bookId } }
|
||||
|
||||
var textFieldValue by remember(isSearchActive) {
|
||||
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
|
||||
|
|
@ -712,7 +718,16 @@ fun LibraryScreenContent(
|
|||
Tab(
|
||||
selected = pagerState.currentPage == index,
|
||||
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) }
|
||||
)
|
||||
|
|
@ -798,6 +813,11 @@ fun LibraryScreenContent(
|
|||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapPositionalThreshold = 0.25f
|
||||
),
|
||||
beyondViewportPageCount = 0,
|
||||
key = { it }
|
||||
) { page ->
|
||||
when (page) {
|
||||
|
|
@ -822,7 +842,7 @@ fun LibraryScreenContent(
|
|||
items(recentFiles, key = { it.bookId }) { item ->
|
||||
LibraryListItem(
|
||||
item = item,
|
||||
isSelected = selectedItems.any { it.bookId == item.bookId },
|
||||
isSelected = item.bookId in selectedBookIds,
|
||||
isPinned = item.bookId in pinnedLibraryBookIds,
|
||||
onItemClick = { onItemClick(item) },
|
||||
onItemLongClick = { onItemLongClick(item) },
|
||||
|
|
@ -1876,6 +1896,18 @@ private fun FolderSyncScreen(
|
|||
isLoading: Boolean
|
||||
) {
|
||||
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(
|
||||
floatingActionButton = {
|
||||
|
|
@ -1943,7 +1975,7 @@ private fun FolderSyncScreen(
|
|||
items(syncedFolders, key = { it.uriString }) { folder ->
|
||||
FolderCard(
|
||||
folder = folder,
|
||||
allRecentFiles = allRecentFiles,
|
||||
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
|
||||
onRemoveClick = onRemoveFolderClick,
|
||||
onEditFiltersClick = { editingFolder = folder }
|
||||
)
|
||||
|
|
@ -1952,11 +1984,11 @@ private fun FolderSyncScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (editingFolder != null) {
|
||||
editingFolder?.let { folder ->
|
||||
EditFolderFiltersDialog(
|
||||
folder = editingFolder!!,
|
||||
folder = folder,
|
||||
onConfirm = { newFilters ->
|
||||
onEditFolderFiltersClick(editingFolder!!, newFilters)
|
||||
onEditFolderFiltersClick(folder, newFilters)
|
||||
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)
|
||||
@Composable
|
||||
private fun FolderCard(
|
||||
folder: SyncedFolder,
|
||||
allRecentFiles: List<RecentFileItem>,
|
||||
stats: FolderFileStats,
|
||||
onRemoveClick: (SyncedFolder) -> Unit,
|
||||
onEditFiltersClick: (SyncedFolder) -> Unit
|
||||
) {
|
||||
|
|
@ -1976,14 +2017,6 @@ private fun FolderCard(
|
|||
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 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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||
|
|
@ -2058,18 +2091,18 @@ private fun FolderCard(
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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))
|
||||
androidx.compose.foundation.layout.FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
countsByType.forEach { (type, count) ->
|
||||
stats.countsByType.forEach { (type, count) ->
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }
|
||||
|
|
|
|||
435
app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,9 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
||||
@UnstableApi
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
|
|
@ -91,12 +93,14 @@ class MainActivity : AppCompatActivity() {
|
|||
AppThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight
|
||||
|
||||
AppTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = uiState.appSeedColor == null,
|
||||
seedColor = uiState.appSeedColor,
|
||||
contrastLevel = uiState.appContrastOption.value,
|
||||
textDimFactor = uiState.appTextDimFactor
|
||||
textDimFactor = textDimFactor
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
14
app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt
Normal 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"
|
||||
|
|
@ -22,10 +22,9 @@ package com.aryan.reader
|
|||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
|
|
@ -33,17 +32,15 @@ import androidx.compose.material3.Scaffold
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) {
|
||||
object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home)
|
||||
|
|
@ -55,6 +52,7 @@ private val bottomBarItems = listOf(
|
|||
BottomBarScreen.Library,
|
||||
)
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel,
|
||||
|
|
@ -75,21 +73,7 @@ fun MainScreen(
|
|||
if (viewingShelfName != null) {
|
||||
ShelfScreen(viewModel = viewModel)
|
||||
} else {
|
||||
val pagerState = rememberPagerState(
|
||||
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)
|
||||
}
|
||||
val selectedPage = uiState.mainScreenStartPage.coerceIn(0, bottomBarItems.lastIndex)
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
|
|
@ -99,23 +83,28 @@ fun MainScreen(
|
|||
NavigationBarItem(
|
||||
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
|
||||
label = { Text(stringResource(screen.stringResId)) },
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
|
||||
selected = selectedPage == 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 ->
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
key = { bottomBarItems[it].route },
|
||||
beyondViewportPageCount = 1,
|
||||
userScrollEnabled = false
|
||||
) { page ->
|
||||
when (page) {
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (selectedPage) {
|
||||
0 -> HomeScreen(
|
||||
viewModel = viewModel,
|
||||
windowSizeClass = windowSizeClass,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import kotlinx.serialization.protobuf.ProtoBuf
|
|||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.graphics.createBitmap
|
||||
|
|
@ -48,6 +48,7 @@ import androidx.credentials.exceptions.NoCredentialException
|
|||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
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.RemoteConfigRepository
|
||||
import com.aryan.reader.data.ShelfMetadata
|
||||
import com.aryan.reader.data.SmartCollectionEngine
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.data.getUri
|
||||
import com.aryan.reader.data.toBookMetadata
|
||||
import com.aryan.reader.data.toRecentFileItem
|
||||
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.MobiParser
|
||||
import com.aryan.reader.epub.SingleFileImporter
|
||||
import com.aryan.reader.epub.hasReadableExtractedContent
|
||||
import com.aryan.reader.ml.ISpeechBubbleDetector
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.paginatedreader.Locator
|
||||
|
|
@ -120,60 +122,18 @@ import kotlinx.coroutines.sync.Mutex
|
|||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors.newSingleThreadExecutor
|
||||
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(
|
||||
val documentId: String,
|
||||
val pageIndex: Int
|
||||
|
|
@ -187,166 +147,8 @@ private data class CachedSpeechBubble(
|
|||
val maskBitmap: Bitmap?
|
||||
)
|
||||
|
||||
enum class AddBooksSource(@StringRes val labelRes: Int) {
|
||||
UNSHELVED(R.string.add_books_source_unshelved),
|
||||
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(),
|
||||
)
|
||||
|
||||
@kotlin.OptIn(ExperimentalSerializationApi::class)
|
||||
@UnstableApi
|
||||
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val appContext: Context = application.applicationContext
|
||||
private val authRepository = AuthRepository(appContext)
|
||||
|
|
@ -380,6 +182,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
private val _prefsUpdateFlow = MutableStateFlow(0L)
|
||||
private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener
|
||||
private val feedbackRepository = FeedbackRepository(appContext)
|
||||
private val libraryStateProjector = LibraryStateProjector(AndroidFolderPathResolver())
|
||||
private var feedbackListener: Any? = null
|
||||
private val importMutex = 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 input = connection.inputStream
|
||||
val output = java.io.FileOutputStream(tempFile, isPartial)
|
||||
val output = FileOutputStream(tempFile, isPartial)
|
||||
val data = ByteArray(16 * 1024)
|
||||
var count: Int
|
||||
|
||||
|
|
@ -744,7 +547,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
appContrastOption = try {
|
||||
AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name)
|
||||
} 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,
|
||||
customAppThemes = loadCustomAppThemes(prefs)
|
||||
)
|
||||
|
|
@ -808,299 +612,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
open val uiState: StateFlow<ReaderScreenState> = combine(
|
||||
_internalState, libraryFlow, tagFlow
|
||||
) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) ->
|
||||
val tagsById = dbTags.associateBy { it.id }
|
||||
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||
withContext(Dispatchers.Default) {
|
||||
libraryStateProjector.project(
|
||||
LibraryProjectionInput(
|
||||
state = internalState,
|
||||
recentFilesFromDb = recentFilesFromDb,
|
||||
dbShelves = dbShelves,
|
||||
shelfRefs = shelfRefs,
|
||||
dbTags = dbTags,
|
||||
tagRefs = tagRefs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val allLibraryFiles = recentFilesFromDb
|
||||
.filterNot { it.bookId.endsWith("_reflow") }
|
||||
.map { item ->
|
||||
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
|
||||
}
|
||||
|
||||
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(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
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) {
|
||||
prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) }
|
||||
_internalState.update { it.copy(isTabsEnabled = enabled) }
|
||||
|
|
@ -1529,7 +1058,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.d("ViewModel instance created.")
|
||||
WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||
|
||||
// --- ADD THIS BLOCK ---
|
||||
val locatorConverter = LocatorConverter(
|
||||
bookCacheDao,
|
||||
ProtoBuf { serializersModule = semanticBlockModule },
|
||||
|
|
@ -1814,7 +1342,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
inputStream = inputStream,
|
||||
bookId = bookId,
|
||||
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(
|
||||
inputStream = inputStream,
|
||||
|
|
@ -1860,10 +1394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) {
|
||||
return@withLock
|
||||
}
|
||||
if (latestState.selectedEpubBook?.extractionBasePath?.let { path ->
|
||||
path.isNotBlank() && File(path).exists()
|
||||
} == true
|
||||
) {
|
||||
if (latestState.selectedEpubBook?.hasReadableExtractedContent() == true) {
|
||||
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))
|
||||
|
||||
|
|
@ -2835,6 +2370,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
fun removeSyncedFolder(folder: SyncedFolder) {
|
||||
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()
|
||||
currentFolders.removeAll { it.uriString == folder.uriString }
|
||||
|
||||
|
|
@ -2842,9 +2382,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
_internalState.update { it.copy(syncedFolders = currentFolders) }
|
||||
|
||||
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
||||
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||
|
||||
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||
try {
|
||||
appContext.contentResolver.releasePersistableUriPermission(
|
||||
folder.uriString.toUri(),
|
||||
|
|
@ -2855,7 +2394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
if (currentFolders.isEmpty()) {
|
||||
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||
}
|
||||
|
||||
showBanner(appContext.getString(R.string.banner_folder_removed))
|
||||
|
|
@ -2870,16 +2409,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
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
|
||||
if (folders.isEmpty()) return
|
||||
|
||||
Timber.tag("FolderSync")
|
||||
.d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)")
|
||||
val targetFolderName = targetFolderUriString
|
||||
?.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)
|
||||
if (!metadataOnly) {
|
||||
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
|
||||
}
|
||||
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()
|
||||
|
||||
|
|
@ -2958,7 +2514,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
folders.forEach { folder ->
|
||||
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
||||
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||
|
||||
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||
try {
|
||||
appContext.contentResolver.releasePersistableUriPermission(
|
||||
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)
|
||||
}
|
||||
_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 allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
|
||||
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
|
||||
val validActiveBookCacheDirs = allDbIds.mapTo(mutableSetOf()) {
|
||||
ImportedFileCache.activeBookDirName(it)
|
||||
}
|
||||
ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1))
|
||||
|
||||
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")
|
||||
}
|
||||
} else if (ImportedFileCache.isActiveBookDir(name)) {
|
||||
val bookId = name.removePrefix("imported_file_")
|
||||
if (bookId !in allDbIds) {
|
||||
val legacyBookId = name.removePrefix("imported_file_")
|
||||
if (name !in validActiveBookCacheDirs && legacyBookId !in allDbIds && file.lastModified() < oneHourAgo) {
|
||||
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_")) {
|
||||
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
|
||||
) {
|
||||
val openBookStartTime = System.currentTimeMillis()
|
||||
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
|
||||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
|
||||
|
||||
|
|
@ -4341,12 +3908,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
|
||||
if (recentItem?.sourceFolderUri != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||
_internalState.update {
|
||||
|
|
@ -4357,6 +3918,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
isLoading = false
|
||||
)
|
||||
}
|
||||
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||
persistReaderSession(bookId, type)
|
||||
addFileToRecent(
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
if (recentItem?.sourceFolderUri != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
||||
}
|
||||
}
|
||||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||
val locator =
|
||||
|
|
@ -4405,6 +3962,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
initialHighlightsJson = recentItem?.highlightsJson,
|
||||
)
|
||||
}
|
||||
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||
persistReaderSession(bookId, type)
|
||||
|
||||
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/plain" -> {
|
||||
if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) {
|
||||
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
|
||||
}
|
||||
resolveFileTypeFromName(fileName) ?: FileType.TXT
|
||||
}
|
||||
|
||||
else -> {
|
||||
when {
|
||||
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
|
||||
}
|
||||
resolveFileTypeFromName(fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4739,7 +4221,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
} else {
|
||||
throw Exception(
|
||||
"MobiParser returned null. The file might be DRM-protected or invalid."
|
||||
if (MobiParser.isNativeParserAvailable) {
|
||||
"MobiParser returned null. The file might be DRM-protected or invalid."
|
||||
} else {
|
||||
MobiParser.nativeParserUnavailableMessage()
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -4945,6 +4431,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
fun onRecentFileClicked(item: RecentFileItem) {
|
||||
ReaderPerfLog.d("FileOpen click bookId=${item.bookId} name=${item.displayName}")
|
||||
val currentSelection = _internalState.value.contextualActionItems
|
||||
if (currentSelection.isNotEmpty()) {
|
||||
Timber.d("Toggling selection for: ${item.displayName}")
|
||||
|
|
@ -5125,6 +4612,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
fun setMainScreenPage(page: Int) {
|
||||
val sanitizedPage = page.coerceIn(0, 1)
|
||||
if (_internalState.value.mainScreenStartPage == sanitizedPage) return
|
||||
_internalState.update { it.copy(mainScreenStartPage = sanitizedPage) }
|
||||
persistLibraryLandingState()
|
||||
}
|
||||
|
|
@ -5132,6 +4620,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
fun setLibraryScreenPage(page: Int) {
|
||||
val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3
|
||||
val sanitizedPage = page.coerceIn(0, maxLibraryPage)
|
||||
if (_internalState.value.libraryScreenStartPage == sanitizedPage) return
|
||||
_internalState.update {
|
||||
it.copy(libraryScreenStartPage = sanitizedPage)
|
||||
}
|
||||
|
|
@ -5701,9 +5190,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
|
||||
}
|
||||
|
||||
fun setAppTextDimFactor(factor: Float) {
|
||||
_internalState.update { it.copy(appTextDimFactor = factor) }
|
||||
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR, factor) }
|
||||
fun setAppTextDimFactorLight(factor: Float) {
|
||||
_internalState.update { it.copy(appTextDimFactorLight = 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?) {
|
||||
|
|
@ -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_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_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"
|
||||
|
||||
val SUPPORTED_MIME_TYPES = arrayOf(
|
||||
|
|
|
|||
|
|
@ -2,19 +2,20 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Xml
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class MetadataExtractionWorker(
|
||||
private val appContext: Context,
|
||||
|
|
@ -22,179 +23,271 @@ class MetadataExtractionWorker(
|
|||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
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 {
|
||||
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) {
|
||||
val workerStart = ReaderPerfLog.nowNanos()
|
||||
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
val hasLegacy = prefs.contains("synced_folder_uri")
|
||||
val hasNew = prefs.contains("synced_folders_list_json")
|
||||
|
||||
if (!hasLegacy && !hasNew) {
|
||||
Timber.tag("MetadataWorker").w("No folders linked. Stopping.")
|
||||
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
try {
|
||||
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers()
|
||||
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||
|
||||
if (filesToProcess.isEmpty()) {
|
||||
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||
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 ->
|
||||
if (isStopped) 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 {
|
||||
val uri = item.uriString?.toUri() ?: return@forEach
|
||||
val type = item.type
|
||||
|
||||
var coverPath: String? = null
|
||||
var title: String? = null
|
||||
var author: String? = null
|
||||
|
||||
val fileSize = try {
|
||||
if (uri.scheme == "file") {
|
||||
uri.path?.let { File(it).length() } ?: 0L
|
||||
} else {
|
||||
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||
if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||
} else 0L
|
||||
} ?: 0L
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}")
|
||||
0L
|
||||
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()
|
||||
}
|
||||
|
||||
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
when (type) {
|
||||
FileType.EPUB -> {
|
||||
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
|
||||
val title = sanitizeTitle(metadata.title)
|
||||
val author = sanitizeAuthor(metadata.author)
|
||||
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
|
||||
val titleChanged = title != null && title != item.title
|
||||
val authorChanged = author != null && author != item.author
|
||||
|
||||
try {
|
||||
val pdfiumCore = PdfiumCore(appContext)
|
||||
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val pdfDocument = pdfiumCore.newDocument(pfd)
|
||||
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
|
||||
|
||||
val extractedTitle = meta.title
|
||||
if (!extractedTitle.isNullOrBlank()) {
|
||||
title = extractedTitle
|
||||
}
|
||||
|
||||
val extractedAuthor = meta.author
|
||||
if (!extractedAuthor.isNullOrBlank()) {
|
||||
author = extractedAuthor
|
||||
}
|
||||
|
||||
pdfiumCore.closeDocument(pdfDocument)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore")
|
||||
}
|
||||
}
|
||||
FileType.ODT, FileType.FODT -> {
|
||||
val book = odtParser.createOdtBook(
|
||||
inputStream = inputStream,
|
||||
bookId = item.bookId,
|
||||
originalBookNameHint = item.displayName,
|
||||
isFlat = type == FileType.FODT,
|
||||
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) }
|
||||
}
|
||||
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
|
||||
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
|
||||
)
|
||||
)
|
||||
recentFilesRepository.addRecentFile(updatedItem)
|
||||
Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)")
|
||||
if (sizeChanged || titleChanged || authorChanged) {
|
||||
updated++
|
||||
}
|
||||
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
|
||||
flushUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
} 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}")
|
||||
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, "Metadata extraction failed")
|
||||
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") {
|
||||
uri.path?.let { File(it).length() } ?: 0L
|
||||
} else {
|
||||
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
} ?: 0L
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MetadataWorker").e(e, "Failed to query file size for $uri")
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
|
||||
val opfEntries = linkedMapOf<String, String>()
|
||||
var containerXml: String? = null
|
||||
|
||||
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input.buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
if (entry.isDirectory) continue
|
||||
val name = entry.name
|
||||
when {
|
||||
name == "META-INF/container.xml" -> containerXml = zip.readTextEntry()
|
||||
name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry()
|
||||
}
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val opfPath = containerXml?.let { parseEpubRootfilePath(it) }
|
||||
val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull()
|
||||
return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata()
|
||||
}
|
||||
|
||||
private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata {
|
||||
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)
|
||||
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val pdfDocument = pdfiumCore.newDocument(pfd)
|
||||
try {
|
||||
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
|
||||
TextMetadata(title = meta.title, author = meta.author)
|
||||
} finally {
|
||||
pdfiumCore.closeDocument(pdfDocument)
|
||||
}
|
||||
} ?: TextMetadata()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF text metadata")
|
||||
TextMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEpubRootfilePath(containerXml: String): String? {
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
parser.setInput(containerXml.reader())
|
||||
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) {
|
||||
return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseXmlTextMetadata(xml: String): TextMetadata {
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
54
app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt
Normal 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
|
||||
)
|
||||
}
|
||||
59
app/src/main/java/com/aryan/reader/ReaderPerfLog.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
app/src/main/java/com/aryan/reader/SharedModelMappers.kt
Normal 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
|
||||
)
|
||||
}
|
||||
42
app/src/main/java/com/aryan/reader/UiLabelResources.kt
Normal 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
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||
TagEntity::class,
|
||||
BookTagCrossRef::class
|
||||
],
|
||||
version = 18,
|
||||
version = 19,
|
||||
exportSchema = false
|
||||
)
|
||||
@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 {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
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_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
||||
MIGRATION_16_17, MIGRATION_17_18
|
||||
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19
|
||||
)
|
||||
.fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import android.net.Uri
|
|||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.aryan.reader.ReaderPerfLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
|
@ -16,6 +17,116 @@ object LocalSyncUtils {
|
|||
private const val ANNOTATION_SUFFIX = "_annotations"
|
||||
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? {
|
||||
val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
||||
if (existing != null && existing.isDirectory) return existing
|
||||
|
|
@ -209,22 +320,22 @@ object LocalSyncUtils {
|
|||
|
||||
suspend fun preloadAnnotationSidecars(
|
||||
context: Context,
|
||||
rootTree: DocumentFile
|
||||
sourceFolderUri: Uri
|
||||
): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) {
|
||||
val results = mutableMapOf<String, Pair<Long, String>>()
|
||||
|
||||
try {
|
||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
||||
if (syncDir == null || !syncDir.isDirectory) return@withContext results
|
||||
val bookIds = syncDir.listFiles()
|
||||
.mapNotNull { extractAnnotationBookId(it.name) }
|
||||
.toSet()
|
||||
|
||||
for (bookId in bookIds) {
|
||||
val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
||||
if (best != null) {
|
||||
results[bookId] = best
|
||||
val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||
.filter { file ->
|
||||
val name = file.name
|
||||
extractAnnotationBookId(name) != null &&
|
||||
!name.contains(".syncthing.")
|
||||
}
|
||||
.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) {
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
|
||||
|
|
@ -233,6 +344,44 @@ object LocalSyncUtils {
|
|||
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(
|
||||
context: Context,
|
||||
sourceFolderUri: Uri,
|
||||
|
|
@ -253,12 +402,13 @@ object LocalSyncUtils {
|
|||
private fun resolveAndCleanAnnotationConflicts(
|
||||
context: Context,
|
||||
syncDir: DocumentFile,
|
||||
bookId: String
|
||||
bookId: String,
|
||||
knownFiles: List<DocumentFile>? = null
|
||||
): Pair<Long, String>? {
|
||||
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
||||
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
||||
|
||||
val allFiles = syncDir.listFiles()
|
||||
val allFiles = knownFiles ?: syncDir.listFiles().asList()
|
||||
|
||||
val candidates = allFiles.filter { file ->
|
||||
val name = file.name ?: ""
|
||||
|
|
@ -475,21 +625,17 @@ object LocalSyncUtils {
|
|||
val finalResults = mutableMapOf<String, FolderBookMetadata>()
|
||||
|
||||
try {
|
||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
|
||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
||||
if (syncDir == null || !syncDir.isDirectory) return@withContext finalResults
|
||||
val allFiles = syncDir.listFiles()
|
||||
|
||||
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||
val groupedFiles = allFiles
|
||||
.filter {
|
||||
val name = it.name ?: ""
|
||||
val name = it.name
|
||||
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
|
||||
!name.contains(ANNOTATION_SUFFIX) &&
|
||||
!name.endsWith(".tmp") &&
|
||||
!name.contains(".syncthing.")
|
||||
}
|
||||
.groupBy { file ->
|
||||
var name = file.name ?: ""
|
||||
var name = file.name
|
||||
if (name.startsWith(".")) name = name.substring(1)
|
||||
if (name.contains(".sync-conflict")) {
|
||||
name.substringBefore(".sync-conflict")
|
||||
|
|
@ -499,17 +645,47 @@ object LocalSyncUtils {
|
|||
}
|
||||
|
||||
groupedFiles.forEach { (bookId, files) ->
|
||||
val winner = resolveAndCleanConflicts(context, files, bookId)
|
||||
val winner = resolveMetadataConflictsReadOnly(context, files, bookId)
|
||||
if (winner != null) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,8 +90,59 @@ interface RecentFileDao {
|
|||
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
|
||||
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")
|
||||
suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity>
|
||||
@Query("""
|
||||
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")
|
||||
suspend fun detachAllFolderBooks()
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ data class RecentFileEntity(
|
|||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||
@ColumnInfo(defaultValue = "NULL") val description: String?
|
||||
@ColumnInfo(defaultValue = "NULL") val description: String?,
|
||||
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean
|
||||
)
|
||||
|
||||
data class RecentFileSummary(
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@
|
|||
*/
|
||||
package com.aryan.reader.data
|
||||
|
||||
import android.net.Uri
|
||||
import com.aryan.reader.FileType
|
||||
import androidx.core.net.toUri
|
||||
|
||||
data class RecentFileItem(
|
||||
val bookId: String,
|
||||
|
|
@ -51,10 +49,9 @@ data class RecentFileItem(
|
|||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val folderTextMetadataParsed: Boolean = false,
|
||||
val tags: List<TagEntity> = emptyList()
|
||||
) {
|
||||
fun getUri(): Uri? = uriString?.toUri()
|
||||
}
|
||||
)
|
||||
|
||||
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||
return RecentFileItem(
|
||||
|
|
@ -84,7 +81,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
|||
fileSize = this.fileSize,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description
|
||||
description = this.description,
|
||||
folderTextMetadataParsed = this.folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +114,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
|||
fileSize = this.fileSize,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description
|
||||
description = this.description,
|
||||
folderTextMetadataParsed = this.folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
|
||||
fun RecentFileItem.getUri(): Uri? = uriString?.toUri()
|
||||
|
|
@ -24,6 +24,7 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.ReaderPerfLog
|
||||
import timber.log.Timber
|
||||
import com.aryan.reader.BookImporter
|
||||
import com.aryan.reader.paginatedreader.Locator
|
||||
|
|
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.room.withTransaction
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
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) {
|
||||
|
||||
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 bookImporter = BookImporter(context)
|
||||
|
||||
|
|
@ -57,10 +60,10 @@ class RecentFilesRepository(private val context: Context) {
|
|||
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
|
||||
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
|
||||
|
||||
val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves()
|
||||
val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs()
|
||||
val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags()
|
||||
val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs()
|
||||
val activeShelvesFlow = database.shelfDao().getAllActiveShelves()
|
||||
val shelfCrossRefsFlow = database.shelfDao().getAllBookShelfCrossRefs()
|
||||
val tagsFlow = database.tagDao().getAllTags()
|
||||
val tagCrossRefsFlow = database.tagDao().getAllBookTagCrossRefs()
|
||||
|
||||
init {
|
||||
if (!coverCacheDir.exists()) {
|
||||
|
|
@ -184,7 +187,8 @@ class RecentFilesRepository(private val context: Context) {
|
|||
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
|
||||
seriesName = item.seriesName ?: existingItem.seriesName,
|
||||
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
|
||||
description = item.description ?: existingItem.description
|
||||
description = item.description ?: existingItem.description,
|
||||
folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
|
||||
)
|
||||
} else {
|
||||
item.toRecentFileEntity()
|
||||
|
|
@ -355,12 +359,25 @@ class RecentFilesRepository(private val context: Context) {
|
|||
}
|
||||
|
||||
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
|
||||
val filesToRemove = getFilesBySourceFolder(folderUriString)
|
||||
if (filesToRemove.isNotEmpty()) {
|
||||
Timber.d("DeleteDebug: Cascading deletion for ${filesToRemove.size} files from folder.")
|
||||
deleteFilePermanently(filesToRemove.map { it.bookId })
|
||||
} else {
|
||||
recentFileDao.deleteFilesBySourceFolder(folderUriString)
|
||||
val start = ReaderPerfLog.nowNanos()
|
||||
val filesToRemove = recentFileDao.getFilesBySourceFolder(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) {
|
||||
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() }
|
||||
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import android.graphics.Bitmap
|
|||
import com.aryan.reader.epub.EpubParser.EpubPageTarget
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class EpubTocEntry(
|
||||
|
|
@ -51,3 +52,14 @@ data class EpubBook(
|
|||
val seriesIndex: Double? = 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ object ImportedFileCache {
|
|||
private const val TEMP_PREFIX = "imported_file_tmp_"
|
||||
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 {
|
||||
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
|
||||
return File(context.cacheDir, activeBookDirName(bookId))
|
||||
}
|
||||
|
||||
fun prepareActiveBookDir(context: Context, bookId: String): File {
|
||||
|
|
@ -39,6 +43,7 @@ object ImportedFileCache {
|
|||
|
||||
fun clearBookCache(context: Context, bookId: String) {
|
||||
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
|
||||
legacyActiveBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
|
||||
clearTemporaryBookDirs(context, bookId)
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +74,10 @@ object ImportedFileCache {
|
|||
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 {
|
||||
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
|
||||
val hash = bookId.hashCode().toLong() and 0xffffffffL
|
||||
|
|
|
|||
|
|
@ -109,10 +109,20 @@ class MobiParser(private val context: Context) {
|
|||
private external fun parseMobiFile(filePath: String): ParsedMobiData?
|
||||
|
||||
companion object {
|
||||
init {
|
||||
private val nativeLoadError: Throwable? = try {
|
||||
System.loadLibrary("mobi")
|
||||
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(
|
||||
|
|
@ -122,6 +132,11 @@ class MobiParser(private val context: Context) {
|
|||
parseContent: Boolean = true,
|
||||
extractionDirOverride: File? = null
|
||||
): EpubBook? = withContext(Dispatchers.IO) {
|
||||
if (!isNativeParserAvailable) {
|
||||
Timber.e("Skipping MOBI parsing: ${nativeParserUnavailableMessage()}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
|
||||
try {
|
||||
tempFile.outputStream().use { output ->
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.safety.Safelist
|
||||
import org.zwobble.mammoth.DocumentConverter
|
||||
import timber.log.Timber
|
||||
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 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(
|
||||
inputStream: InputStream,
|
||||
type: FileType,
|
||||
|
|
@ -61,8 +72,9 @@ class SingleFileImporter(private val context: Context) {
|
|||
): EpubBook {
|
||||
|
||||
val lowerHint = originalBookNameHint.lowercase()
|
||||
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) }
|
||||
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") ||
|
||||
lowerHint.endsWith(".csv.txt") || lowerHint.endsWith(".tsv.txt")
|
||||
val isCodeOrData = com.aryan.reader.isCodeOrDataFileName(originalBookNameHint)
|
||||
|
||||
if (type == FileType.HTML && (isCsv || isCodeOrData)) {
|
||||
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")
|
||||
}
|
||||
|
||||
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 ->
|
||||
var line = reader.readLine()
|
||||
|
|
@ -177,11 +189,7 @@ class SingleFileImporter(private val context: Context) {
|
|||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||
|
||||
if (metadataFile.exists()) {
|
||||
|
|
@ -245,7 +253,7 @@ class SingleFileImporter(private val context: Context) {
|
|||
val chapterTitle = "Page $pageNum"
|
||||
|
||||
val document = parser.parse(rawText)
|
||||
val htmlBody = renderer.render(document)
|
||||
val htmlBody = sanitizeHtmlFragment(renderer.render(document))
|
||||
|
||||
val fileName = "page_$pageNum.html"
|
||||
val file = File(extractionDir, fileName)
|
||||
|
|
@ -315,11 +323,7 @@ class SingleFileImporter(private val context: Context) {
|
|||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||
|
||||
if (metadataFile.exists()) {
|
||||
|
|
@ -480,11 +484,7 @@ class SingleFileImporter(private val context: Context) {
|
|||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||
|
||||
if (metadataFile.exists()) {
|
||||
|
|
@ -507,6 +507,7 @@ class SingleFileImporter(private val context: Context) {
|
|||
val chapters = mutableListOf<EpubChapter>()
|
||||
|
||||
inputStream.bufferedReader().use { reader ->
|
||||
var inScript = false
|
||||
var inStyle = false
|
||||
var inBody = false
|
||||
var pageNum = 1
|
||||
|
|
@ -516,6 +517,19 @@ class SingleFileImporter(private val context: Context) {
|
|||
while (reader.readLine().also { line = it } != null) {
|
||||
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 (trimmed.startsWith("<title", ignoreCase = true)) {
|
||||
val t = trimmed.substringAfter(">").substringBefore("</title>")
|
||||
|
|
@ -632,6 +646,10 @@ class SingleFileImporter(private val context: Context) {
|
|||
return@withContext book
|
||||
}
|
||||
|
||||
private fun sanitizeHtmlFragment(html: String): String {
|
||||
return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
|
||||
}
|
||||
|
||||
private suspend fun parseDocx(
|
||||
inputStream: InputStream,
|
||||
originalBookNameHint: String,
|
||||
|
|
@ -654,11 +672,7 @@ class SingleFileImporter(private val context: Context) {
|
|||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||
|
||||
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 fileName = "page_$pageNum.html"
|
||||
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("\"", """)}</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("\"", """)}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${sanitizedBodyContent.trim()}\n</body>\n</html>"
|
||||
|
||||
file.writeText(fullHtml)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,8 @@ import android.content.ClipData
|
|||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.util.Base64
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
|
|
@ -83,13 +81,12 @@ import androidx.compose.ui.window.Popup
|
|||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderTexture
|
||||
import com.aryan.reader.getReaderTextureDataUri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStreamReader
|
||||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
|
|
@ -351,6 +348,7 @@ fun ChapterWebView(
|
|||
currentParagraphGap: Float,
|
||||
currentImageSize: Float,
|
||||
currentHorizontalMargin: Float,
|
||||
currentVerticalMargin: Float,
|
||||
onChapterInitiallyScrolled: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onTap: () -> Unit,
|
||||
|
|
@ -386,7 +384,8 @@ fun ChapterWebView(
|
|||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
onInternalLinkClick: (String) -> Unit,
|
||||
activeTextureId: String? = null
|
||||
activeTextureId: String? = null,
|
||||
activeTextureAlpha: Float = 0.55f
|
||||
) {
|
||||
Timber.d(
|
||||
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
|
||||
|
|
@ -406,17 +405,8 @@ fun ChapterWebView(
|
|||
|
||||
val textureBase64 by remember(activeTextureId) {
|
||||
mutableStateOf(
|
||||
activeTextureId?.let { id ->
|
||||
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
|
||||
)
|
||||
}
|
||||
})
|
||||
getReaderTextureDataUri(context, activeTextureId)
|
||||
)
|
||||
}
|
||||
|
||||
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
|
||||
|
|
@ -476,10 +466,10 @@ fun ChapterWebView(
|
|||
|
||||
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 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(
|
||||
|
|
@ -489,6 +479,7 @@ fun ChapterWebView(
|
|||
currentParagraphGap,
|
||||
currentImageSize,
|
||||
currentHorizontalMargin,
|
||||
currentVerticalMargin,
|
||||
currentFontFamily,
|
||||
currentTextAlign
|
||||
) {
|
||||
|
|
@ -737,7 +728,7 @@ fun ChapterWebView(
|
|||
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
|
||||
val textHex =
|
||||
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)
|
||||
|
||||
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
|
||||
|
|
@ -782,7 +773,7 @@ fun ChapterWebView(
|
|||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
|
@ -946,7 +937,7 @@ fun ChapterWebView(
|
|||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||
|
||||
@Suppress("KotlinConstantConditions")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -92,7 +92,7 @@ fun DictionarySettingsDialog(
|
|||
)
|
||||
|
||||
// ── Dictionary ──
|
||||
if (BuildConfig.FLAVOR != "oss") {
|
||||
if (areReaderAiFeaturesEnabled(context)) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
|
|
|
|||
|
|
@ -32,11 +32,14 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import com.aryan.reader.AiDefinitionPopup
|
||||
import com.aryan.reader.AiFeature
|
||||
import com.aryan.reader.AiDefinitionResult
|
||||
import com.aryan.reader.AiHubBottomSheet
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SummarizationResult
|
||||
import com.aryan.reader.SummaryCacheManager
|
||||
import com.aryan.reader.callByokTextAi
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.fetchRecap
|
||||
import com.aryan.reader.paginatedreader.IPaginator
|
||||
|
|
@ -55,6 +58,7 @@ import java.net.URL
|
|||
*/
|
||||
suspend fun summarizeBookContent(
|
||||
content: String,
|
||||
context: Context,
|
||||
authToken: String?,
|
||||
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
|
||||
onUpdate: (String) -> Unit,
|
||||
|
|
@ -68,6 +72,27 @@ suspend fun summarizeBookContent(
|
|||
}
|
||||
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) {
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
|
|
@ -196,6 +221,7 @@ suspend fun executeRecapLogic(
|
|||
|
||||
summarizeBookContent(
|
||||
content = textToSummarize,
|
||||
context = context,
|
||||
authToken = authToken,
|
||||
onUsageReceived = { cost, _ ->
|
||||
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
|
||||
|
|
|
|||
|
|
@ -123,7 +123,9 @@ fun VerticalScrollbar(
|
|||
|
||||
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 firstItemOffset = listState.firstVisibleItemScrollOffset
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
*/
|
||||
// EpubReaderScreen.kt
|
||||
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
|
||||
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants"
|
||||
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
|
||||
)
|
||||
|
||||
package com.aryan.reader.epubreader
|
||||
|
|
@ -35,6 +35,8 @@ import android.graphics.Bitmap
|
|||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.RoundedCorner
|
||||
import android.view.View
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
|
|
@ -119,9 +121,14 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.graphics.BlendMode
|
||||
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.platform.LocalConfiguration
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
|
|
@ -158,12 +166,17 @@ import com.aryan.reader.SearchResult
|
|||
import com.aryan.reader.SummarizationResult
|
||||
import com.aryan.reader.SummaryCacheManager
|
||||
import com.aryan.reader.TtsSettingsSheet
|
||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||
import com.aryan.reader.countWords
|
||||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epub.hasReadableExtractedContent
|
||||
import com.aryan.reader.fetchAiDefinition
|
||||
import com.aryan.reader.loadCustomThemes
|
||||
import com.aryan.reader.loadGlobalTextureTransparency
|
||||
import com.aryan.reader.loadReaderThemeId
|
||||
import com.aryan.reader.loadReaderTextureBitmap
|
||||
import com.aryan.reader.paginatedreader.BookPaginator
|
||||
import com.aryan.reader.paginatedreader.CfiUtils
|
||||
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.rememberSearchState
|
||||
import com.aryan.reader.saveCustomThemes
|
||||
import com.aryan.reader.saveGlobalTextureTransparency
|
||||
import com.aryan.reader.saveReaderThemeId
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
import com.aryan.reader.tts.loadTtsMode
|
||||
import com.aryan.reader.tts.rememberTtsController
|
||||
import com.aryan.reader.tts.splitTextIntoChunks
|
||||
import kotlinx.coroutines.Job
|
||||
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 KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
|
||||
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
|
||||
private const val TOOL_ORDER_KEY = "reader_tool_order"
|
||||
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
|
||||
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
|
||||
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
|
||||
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
|
||||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
|
||||
private 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>) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
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()
|
||||
}
|
||||
|
||||
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) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
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 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)
|
||||
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_CUSTOM_THEMES = "custom_themes_json"
|
||||
|
||||
@UnstableApi
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@Composable
|
||||
fun EpubReaderScreen(
|
||||
|
|
@ -416,8 +496,8 @@ fun EpubReaderScreen(
|
|||
}
|
||||
} else null
|
||||
|
||||
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath) {
|
||||
epubBook.extractionBasePath.isNotBlank() && File(epubBook.extractionBasePath).exists()
|
||||
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) {
|
||||
epubBook.hasReadableExtractedContent()
|
||||
}
|
||||
var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
|
||||
mutableStateOf(false)
|
||||
|
|
@ -563,6 +643,7 @@ fun EpubReaderHost(
|
|||
|
||||
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
|
||||
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
|
||||
var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
|
||||
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
|
||||
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
|
||||
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
||||
|
|
@ -582,7 +663,7 @@ fun EpubReaderHost(
|
|||
var currentTtsMode by remember {
|
||||
mutableStateOf(
|
||||
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 toolOrder by remember { mutableStateOf(loadToolOrder(context)) }
|
||||
var bottomTools by remember { mutableStateOf(loadBottomTools(context)) }
|
||||
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
|
||||
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
|
||||
val isOss = BuildConfig.FLAVOR == "oss"
|
||||
val effectiveUseOnline = !isOss && useOnlineDictionary
|
||||
val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
|
||||
|
||||
if (effectiveUseOnline) {
|
||||
val wordCount = countWords(word)
|
||||
if (wordCount > 1 && !isProUser) {
|
||||
if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
|
||||
showDictionaryUpsellDialog = true
|
||||
} else {
|
||||
selectedTextForAi = word
|
||||
|
|
@ -815,6 +897,8 @@ fun EpubReaderHost(
|
|||
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
|
||||
|
||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
|
||||
val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
|
||||
|
||||
var bookmarks by remember(epubBook.title) {
|
||||
mutableStateOf(
|
||||
|
|
@ -991,6 +1075,7 @@ fun EpubReaderHost(
|
|||
var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) }
|
||||
var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) }
|
||||
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 currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
|
||||
var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
|
||||
|
|
@ -1006,14 +1091,14 @@ fun EpubReaderHost(
|
|||
var showFontSelectionSheet by remember { mutableStateOf(false) }
|
||||
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) {
|
||||
saveLocalReaderSettings(
|
||||
context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
|
||||
context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
|
||||
)
|
||||
} else {
|
||||
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 customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
|
||||
var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) }
|
||||
|
||||
val activeTheme = remember(currentThemeId, customThemes) {
|
||||
BuiltInThemes.find { it.id == currentThemeId }
|
||||
|
|
@ -1151,6 +1237,19 @@ fun EpubReaderHost(
|
|||
} else activeTheme.textColor
|
||||
}
|
||||
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 overlayAlpha = if (isDarkTheme) 0.08f else 0.06f
|
||||
|
|
@ -1401,7 +1500,7 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
fun startTts() {
|
||||
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
||||
if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
||||
showInsufficientCreditsDialog = true
|
||||
return
|
||||
}
|
||||
|
|
@ -1441,6 +1540,7 @@ fun EpubReaderHost(
|
|||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -1460,7 +1560,7 @@ fun EpubReaderHost(
|
|||
)
|
||||
|
||||
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
|
||||
return
|
||||
}
|
||||
|
|
@ -1504,6 +1604,7 @@ fun EpubReaderHost(
|
|||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -1582,18 +1683,6 @@ fun EpubReaderHost(
|
|||
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) {
|
||||
if (currentClientHeightValue > 0) {
|
||||
max(
|
||||
|
|
@ -1989,10 +2078,7 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
|
||||
val pageInfoBottomPadding by animateDpAsState(
|
||||
targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
|
||||
label = "PageInfoBottomPadding"
|
||||
)
|
||||
val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding
|
||||
|
||||
val isPageInfoVisible = when (pageInfoMode) {
|
||||
PageInfoMode.DEFAULT -> !showBars
|
||||
|
|
@ -2631,7 +2717,7 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
val handleGenerateSummary: (Boolean) -> Unit = { force ->
|
||||
if (!isProUser && credits <= 0) {
|
||||
if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) {
|
||||
showInsufficientCreditsDialog = true
|
||||
showAiHubSheet = false
|
||||
} else {
|
||||
|
|
@ -2688,6 +2774,7 @@ fun EpubReaderHost(
|
|||
val finalSummaryBuilder = StringBuilder()
|
||||
summarizeBookContent(
|
||||
content = text,
|
||||
context = context,
|
||||
authToken = token,
|
||||
onUsageReceived = { cost, freeRemaining ->
|
||||
currentCost = cost
|
||||
|
|
@ -2750,7 +2837,7 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
val handleGenerateRecap: () -> Unit = {
|
||||
if (credits <= 0) {
|
||||
if (BuildConfig.FLAVOR != "oss" && credits <= 0) {
|
||||
showInsufficientCreditsDialog = true
|
||||
showAiHubSheet = false
|
||||
} else {
|
||||
|
|
@ -2820,6 +2907,7 @@ fun EpubReaderHost(
|
|||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(effectiveBg)
|
||||
.then(activeTextureModifier)
|
||||
.padding(top = effectiveTopPadding)
|
||||
.focusRequester(containerFocusRequester)
|
||||
.focusable()
|
||||
|
|
@ -2879,13 +2967,15 @@ fun EpubReaderHost(
|
|||
) {
|
||||
when (currentRenderMode) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = contentTopPadding)
|
||||
.padding(bottom = contentBottomPadding)
|
||||
.padding(top = 16.dp)
|
||||
.testTag("ReaderContainer")
|
||||
) {
|
||||
if (chapters.isEmpty()) {
|
||||
|
|
@ -3292,10 +3382,12 @@ fun EpubReaderHost(
|
|||
currentParagraphGap = currentParagraphGap,
|
||||
currentImageSize = currentImageSize,
|
||||
currentHorizontalMargin = currentHorizontalMargin,
|
||||
currentVerticalMargin = currentVerticalMargin,
|
||||
currentFontFamily = currentFontFamily,
|
||||
customFontPath = currentCustomFontPath,
|
||||
currentTextAlign = currentTextAlign,
|
||||
activeTextureId = activeTextureId,
|
||||
activeTextureAlpha = activeTextureAlpha,
|
||||
onHighlightClicked = {
|
||||
lastHighlightClickTime = System.currentTimeMillis()
|
||||
showBars = false
|
||||
|
|
@ -3462,7 +3554,7 @@ fun EpubReaderHost(
|
|||
|
||||
if (ttsChunks.isNotEmpty()) {
|
||||
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
|
||||
ttsShouldStartOnChapterLoad = false
|
||||
return@launch
|
||||
|
|
@ -3483,6 +3575,7 @@ fun EpubReaderHost(
|
|||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
chapterIndex = targetChapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -3528,6 +3621,7 @@ fun EpubReaderHost(
|
|||
|
||||
summarizeBookContent(
|
||||
content = content,
|
||||
context = context,
|
||||
authToken = token,
|
||||
onUsageReceived = { cost: Double?, freeRemaining: Int? ->
|
||||
currentCost = cost
|
||||
|
|
@ -3779,11 +3873,14 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = contentTopPadding)
|
||||
.padding(bottom = contentBottomPadding)
|
||||
.testTag("ReaderContainer")
|
||||
) {
|
||||
|
|
@ -3799,6 +3896,7 @@ fun EpubReaderHost(
|
|||
paragraphGapMultiplier = currentParagraphGap,
|
||||
imageSizeMultiplier = currentImageSize,
|
||||
horizontalMarginMultiplier = currentHorizontalMargin,
|
||||
verticalMarginMultiplier = currentVerticalMargin,
|
||||
fontFamily = activeFontFamily,
|
||||
textAlign = currentTextAlign,
|
||||
activeHighlightPalette = currentHighlightPalette,
|
||||
|
|
@ -3810,6 +3908,7 @@ fun EpubReaderHost(
|
|||
offset = ttsState.startOffsetInSource
|
||||
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
|
||||
activeTextureId = activeTextureId,
|
||||
activeTextureAlpha = activeTextureAlpha,
|
||||
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
|
||||
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
|
||||
onPaginatorReady = { newPaginator ->
|
||||
|
|
@ -4094,17 +4193,19 @@ fun EpubReaderHost(
|
|||
|
||||
// Page Info Bar (Vertical)
|
||||
AnimatedVisibility(
|
||||
visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
|
||||
visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
|
||||
enter = fadeIn(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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PAGE_INFO_BAR_HEIGHT)
|
||||
.height(pageInfoBarHeight)
|
||||
.background(infoBarBgColor)
|
||||
.padding(bottom = bottomPadding + pageInfoBottomPadding)
|
||||
.then(activeTextureModifier)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
|
@ -4140,17 +4241,19 @@ fun EpubReaderHost(
|
|||
|
||||
// Page Info Bar (Paginated)
|
||||
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)),
|
||||
exit = fadeOut(animationSpec = tween(200)),
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
modifier = Modifier.align(
|
||||
if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PAGE_INFO_BAR_HEIGHT)
|
||||
.height(pageInfoBarHeight)
|
||||
.background(infoBarBgColor)
|
||||
.padding(bottom = bottomPadding + pageInfoBottomPadding)
|
||||
.then(activeTextureModifier)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
|
@ -4442,6 +4545,8 @@ fun EpubReaderHost(
|
|||
volumeScrollEnabled = volumeScrollEnabled,
|
||||
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||
hiddenTools = hiddenTools,
|
||||
toolOrder = toolOrder,
|
||||
bottomTools = bottomTools,
|
||||
onCustomizeTools = { showCustomizeToolsSheet = true },
|
||||
onNavigateBack = { triggerSaveAndExit() },
|
||||
isKeepScreenOn = isKeepScreenOn,
|
||||
|
|
@ -4522,6 +4627,71 @@ fun EpubReaderHost(
|
|||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||
onOpenThemeSettings = { showThemePanel = 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) {
|
||||
{
|
||||
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||
|
|
@ -4687,8 +4857,12 @@ fun EpubReaderHost(
|
|||
ttsState = ttsState,
|
||||
isProUser = isProUser,
|
||||
hiddenTools = hiddenTools,
|
||||
toolOrder = toolOrder,
|
||||
bottomTools = bottomTools,
|
||||
currentTtsMode = currentTtsMode,
|
||||
onOpenAiHub = { showAiHubSheet = true },
|
||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||
onOpenThemeSettings = { showThemePanel = true },
|
||||
onOpenSlider = {
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
|
|
@ -4770,6 +4944,8 @@ fun EpubReaderHost(
|
|||
onImageSizeChange = { currentImageSize = it },
|
||||
currentHorizontalMargin = currentHorizontalMargin,
|
||||
onHorizontalMarginChange = { currentHorizontalMargin = it },
|
||||
currentVerticalMargin = currentVerticalMargin,
|
||||
onVerticalMarginChange = { currentVerticalMargin = it },
|
||||
currentFont = currentFontFamily,
|
||||
currentCustomFontName = if(currentCustomFontPath != null) {
|
||||
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
|
||||
|
|
@ -4788,6 +4964,7 @@ fun EpubReaderHost(
|
|||
currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL
|
||||
currentImageSize = DEFAULT_IMAGE_SIZE_VAL
|
||||
currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL
|
||||
currentVerticalMargin = DEFAULT_VERTICAL_MARGIN_VAL
|
||||
currentFontFamily = ReaderFont.ORIGINAL
|
||||
currentCustomFontPath = null
|
||||
currentTextAlign = ReaderTextAlign.DEFAULT
|
||||
|
|
@ -5085,10 +5262,20 @@ fun EpubReaderHost(
|
|||
if (showCustomizeToolsSheet) {
|
||||
CustomizeToolsSheet(
|
||||
hiddenTools = hiddenTools,
|
||||
toolOrder = toolOrder,
|
||||
bottomTools = bottomTools,
|
||||
onUpdate = { newHiddenSet ->
|
||||
hiddenTools = newHiddenSet
|
||||
saveHiddenTools(context, newHiddenSet)
|
||||
},
|
||||
onOrderUpdate = { newOrder ->
|
||||
toolOrder = newOrder
|
||||
saveToolOrder(context, newOrder)
|
||||
},
|
||||
onPlacementUpdate = { newBottomTools ->
|
||||
bottomTools = newBottomTools
|
||||
saveBottomTools(context, newBottomTools)
|
||||
},
|
||||
onDismiss = { showCustomizeToolsSheet = false }
|
||||
)
|
||||
}
|
||||
|
|
@ -5133,6 +5320,11 @@ fun EpubReaderHost(
|
|||
pageInfoMode = it
|
||||
savePageInfoMode(context, it)
|
||||
},
|
||||
pageInfoPosition = pageInfoPosition,
|
||||
onPageInfoPositionChange = {
|
||||
pageInfoPosition = it
|
||||
savePageInfoPosition(context, it)
|
||||
},
|
||||
pullToTurnEnabled = pullToTurnEnabled,
|
||||
onPullToTurnChange = {
|
||||
pullToTurnEnabled = it
|
||||
|
|
@ -5173,6 +5365,11 @@ fun EpubReaderHost(
|
|||
ReaderThemePanel(
|
||||
isVisible = true,
|
||||
currentThemeId = currentThemeId,
|
||||
globalTextureTransparency = globalTextureTransparency,
|
||||
onGlobalTextureTransparencyChange = {
|
||||
globalTextureTransparency = it
|
||||
saveGlobalTextureTransparency(context, it)
|
||||
},
|
||||
onThemeSelected = {
|
||||
currentThemeId = it
|
||||
saveReaderThemeId(context, it)
|
||||
|
|
|
|||
|
|
@ -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 SYSTEM_UI_MODE_KEY = "reader_system_ui_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"
|
||||
|
||||
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_IMAGE_SIZE_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_PITCH_KEY = "tts_pitch"
|
||||
|
||||
|
|
@ -177,12 +179,18 @@ enum class PageInfoMode(val id: Int, val title: String) {
|
|||
HIDDEN(2, "Always Hide")
|
||||
}
|
||||
|
||||
enum class PageInfoPosition(val id: Int, val title: String) {
|
||||
BOTTOM(0, "Bottom"),
|
||||
TOP(1, "Top")
|
||||
}
|
||||
|
||||
data class FormatSettings(
|
||||
val fontSize: Float,
|
||||
val lineHeight: Float,
|
||||
val paragraphGap: Float,
|
||||
val imageSize: Float,
|
||||
val horizontalMargin: Float,
|
||||
val verticalMargin: Float,
|
||||
val font: ReaderFont,
|
||||
val customPath: String?,
|
||||
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_IMAGE_SIZE_PREFIX = "local_image_size_"
|
||||
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_TEXT_ALIGN_PREFIX = "local_text_align_"
|
||||
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 {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
|
@ -216,6 +226,7 @@ fun saveLocalReaderSettings(
|
|||
paragraphGap: Float,
|
||||
imageSize: Float,
|
||||
horizontalMargin: Float,
|
||||
verticalMargin: Float,
|
||||
fontFamily: ReaderFont,
|
||||
customFontPath: String?,
|
||||
textAlign: ReaderTextAlign
|
||||
|
|
@ -227,6 +238,7 @@ fun saveLocalReaderSettings(
|
|||
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
|
||||
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
|
||||
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
|
||||
putFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, verticalMargin)
|
||||
if (customFontPath != null) {
|
||||
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
|
||||
} else {
|
||||
|
|
@ -258,6 +270,17 @@ fun loadPageInfoMode(context: Context): PageInfoMode {
|
|||
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) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)) {
|
||||
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
|
||||
} else {
|
||||
|
|
@ -346,6 +380,7 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
|
|||
paragraphGap = paragraphGap,
|
||||
imageSize = imageSize,
|
||||
horizontalMargin = horizontalMargin,
|
||||
verticalMargin = verticalMargin,
|
||||
font = font,
|
||||
customPath = customPath,
|
||||
textAlign = textAlign
|
||||
|
|
@ -390,6 +425,7 @@ fun saveReaderSettings(
|
|||
paragraphGap: Float,
|
||||
imageSize: Float,
|
||||
horizontalMargin: Float,
|
||||
verticalMargin: Float,
|
||||
fontFamily: ReaderFont,
|
||||
customFontPath: String?,
|
||||
textAlign: ReaderTextAlign
|
||||
|
|
@ -401,6 +437,7 @@ fun saveReaderSettings(
|
|||
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
|
||||
putFloat(IMAGE_SIZE_KEY, imageSize)
|
||||
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
|
||||
putFloat(VERTICAL_MARGIN_KEY, verticalMargin)
|
||||
if (customFontPath != null) {
|
||||
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
|
||||
} else {
|
||||
|
|
@ -454,6 +491,8 @@ fun ReaderTextFormatPanel(
|
|||
onImageSizeChange: (Float) -> Unit,
|
||||
currentHorizontalMargin: Float,
|
||||
onHorizontalMarginChange: (Float) -> Unit,
|
||||
currentVerticalMargin: Float,
|
||||
onVerticalMarginChange: (Float) -> Unit,
|
||||
currentFont: ReaderFont,
|
||||
currentCustomFontName: String?,
|
||||
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,
|
||||
pageInfoMode: PageInfoMode,
|
||||
onPageInfoModeChange: (PageInfoMode) -> Unit,
|
||||
pageInfoPosition: PageInfoPosition,
|
||||
onPageInfoPositionChange: (PageInfoPosition) -> Unit,
|
||||
pullToTurnEnabled: Boolean,
|
||||
onPullToTurnChange: (Boolean) -> Unit,
|
||||
pullToTurnMultiplier: Float,
|
||||
|
|
@ -884,6 +939,16 @@ fun VisualOptionsSheet(
|
|||
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))
|
||||
|
||||
// Pull to change chapter
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
package com.aryan.reader.epubreader
|
||||
|
||||
import timber.log.Timber
|
||||
import android.graphics.Color
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
|
|
@ -51,14 +52,20 @@ fun EpubReaderSystemUiController(
|
|||
return@DisposableEffect onDispose {}
|
||||
}
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
val originalStatusBarColor = window.statusBarColor
|
||||
val originalNavigationBarColor = window.navigationBarColor
|
||||
Timber.d("Applying immersive mode.")
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
onDispose {
|
||||
Timber.d("Restoring system UI.")
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
window.statusBarColor = originalStatusBarColor
|
||||
window.navigationBarColor = originalNavigationBarColor
|
||||
insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
|
||||
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
|
||||
insetsController.systemBarsBehavior = initialSystemBarsBehavior
|
||||
|
|
|
|||
|
|
@ -328,6 +328,8 @@ private fun handleVerticalAutoAdvance(
|
|||
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
|
||||
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
|
||||
chapterIndex = currentTtsChapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
continueSession = true,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -358,6 +360,8 @@ private fun handleVerticalAutoAdvance(
|
|||
chapterTitle = chapters.getOrNull(nextIdx)?.title,
|
||||
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
|
||||
chapterIndex = nextIdx,
|
||||
totalChapters = chapters.size,
|
||||
continueSession = true,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -433,6 +437,8 @@ private fun handlePaginatedAutoAdvance(
|
|||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
chapterIndex = chapterToTry,
|
||||
totalChapters = chapters.size,
|
||||
continueSession = true,
|
||||
ttsMode = ttsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ data class OpdsAcquisition(
|
|||
get() = when {
|
||||
mimeType.contains("epub") -> "EPUB"
|
||||
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("fictionbook") || mimeType.contains("fb2") -> "FB2"
|
||||
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
|
||||
|
|
@ -52,6 +54,7 @@ data class OpdsAcquisition(
|
|||
"PDF" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2" -> 2
|
||||
"MD", "HTML" -> 2
|
||||
"CBZ" -> 1
|
||||
"TXT" -> 0
|
||||
else -> -1
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aryan.reader.resolveFileExtensionSuffixFromName
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Response
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
|
@ -93,16 +95,7 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
val body = response.body ?: throw Exception("Empty body")
|
||||
val contentLength = body.contentLength()
|
||||
|
||||
val ext = when (acquisition.formatName) {
|
||||
"EPUB" -> ".epub"
|
||||
"PDF" -> ".pdf"
|
||||
"MOBI" -> ".mobi"
|
||||
"FB2" -> ".fb2"
|
||||
"CBZ" -> ".cbz"
|
||||
"CBR" -> ".cbr"
|
||||
"TXT" -> ".txt"
|
||||
else -> ".epub"
|
||||
}
|
||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||
|
||||
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
|
||||
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 {
|
||||
loadCatalogs()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -120,7 +120,8 @@ class BookPaginator(
|
|||
private val mathMLRenderer: MathMLRenderer,
|
||||
private val userTextAlign: TextAlign?,
|
||||
private val paragraphGapMultiplier: Float,
|
||||
private val imageSizeMultiplier: Float
|
||||
private val imageSizeMultiplier: Float,
|
||||
private val verticalMarginMultiplier: Float
|
||||
) : IPaginator {
|
||||
override var totalPageCount by mutableIntStateOf(0)
|
||||
private set
|
||||
|
|
@ -185,6 +186,14 @@ class BookPaginator(
|
|||
isLoading = true
|
||||
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)
|
||||
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
||||
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
||||
|
|
@ -214,7 +223,7 @@ class BookPaginator(
|
|||
// 5. Prioritize CURRENT chapter only
|
||||
// We no longer blindly queue neighbors immediately to keep startup fast.
|
||||
// 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
|
||||
triggerPagination(startChapter, PRIORITY_HIGHEST)
|
||||
|
|
@ -277,6 +286,7 @@ class BookPaginator(
|
|||
append("-ta:$userTextAlign")
|
||||
append("-pg:$paragraphGapMultiplier")
|
||||
append("-img:$imageSizeMultiplier")
|
||||
append("-vm:$verticalMarginMultiplier")
|
||||
}
|
||||
val hash = configString.hashCode()
|
||||
return hash
|
||||
|
|
@ -503,7 +513,7 @@ class BookPaginator(
|
|||
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
|
||||
}
|
||||
|
||||
val semanticBlocks = htmlToSemanticBlocks(
|
||||
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||
html = processedHtml,
|
||||
cssRules = parsingCssRules,
|
||||
textStyle = textStyle.copy(color = Color.Black),
|
||||
|
|
@ -792,6 +802,10 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
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) {
|
||||
Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -163,10 +163,12 @@ class ContentStyler(
|
|||
}
|
||||
|
||||
is SemanticMath -> {
|
||||
val svgContent = block.svgContent
|
||||
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
|
||||
val finalSvgContent = when {
|
||||
block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
|
||||
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
|
||||
else -> {
|
||||
val themedSvg = applyThemeToSvg(block.svgContent)
|
||||
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
|
||||
embedImagesInSvg(themedSvg)
|
||||
}
|
||||
}
|
||||
|
|
@ -452,11 +454,11 @@ class ContentStyler(
|
|||
}
|
||||
}
|
||||
|
||||
if (span.linkHref != null) {
|
||||
addStringAnnotation("URL", span.linkHref, span.start, span.end)
|
||||
span.linkHref?.let { linkHref ->
|
||||
addStringAnnotation("URL", linkHref, span.start, span.end)
|
||||
}
|
||||
if (span.elementId != null) {
|
||||
addStringAnnotation("ID", span.elementId, span.start, span.end)
|
||||
span.elementId?.let { elementId ->
|
||||
addStringAnnotation("ID", elementId, span.start, span.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,47 +27,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import java.io.File
|
||||
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 {
|
||||
val identifier = "$bookId:$fontPath"
|
||||
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ class LocatorConverter(
|
|||
otherComplex = mergedOtherComplex
|
||||
)
|
||||
|
||||
val semanticBlocks = htmlToSemanticBlocks(
|
||||
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||
html = htmlToParse,
|
||||
cssRules = parsingCssRules,
|
||||
textStyle = TextStyle(),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
|
|||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -147,7 +148,7 @@ import coil.compose.AsyncImage
|
|||
import coil.imageLoader
|
||||
import coil.request.ImageRequest.Builder
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderTexture
|
||||
import com.aryan.reader.loadReaderTextureBitmap
|
||||
import com.aryan.reader.countWords
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epubreader.HighlightColor
|
||||
|
|
@ -251,6 +252,12 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
|||
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(
|
||||
baseStyle: TextStyle,
|
||||
level: Int,
|
||||
|
|
@ -473,6 +480,51 @@ private fun computeImageRenderSizeDp(
|
|||
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
|
||||
private fun WrappingContentLayout(
|
||||
block: WrappingContentBlock,
|
||||
|
|
@ -682,6 +734,7 @@ fun PaginatedReaderScreen(
|
|||
paragraphGapMultiplier: Float,
|
||||
imageSizeMultiplier: Float,
|
||||
horizontalMarginMultiplier: Float,
|
||||
verticalMarginMultiplier: Float,
|
||||
fontFamily: FontFamily,
|
||||
textAlign: ReaderTextAlign,
|
||||
ttsHighlightInfo: TtsHighlightInfo?,
|
||||
|
|
@ -702,7 +755,8 @@ fun PaginatedReaderScreen(
|
|||
onHighlightDeleted: (String) -> Unit,
|
||||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
activeTextureId: String? = null
|
||||
activeTextureId: String? = null,
|
||||
activeTextureAlpha: Float = 0.55f
|
||||
) {
|
||||
LaunchedEffect(userHighlights) {
|
||||
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
|
||||
|
|
@ -713,11 +767,7 @@ fun PaginatedReaderScreen(
|
|||
|
||||
val context = LocalContext.current
|
||||
val textureBitmap = remember(activeTextureId) {
|
||||
activeTextureId?.let { id ->
|
||||
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
|
||||
ImageBitmap.imageResource(context.resources, resId)
|
||||
}
|
||||
}
|
||||
loadReaderTextureBitmap(context, activeTextureId)
|
||||
}
|
||||
|
||||
val textureModifier = if (textureBitmap != null) {
|
||||
|
|
@ -725,13 +775,13 @@ fun PaginatedReaderScreen(
|
|||
val brush = ShaderBrush(
|
||||
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
|
||||
|
||||
var isNavigatingByLink by remember { mutableStateOf(false) }
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val baseTextStyle = MaterialTheme.typography.bodyLarge
|
||||
|
||||
|
|
@ -740,6 +790,7 @@ fun PaginatedReaderScreen(
|
|||
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
|
||||
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
|
||||
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
|
||||
var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
|
||||
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
|
||||
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
|
||||
|
||||
|
|
@ -781,7 +832,7 @@ fun PaginatedReaderScreen(
|
|||
debouncedFontFamily
|
||||
) {
|
||||
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
|
||||
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
|
||||
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
|
||||
|
||||
baseTextStyle.copy(
|
||||
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 ||
|
||||
lineHeightMultiplier != debouncedLineHeightMult ||
|
||||
paragraphGapMultiplier != debouncedParagraphGapMult ||
|
||||
imageSizeMultiplier != debouncedImageSizeMult ||
|
||||
horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
|
||||
verticalMarginMultiplier != debouncedVerticalMarginMult ||
|
||||
fontFamily != debouncedFontFamily ||
|
||||
textAlign != debouncedTextAlign
|
||||
) {
|
||||
|
|
@ -836,6 +888,7 @@ fun PaginatedReaderScreen(
|
|||
debouncedParagraphGapMult = paragraphGapMultiplier
|
||||
debouncedImageSizeMult = imageSizeMultiplier
|
||||
debouncedHorizontalMarginMult = horizontalMarginMultiplier
|
||||
debouncedVerticalMarginMult = verticalMarginMultiplier
|
||||
debouncedFontFamily = fontFamily
|
||||
debouncedTextAlign = textAlign
|
||||
Timber.d("Debounce complete. Applying new format settings.")
|
||||
|
|
@ -851,8 +904,28 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val horizontalPadding = 16.dp * debouncedHorizontalMarginMult
|
||||
val verticalPadding = 16.dp
|
||||
val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
|
||||
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 =
|
||||
remember(this.constraints, density, horizontalPadding, verticalPadding) {
|
||||
|
|
@ -860,9 +933,9 @@ fun PaginatedReaderScreen(
|
|||
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
|
||||
val finalConstraints = this.constraints.copy(
|
||||
minWidth = 0,
|
||||
maxWidth = this.constraints.maxWidth - (2 * horizontalPaddingPx),
|
||||
maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
|
||||
minHeight = 0,
|
||||
maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx)
|
||||
maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
|
||||
)
|
||||
finalConstraints
|
||||
}
|
||||
|
|
@ -950,7 +1023,8 @@ fun PaginatedReaderScreen(
|
|||
mathMLRenderer = mathMLRenderer,
|
||||
userTextAlign = userTextAlign,
|
||||
paragraphGapMultiplier = debouncedParagraphGapMult,
|
||||
imageSizeMultiplier = debouncedImageSizeMult
|
||||
imageSizeMultiplier = debouncedImageSizeMult,
|
||||
verticalMarginMultiplier = debouncedVerticalMarginMult
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1168,7 +1242,10 @@ fun PaginatedReaderScreen(
|
|||
isDarkTheme = isDarkTheme,
|
||||
activeHighlightPalette = activeHighlightPalette,
|
||||
onUpdatePalette = onUpdatePalette,
|
||||
effectiveText = effectiveText
|
||||
effectiveText = effectiveText,
|
||||
pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
|
||||
pageTextureBitmap = textureBitmap,
|
||||
pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
|
||||
)
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
|
|
@ -1989,7 +2066,10 @@ internal fun PaginatedReaderContent(
|
|||
onHighlightDeleted: (String) -> Unit,
|
||||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
isDarkTheme: Boolean
|
||||
isDarkTheme: Boolean,
|
||||
pageTextureModifier: Modifier = Modifier,
|
||||
pageTextureBitmap: ImageBitmap? = null,
|
||||
pageTextureAlpha: Float = 0f
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -2130,7 +2210,9 @@ internal fun PaginatedReaderContent(
|
|||
pageIndex,
|
||||
effectiveBg,
|
||||
isDarkTheme,
|
||||
pageTurnTouchY
|
||||
pageTurnTouchY,
|
||||
pageTextureBitmap,
|
||||
pageTextureAlpha
|
||||
)
|
||||
} else Modifier
|
||||
|
||||
|
|
@ -2284,7 +2366,7 @@ internal fun PaginatedReaderContent(
|
|||
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().pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
|
|
@ -2784,12 +2866,14 @@ internal fun PaginatedReaderContent(
|
|||
val markerAreaModifier =
|
||||
Modifier.width(32.dp)
|
||||
.padding(end = 8.dp)
|
||||
val itemMarkerImage = block.itemMarkerImage
|
||||
val itemMarker = block.itemMarker
|
||||
|
||||
if (block.itemMarkerImage != null) {
|
||||
if (itemMarkerImage != null) {
|
||||
val imageRequest =
|
||||
Builder(LocalContext.current).data(
|
||||
File(
|
||||
block.itemMarkerImage
|
||||
itemMarkerImage
|
||||
)
|
||||
).crossfade(true).build()
|
||||
val imageSize = with(density) {
|
||||
|
|
@ -2805,9 +2889,9 @@ internal fun PaginatedReaderContent(
|
|||
alignment = Alignment.CenterEnd,
|
||||
contentScale = ContentScale.FillHeight
|
||||
)
|
||||
} else if (block.itemMarker != null) {
|
||||
} else if (itemMarker != null) {
|
||||
Text(
|
||||
text = block.itemMarker,
|
||||
text = itemMarker,
|
||||
style = textStyle.copy(
|
||||
textAlign = TextAlign.End
|
||||
),
|
||||
|
|
@ -3017,10 +3101,12 @@ internal fun PaginatedReaderContent(
|
|||
}
|
||||
|
||||
is MathBlock -> {
|
||||
val svgContent = block.svgContent?.takeIf { it.isNotBlank() }
|
||||
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(
|
||||
modifier = paddingModifier
|
||||
) {
|
||||
|
|
@ -3104,7 +3190,7 @@ internal fun PaginatedReaderContent(
|
|||
val imageRequest =
|
||||
Builder(LocalContext.current).data(
|
||||
SvgData(
|
||||
block.svgContent
|
||||
nonBlankSvgContent
|
||||
)
|
||||
).listener(
|
||||
onError = { _, result ->
|
||||
|
|
@ -3189,7 +3275,10 @@ internal fun PaginatedReaderContent(
|
|||
)
|
||||
}).crossfade(true).build()
|
||||
|
||||
BoxWithConstraints(modifier = paddingModifier) {
|
||||
BoxWithConstraints(
|
||||
modifier = paddingModifier,
|
||||
contentAlignment = imageBlockContentAlignment(style)
|
||||
) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
block = block,
|
||||
density = density,
|
||||
|
|
@ -3357,9 +3446,10 @@ internal fun PaginatedReaderContent(
|
|||
Row(
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
if (blockInCell.itemMarker != null) {
|
||||
val itemMarker = blockInCell.itemMarker
|
||||
if (itemMarker != null) {
|
||||
Text(
|
||||
text = blockInCell.itemMarker,
|
||||
text = itemMarker,
|
||||
style = cellTextStyle,
|
||||
modifier = Modifier.padding(
|
||||
end = 4.dp
|
||||
|
|
@ -3390,40 +3480,23 @@ internal fun PaginatedReaderContent(
|
|||
}
|
||||
|
||||
is ImageBlock -> {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
AsyncImage(
|
||||
model = Builder(
|
||||
LocalContext.current
|
||||
).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
)
|
||||
.build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = tableCellImageModifier(
|
||||
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(
|
||||
model = Builder(
|
||||
LocalContext.current
|
||||
).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
)
|
||||
.build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is TextContentBlock -> {
|
||||
|
|
@ -4101,10 +4174,12 @@ private fun RenderFlexChildBlock(
|
|||
val markerAreaModifier = Modifier
|
||||
.width(32.dp)
|
||||
.padding(end = 8.dp)
|
||||
val itemMarkerImage = childBlock.itemMarkerImage
|
||||
val itemMarker = childBlock.itemMarker
|
||||
|
||||
if (childBlock.itemMarkerImage != null) {
|
||||
if (itemMarkerImage != null) {
|
||||
val imageRequest =
|
||||
Builder(LocalContext.current).data(File(childBlock.itemMarkerImage))
|
||||
Builder(LocalContext.current).data(File(itemMarkerImage))
|
||||
.crossfade(true).build()
|
||||
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
||||
|
||||
|
|
@ -4115,9 +4190,9 @@ private fun RenderFlexChildBlock(
|
|||
alignment = Alignment.CenterEnd,
|
||||
contentScale = ContentScale.FillHeight
|
||||
)
|
||||
} else if (childBlock.itemMarker != null) {
|
||||
} else if (itemMarker != null) {
|
||||
Text(
|
||||
text = childBlock.itemMarker,
|
||||
text = itemMarker,
|
||||
style = textStyle.copy(textAlign = TextAlign.End),
|
||||
modifier = markerAreaModifier
|
||||
)
|
||||
|
|
@ -4160,7 +4235,7 @@ private fun RenderFlexChildBlock(
|
|||
ColorFilter.colorMatrix(ColorMatrix(matrix))
|
||||
} else null
|
||||
|
||||
BoxWithConstraints {
|
||||
BoxWithConstraints(contentAlignment = imageBlockContentAlignment(style)) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
block = childBlock,
|
||||
density = density,
|
||||
|
|
@ -4278,37 +4353,20 @@ private fun RenderFlexChildBlock(
|
|||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else if (blockInCell is ImageBlock) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
AsyncImage(
|
||||
model = Builder(LocalContext.current).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
).build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = tableCellImageModifier(
|
||||
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(
|
||||
model = Builder(LocalContext.current).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
).build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4332,7 +4390,9 @@ private fun Modifier.realisticBookPage(
|
|||
pageIndex: Int,
|
||||
paperColor: Color,
|
||||
isDarkTheme: Boolean,
|
||||
touchY: Float?
|
||||
touchY: Float?,
|
||||
textureBitmap: ImageBitmap? = null,
|
||||
textureAlpha: Float = 0f
|
||||
): Modifier = composed {
|
||||
|
||||
val frontPath = remember { Path() }
|
||||
|
|
@ -4360,9 +4420,19 @@ private fun Modifier.realisticBookPage(
|
|||
.drawWithContent {
|
||||
val drawStart = System.nanoTime()
|
||||
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) {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
drawContent()
|
||||
}
|
||||
else if (pageOffset < 0f && pageOffset > -1f) {
|
||||
|
|
@ -4423,7 +4493,7 @@ private fun Modifier.realisticBookPage(
|
|||
frontPath.close()
|
||||
|
||||
clipPath(frontPath) {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
|
||||
|
|
@ -4466,6 +4536,15 @@ private fun Modifier.realisticBookPage(
|
|||
clipRect(0f, 0f, w, h) {
|
||||
clipPath(frontPath) {
|
||||
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)
|
||||
drawPath(reflectedScreenPath, color = flapTint)
|
||||
|
||||
|
|
@ -4493,12 +4572,12 @@ private fun Modifier.realisticBookPage(
|
|||
}
|
||||
|
||||
} else {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
else {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
drawContent()
|
||||
}
|
||||
|
||||
|
|
@ -4515,10 +4594,14 @@ fun Modifier.drawCssBorders(
|
|||
blockStyle: BlockStyle,
|
||||
@Suppress("unused") density: Density
|
||||
): Modifier = this.drawBehind {
|
||||
val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f
|
||||
val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f
|
||||
val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f
|
||||
val leftWidth = blockStyle.borderLeft?.width?.toPx() ?: 0f
|
||||
val borderTop = blockStyle.borderTop
|
||||
val borderRight = blockStyle.borderRight
|
||||
val borderBottom = blockStyle.borderBottom
|
||||
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 trRadius = blockStyle.borderTopRightRadius.toPx()
|
||||
|
|
@ -4550,9 +4633,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// TOP
|
||||
if (topWidth > 0f && blockStyle.borderTop != null) {
|
||||
val color = blockStyle.borderTop.color
|
||||
val effect = getPathEffect(blockStyle.borderTop.style, topWidth)
|
||||
if (topWidth > 0f && borderTop != null) {
|
||||
val color = borderTop.color
|
||||
val effect = getPathEffect(borderTop.style, topWidth)
|
||||
val offset = topWidth / 2f
|
||||
|
||||
val startX = if (tlRadius > 0) tlRadius else 0f
|
||||
|
|
@ -4568,9 +4651,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// BOTTOM
|
||||
if (bottomWidth > 0f && blockStyle.borderBottom != null) {
|
||||
val color = blockStyle.borderBottom.color
|
||||
val effect = getPathEffect(blockStyle.borderBottom.style, bottomWidth)
|
||||
if (bottomWidth > 0f && borderBottom != null) {
|
||||
val color = borderBottom.color
|
||||
val effect = getPathEffect(borderBottom.style, bottomWidth)
|
||||
val offset = size.height - (bottomWidth / 2f)
|
||||
|
||||
val startX = if (blRadius > 0) blRadius else 0f
|
||||
|
|
@ -4586,9 +4669,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// LEFT
|
||||
if (leftWidth > 0f && blockStyle.borderLeft != null) {
|
||||
val color = blockStyle.borderLeft.color
|
||||
val effect = getPathEffect(blockStyle.borderLeft.style, leftWidth)
|
||||
if (leftWidth > 0f && borderLeft != null) {
|
||||
val color = borderLeft.color
|
||||
val effect = getPathEffect(borderLeft.style, leftWidth)
|
||||
val offset = leftWidth / 2f
|
||||
|
||||
val startY = if (tlRadius > 0) tlRadius else 0f
|
||||
|
|
@ -4604,9 +4687,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// RIGHT
|
||||
if (rightWidth > 0f && blockStyle.borderRight != null) {
|
||||
val color = blockStyle.borderRight.color
|
||||
val effect = getPathEffect(blockStyle.borderRight.style, rightWidth)
|
||||
if (rightWidth > 0f && borderRight != null) {
|
||||
val color = borderRight.color
|
||||
val effect = getPathEffect(borderRight.style, rightWidth)
|
||||
val offset = size.width - (rightWidth / 2f)
|
||||
|
||||
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(
|
||||
color = blockStyle.borderTop.color,
|
||||
color = borderTop.color,
|
||||
startAngle = 180f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
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(
|
||||
color = blockStyle.borderTop.color,
|
||||
color = borderTop.color,
|
||||
startAngle = 270f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
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(
|
||||
color = blockStyle.borderBottom.color,
|
||||
color = borderBottom.color,
|
||||
startAngle = 0f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
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(
|
||||
color = blockStyle.borderBottom.color,
|
||||
color = borderBottom.color,
|
||||
startAngle = 90f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
mathMLRenderer = mathMLRenderer,
|
||||
userTextAlign = null,
|
||||
paragraphGapMultiplier = paragraphGapMultiplier,
|
||||
imageSizeMultiplier = 1.0f
|
||||
imageSizeMultiplier = 1.0f,
|
||||
verticalMarginMultiplier = 1.0f
|
||||
)
|
||||
paginator = newPaginator
|
||||
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ abstract class BookCacheDao {
|
|||
ConfigurationCache::class,
|
||||
AnchorIndexEntry::class
|
||||
],
|
||||
version = 8,
|
||||
version = 10,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class BookCacheDatabase : RoomDatabase() {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import androidx.room.ForeignKey
|
|||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
const val LATEST_PROCESSING_VERSION = 8
|
||||
const val LATEST_PROCESSING_VERSION = 10
|
||||
|
||||
@Entity(tableName = "processed_books")
|
||||
data class ProcessedBook(
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import com.aryan.reader.paginatedreader.FontFaceInfo
|
|||
import com.aryan.reader.paginatedreader.MathMLRenderer
|
||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||
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.semanticBlockModule
|
||||
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")}")
|
||||
|
||||
|
||||
val semanticBlocks = htmlToSemanticBlocks(
|
||||
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||
html = processedHtml,
|
||||
cssRules = lightThemeCssRules,
|
||||
textStyle = textStyle,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
|
|
@ -68,6 +69,9 @@ fun MagnifierComposable(
|
|||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val magnifierWidthPx = size.width
|
||||
val magnifierHeightPx = size.height
|
||||
if (magnifierWidthPx <= 0f || magnifierHeightPx <= 0f || zoomFactor <= 0f) {
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
|
||||
|
||||
|
|
@ -109,8 +113,10 @@ fun MagnifierComposable(
|
|||
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
|
||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||
|
||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||
|
|
@ -174,8 +180,10 @@ fun MagnifierComposable(
|
|||
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
|
||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||
val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||
|
||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype
|
||||
|
||||
object NativePdfiumBridge {
|
||||
init {
|
||||
System.loadLibrary("native-lib")
|
||||
|
|
@ -31,9 +33,9 @@ object NativePdfiumBridge {
|
|||
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
|
||||
@JvmStatic external fun checkActionSupport(): Boolean
|
||||
|
||||
const val ANNOT_TEXT = 1 // Sticky Note
|
||||
const val ANNOT_LINK = 2 // Link
|
||||
const val ANNOT_HIGHLIGHT = 8 // Highlight
|
||||
const val ANNOT_INK = 12 // Freehand drawing
|
||||
const val ANNOT_WIDGET = 19
|
||||
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
|
||||
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK
|
||||
const val ANNOT_HIGHLIGHT = PdfiumAnnotationSubtype.HIGHLIGHT
|
||||
const val ANNOT_INK = PdfiumAnnotationSubtype.INK
|
||||
const val ANNOT_WIDGET = PdfiumAnnotationSubtype.WIDGET
|
||||
}
|
||||
|
|
@ -84,7 +84,9 @@ fun VerticalScrollbar(
|
|||
|
||||
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 firstItemOffset = listState.firstVisibleItemScrollOffset
|
||||
|
|
|
|||
|
|
@ -69,9 +69,13 @@ import androidx.compose.ui.graphics.BlendMode
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
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.ShaderBrush
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
|
|
@ -119,6 +123,7 @@ import androidx.core.graphics.scale
|
|||
import androidx.core.graphics.set
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.loadReaderTextureBitmap
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
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)
|
||||
|
||||
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 {
|
||||
ANNOTATION, TEXT_CONTENT
|
||||
}
|
||||
|
|
@ -426,7 +439,10 @@ data class PageStaticData(
|
|||
val colorFilter: StableHolder<ColorFilter?>,
|
||||
val isDarkMode: 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
|
||||
|
|
@ -497,6 +513,7 @@ internal fun PdfPageComposable(
|
|||
onTtsHighlightCenterCalculated: ((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),
|
||||
activeTextureAlpha: Float = 0.55f,
|
||||
excludeImages: Boolean = false,
|
||||
onDoubleTap: ((Offset) -> Unit)? = null,
|
||||
isEditMode: Boolean = false,
|
||||
|
|
@ -552,7 +569,7 @@ internal fun PdfPageComposable(
|
|||
var isLoadingPage by remember { mutableStateOf(true) }
|
||||
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val density = LocalDensity.current
|
||||
LocalContext.current
|
||||
val context = LocalContext.current
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
|
|
@ -564,6 +581,7 @@ internal fun PdfPageComposable(
|
|||
var isTransforming by remember { mutableStateOf(false) }
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
LaunchedEffect(scale, offset) {
|
||||
onZoomAndPanChanged?.invoke(scale, offset)
|
||||
|
|
@ -590,8 +608,10 @@ internal fun PdfPageComposable(
|
|||
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
|
||||
|
||||
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 latestEffectiveScale by rememberUpdatedState(effectiveScale)
|
||||
val latestEffectiveOffset by rememberUpdatedState(effectiveOffset)
|
||||
|
||||
SideEffect {
|
||||
Timber.tag("PdfDrawPerf")
|
||||
|
|
@ -633,6 +653,8 @@ internal fun PdfPageComposable(
|
|||
var actualBitmapHeightPx 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 canvasHeightPx = remember { mutableFloatStateOf(0f) }
|
||||
|
||||
|
|
@ -687,6 +709,15 @@ internal fun PdfPageComposable(
|
|||
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) {
|
||||
derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
|
||||
|
|
@ -1146,13 +1177,13 @@ internal fun PdfPageComposable(
|
|||
try {
|
||||
val pagePtr = pageWrapper.getNativePointer()
|
||||
if (pagePtr != 0L) {
|
||||
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
|
||||
val imgRects = mutableListOf<android.graphics.Rect>()
|
||||
val outRect = FloatArray(4)
|
||||
|
||||
for (i in 0 until objCount) {
|
||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
|
||||
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
|
||||
val pdfRectF = android.graphics.RectF(
|
||||
min(outRect[0], outRect[2]),
|
||||
max(outRect[1], outRect[3]),
|
||||
|
|
@ -1180,26 +1211,26 @@ internal fun PdfPageComposable(
|
|||
val pagePtr = pageWrapper.getNativePointer()
|
||||
|
||||
if (pagePtr != 0L) {
|
||||
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
|
||||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count > 0) {
|
||||
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
|
||||
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count > 0) {
|
||||
val allAnnots = (0 until count).mapNotNull { i ->
|
||||
val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i)
|
||||
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
||||
if (subtype == annotLink) return@mapNotNull null
|
||||
|
||||
var contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents")
|
||||
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
||||
if (contents.isNullOrBlank()) {
|
||||
contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "RC")
|
||||
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
||||
}
|
||||
|
||||
val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM")
|
||||
val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT")
|
||||
val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T")
|
||||
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
||||
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
||||
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
||||
|
||||
val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i)
|
||||
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
||||
val pdfRectF = if (pdfRectArray != null) {
|
||||
android.graphics.RectF(
|
||||
min(pdfRectArray[0], pdfRectArray[2]),
|
||||
|
|
@ -1311,8 +1342,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
LaunchedEffect(
|
||||
effectiveScale,
|
||||
effectiveOffset,
|
||||
needsTilingNow,
|
||||
actualBitmapWidthPx,
|
||||
actualBitmapHeightPx,
|
||||
canvasWidthPx.floatValue,
|
||||
|
|
@ -1323,8 +1353,7 @@ internal fun PdfPageComposable(
|
|||
virtualPage,
|
||||
isActivePage
|
||||
) {
|
||||
val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
||||
if (!needsTiling) {
|
||||
if (!needsTilingNow) {
|
||||
if (tiles.isNotEmpty()) {
|
||||
val oldTiles = tiles
|
||||
tiles = emptyList()
|
||||
|
|
@ -1358,24 +1387,37 @@ internal fun PdfPageComposable(
|
|||
|
||||
snapshotFlow {
|
||||
val rect = visibleScreenRect()
|
||||
if (rect == null) null
|
||||
else {
|
||||
val observedScale = latestEffectiveScale
|
||||
if (isVerticalScroll && rect != null) {
|
||||
val qTop = rect.top / (tileSizePx / 2)
|
||||
val qLeft = rect.left / (tileSizePx / 2)
|
||||
val qBottom = rect.bottom / (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 { _ ->
|
||||
|
||||
delay(150)
|
||||
|
||||
val tileCalcStart = System.nanoTime()
|
||||
if (!isActive) return@collectLatest
|
||||
|
||||
if (isScrolling && effectiveScale > 1f) {
|
||||
return@collectLatest
|
||||
}
|
||||
val renderScale = latestEffectiveScale
|
||||
val renderOffset = latestEffectiveOffset
|
||||
|
||||
val currentVisibleRect = visibleScreenRect()
|
||||
|
||||
|
|
@ -1404,14 +1446,14 @@ internal fun PdfPageComposable(
|
|||
val pivotX = screenWidth / 2f
|
||||
val pivotY = screenHeight / 2f
|
||||
|
||||
pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||
pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
||||
pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||
pyBr = (((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
||||
pxTl = (((0 - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
|
||||
pyTl = (((0 - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
|
||||
pxBr = (((screenWidth - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
|
||||
pyBr = (((screenHeight - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
val requiredTileIds = mutableSetOf<Int>()
|
||||
|
|
@ -1431,8 +1473,10 @@ internal fun PdfPageComposable(
|
|||
|
||||
val currentTileIds = tiles.map { it.tileId }.toSet()
|
||||
|
||||
val scaleTolerance = 0.05f
|
||||
val validCurrentTileIds = tiles.filter { abs(it.renderScale - effectiveScale) <= scaleTolerance }.map { it.tileId }.toSet()
|
||||
val scaleTolerance = PDF_TILE_SCALE_TOLERANCE
|
||||
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
|
||||
if (duration > 2f) {
|
||||
|
|
@ -1441,21 +1485,26 @@ internal fun PdfPageComposable(
|
|||
)
|
||||
}
|
||||
|
||||
if (requiredTileIds != validCurrentTileIds) {
|
||||
|
||||
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
|
||||
val tilesToRecycleIds = currentTileIds - requiredTileIds
|
||||
|
||||
if (tilesToRecycleIds.isNotEmpty()) {
|
||||
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
|
||||
tiles = tilesToKeep
|
||||
withContext(Dispatchers.IO) {
|
||||
tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
||||
}
|
||||
if (tilesToRecycleIds.isNotEmpty()) {
|
||||
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
|
||||
tiles = tilesToKeep
|
||||
withContext(Dispatchers.IO) {
|
||||
tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
||||
}
|
||||
}
|
||||
|
||||
if (isScrolling && renderScale > 1f) {
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
if (requiredTileIds != validCurrentTileIds) {
|
||||
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 ->
|
||||
if (!isActive) return@forEach
|
||||
|
||||
|
|
@ -1469,14 +1518,18 @@ internal fun PdfPageComposable(
|
|||
(col + 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 fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
|
||||
val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
|
||||
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
|
||||
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
|
||||
val fullPageRenderWidth = (actualBitmapWidthPx * tileRenderScale).toInt()
|
||||
val fullPageRenderHeight = (actualBitmapHeightPx * tileRenderScale).toInt()
|
||||
val tileRenderX = (col * tileSizePx * tileRenderScale).toInt()
|
||||
val tileRenderY = (row * tileSizePx * tileRenderScale).toInt()
|
||||
|
||||
page?.renderPageBitmap(
|
||||
bitmap = tileBitmap,
|
||||
|
|
@ -1487,24 +1540,26 @@ internal fun PdfPageComposable(
|
|||
renderAnnot = true
|
||||
)
|
||||
|
||||
val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
|
||||
var handedOver = false
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
val oldTile = tiles.find { it.tileId == tileId }
|
||||
tiles = tiles.filter { it.tileId != tileId } + newTile
|
||||
handedOver = true
|
||||
newTiles += PdfTile(tileBitmap, tileRect, tileId, renderScale)
|
||||
}
|
||||
newTiles
|
||||
}
|
||||
|
||||
oldTile?.let {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
PdfBitmapPool.recycle(it.bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!handedOver) {
|
||||
PdfBitmapPool.recycle(tileBitmap)
|
||||
}
|
||||
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) {
|
||||
replacedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2799,7 +2854,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
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()
|
||||
)
|
||||
|
||||
|
|
@ -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) {
|
||||
return@withContext 2
|
||||
}
|
||||
|
|
@ -3007,25 +3062,21 @@ internal fun PdfPageComposable(
|
|||
awaitEachGesture {
|
||||
@Suppress("UnusedVariable", "Unused") val down =
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
paginationPanFlingJob?.cancel()
|
||||
paginationPanFlingJob = null
|
||||
velocityTracker.resetTracking()
|
||||
|
||||
var mode = 0
|
||||
var accumulatedZoom = 1f
|
||||
var accumulatedPan = Offset.Zero
|
||||
var swipeAccumulatorX = 0f
|
||||
var velocityAccumulator = Offset.Zero
|
||||
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
val canceled = event.changes.any { it.isConsumed }
|
||||
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) {
|
||||
val rawPanChange = event.calculatePan()
|
||||
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)")
|
||||
offset = Offset(newX, newY)
|
||||
|
||||
if (event.changes.isNotEmpty() && panChange != Offset.Zero) {
|
||||
velocityAccumulator += panChange
|
||||
velocityTracker.addPosition(
|
||||
event.changes[0].uptimeMillis,
|
||||
velocityAccumulator
|
||||
)
|
||||
}
|
||||
|
||||
event.changes.forEach {
|
||||
if (it.positionChanged()) it.consume()
|
||||
}
|
||||
|
|
@ -3189,38 +3248,54 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
} else if (mode == 1 && scale > 1f) {
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
val contentWidth = actualBitmapWidthPx * scale
|
||||
val contentHeight = actualBitmapHeightPx * scale
|
||||
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
|
||||
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
|
||||
|
||||
val startX = offset.x
|
||||
val startY = offset.y
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
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 {
|
||||
coroutineScope {
|
||||
launch {
|
||||
if (!isScrollLocked) {
|
||||
Animatable(startX).animateDecay(
|
||||
velocity.x, decay
|
||||
) {
|
||||
val newX = value.coerceIn(
|
||||
-maxOffsetX, maxOffsetX
|
||||
)
|
||||
offset = offset.copy(x = newX)
|
||||
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 {
|
||||
launch {
|
||||
if (flingX != 0f) {
|
||||
Animatable(startOffset.x).animateDecay(flingX, decay) {
|
||||
offset = offset.copy(
|
||||
x = value.coerceIn(-maxOffsetX, maxOffsetX)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
if (flingY != 0f) {
|
||||
Animatable(startOffset.y).animateDecay(flingY, decay) {
|
||||
offset = offset.copy(
|
||||
y = value.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
Animatable(startY).animateDecay(
|
||||
velocity.y, decay
|
||||
) {
|
||||
val newY = value.coerceIn(
|
||||
-maxOffsetY, maxOffsetY
|
||||
)
|
||||
offset = offset.copy(y = newY)
|
||||
}
|
||||
} finally {
|
||||
paginationPanFlingJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3256,7 +3331,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
|
@ -3881,7 +3956,10 @@ internal fun PdfPageComposable(
|
|||
stableColorFilter,
|
||||
isDarkMode,
|
||||
excludeImages,
|
||||
stableImageRects
|
||||
stableImageRects,
|
||||
textureBitmap,
|
||||
effectiveTextureAlpha,
|
||||
textureBlendMode
|
||||
) {
|
||||
Timber.tag("PdfDrawPerf").v(
|
||||
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
|
||||
|
|
@ -3899,7 +3977,10 @@ internal fun PdfPageComposable(
|
|||
colorFilter = stableColorFilter,
|
||||
isDarkMode = isDarkMode,
|
||||
excludeImages = excludeImages,
|
||||
imageRects = stableImageRects
|
||||
imageRects = stableImageRects,
|
||||
textureBitmap = StableHolder(textureBitmap),
|
||||
textureAlpha = effectiveTextureAlpha,
|
||||
textureBlendMode = textureBlendMode
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4263,7 +4344,10 @@ private fun PdfBitmapLayer(
|
|||
colorFilter: ColorFilter? = null,
|
||||
isDarkMode: 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()) {
|
||||
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,
|
||||
isDarkMode = data.isDarkMode,
|
||||
excludeImages = data.excludeImages,
|
||||
imageRects = data.imageRects.item
|
||||
imageRects = data.imageRects.item,
|
||||
textureBitmap = data.textureBitmap.item,
|
||||
textureAlpha = data.textureAlpha,
|
||||
textureBlendMode = data.textureBlendMode
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.core.content.edit
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.ReaderTheme
|
||||
import com.aryan.reader.ReaderTexture
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
|
||||
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_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
|
||||
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"
|
||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||
|
||||
|
|
@ -76,7 +79,13 @@ val PdfBuiltInThemes = listOf(
|
|||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
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> {
|
||||
|
|
@ -89,6 +98,32 @@ internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
|||
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> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return PdfHighlightColor.entries.associateWith {
|
||||
|
|
@ -139,7 +174,7 @@ internal fun loadPdfThemeId(context: Context): String {
|
|||
}
|
||||
|
||||
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)
|
||||
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -11,94 +12,346 @@ import androidx.compose.foundation.layout.WindowInsets
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.background
|
||||
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.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.material.icons.Icons
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
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.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.text.font.FontWeight
|
||||
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.epubreader.OptionSegmentedControl
|
||||
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
|
||||
fun PdfCustomizeToolsSheet(
|
||||
hiddenTools: Set<String>,
|
||||
toolOrder: List<PdfReaderTool>,
|
||||
bottomTools: Set<String>,
|
||||
onUpdate: (Set<String>) -> Unit,
|
||||
onOrderUpdate: (List<PdfReaderTool>) -> Unit,
|
||||
onPlacementUpdate: (Set<String>) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_customize_toolbar),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.desc_customize_toolbar),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
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
|
||||
)
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
|
||||
item {
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
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
|
||||
}
|
||||
items(tools) { tool ->
|
||||
Row(
|
||||
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,
|
||||
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
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_customize_toolbar),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
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()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
val newSet = hiddenTools.toMutableSet()
|
||||
if (newSet.contains(tool.name)) newSet.remove(tool.name)
|
||||
else newSet.add(tool.name)
|
||||
onUpdate(newSet)
|
||||
.then(if (isDragged) Modifier else Modifier.animateItem())
|
||||
.zIndex(zIndex)
|
||||
.graphicsLayer {
|
||||
this.translationY = translationY
|
||||
this.scaleX = scale
|
||||
this.scaleY = scale
|
||||
this.shadowElevation = elevation.toPx()
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = tool.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
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)
|
||||
when (item.type) {
|
||||
PdfFlatItemType.SECTION_HEADER -> {
|
||||
Text(
|
||||
text = item.title ?: "",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +360,154 @@ fun PdfCustomizeToolsSheet(
|
|||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfVisualOptionsSheet(
|
||||
systemUiMode: SystemUiMode,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ object PdfToHtmlGenerator {
|
|||
}
|
||||
|
||||
try {
|
||||
val doc = pdfiumCore.newDocument(pfd)
|
||||
val doc = PdfiumEngineProvider.withPdfium {
|
||||
pdfiumCore.newDocument(pfd)
|
||||
}
|
||||
val totalPages = doc.getPageCount()
|
||||
Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
|
||||
|
||||
|
|
@ -56,7 +58,9 @@ object PdfToHtmlGenerator {
|
|||
writer.write(buildGlobalHtmlFooter())
|
||||
}
|
||||
|
||||
doc.close()
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
doc.close()
|
||||
}
|
||||
pfd.close()
|
||||
Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
|
||||
return@withContext true
|
||||
|
|
@ -137,14 +141,14 @@ object PdfToHtmlGenerator {
|
|||
val textPagePtr = getNativePointer(textPage)
|
||||
|
||||
val imageElements = mutableListOf<ImageElement>()
|
||||
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
|
||||
for (i in 0 until objCount) {
|
||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) {
|
||||
val bbox = FloatArray(4)
|
||||
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
||||
val topY = bbox[3]
|
||||
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) {
|
||||
try {
|
||||
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
|
||||
|
|
@ -175,11 +179,11 @@ object PdfToHtmlGenerator {
|
|||
val flags: IntArray?
|
||||
val charBoxes: FloatArray?
|
||||
|
||||
synchronized(NativePdfiumBridge::class.java) {
|
||||
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
|
||||
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
|
||||
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
|
||||
charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
|
||||
synchronized(PdfiumEngineProvider.lock) {
|
||||
sizes = PdfiumEngineProvider.bridge.getPageFontSizes(textPagePtr, actualCount)
|
||||
weights = PdfiumEngineProvider.bridge.getPageFontWeights(textPagePtr, actualCount)
|
||||
flags = PdfiumEngineProvider.bridge.getPageFontFlags(textPagePtr, actualCount)
|
||||
charBoxes = PdfiumEngineProvider.bridge.getPageCharBoxes(textPagePtr, actualCount)
|
||||
}
|
||||
|
||||
if (sizes == null || weights == null || flags == null) {
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@ import androidx.compose.foundation.rememberScrollState
|
|||
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.Undo
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.BuildConfig
|
||||
|
|
@ -41,9 +43,23 @@ import com.aryan.reader.R
|
|||
import com.aryan.reader.SearchState
|
||||
import com.aryan.reader.SearchTopBar
|
||||
import com.aryan.reader.TooltipIconButton
|
||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
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)
|
||||
@Composable
|
||||
internal fun PdfTopBar(
|
||||
|
|
@ -60,6 +76,8 @@ internal fun PdfTopBar(
|
|||
totalPages: Int,
|
||||
pagerStatePageCount: Int,
|
||||
hiddenTools: Set<String>,
|
||||
toolOrder: List<PdfReaderTool>,
|
||||
bottomTools: Set<String>,
|
||||
isScrollLocked: Boolean,
|
||||
isEditMode: Boolean,
|
||||
displayMode: DisplayMode,
|
||||
|
|
@ -83,6 +101,16 @@ internal fun PdfTopBar(
|
|||
onShowCustomizeTools: () -> Unit,
|
||||
onShowOcrLanguage: () -> 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,
|
||||
onToggleTapToNavigate: () -> Unit,
|
||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||
|
|
@ -153,35 +181,89 @@ internal fun PdfTopBar(
|
|||
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
|
||||
)
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_theme),
|
||||
description = stringResource(R.string.tooltip_theme_desc),
|
||||
onClick = onShowThemePanel
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
toolOrder
|
||||
.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),
|
||||
description = stringResource(R.string.tooltip_theme_desc),
|
||||
onClick = onShowThemePanel
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||
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),
|
||||
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)
|
||||
}
|
||||
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(
|
||||
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 (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
|
||||
TooltipIconButton(
|
||||
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),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) {
|
||||
|
|
@ -197,14 +279,25 @@ internal fun PdfTopBar(
|
|||
|
||||
Box {
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_more_options),
|
||||
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))
|
||||
}
|
||||
|
||||
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(
|
||||
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||
|
|
@ -212,6 +305,47 @@ internal fun PdfTopBar(
|
|||
)
|
||||
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)) {
|
||||
DropdownMenuItem(
|
||||
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
|
||||
fun ReflowProgressOverlay(
|
||||
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
|
||||
fun PdfBottomBar(
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -455,14 +738,17 @@ fun PdfBottomBar(
|
|||
systemUiMode: SystemUiMode,
|
||||
navBarHeightDp: Dp,
|
||||
hiddenTools: Set<String>,
|
||||
toolOrder: List<PdfReaderTool>,
|
||||
bottomTools: Set<String>,
|
||||
isTtsPlayingOrLoading: Boolean,
|
||||
showAllTextHighlights: Boolean,
|
||||
isHighlightingLoading: Boolean,
|
||||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
ttsErrorMessage: String?,
|
||||
jumpBackPage: Int?,
|
||||
onJumpBack: () -> Unit,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
onShowToc: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
|
|
@ -490,106 +776,91 @@ fun PdfBottomBar(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (jumpBackPage != null) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.action_jump_back_to_page, jumpBackPage + 1),
|
||||
description = stringResource(R.string.desc_return_to_previous_page),
|
||||
onClick = onJumpBack
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
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
|
||||
)
|
||||
toolOrder
|
||||
.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),
|
||||
description = stringResource(R.string.tooltip_theme_desc),
|
||||
onClick = onShowThemePanel
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_lock_pan),
|
||||
description = stringResource(R.string.tooltip_lock_pan_desc),
|
||||
onClick = onToggleScrollLock
|
||||
) {
|
||||
Icon(Icons.Default.LockOpen, contentDescription = stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
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(
|
||||
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,
|
||||
modifier = Modifier.testTag("TocButton")
|
||||
) {
|
||||
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,
|
||||
modifier = Modifier.testTag("SearchButton")
|
||||
) {
|
||||
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 (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_toc),
|
||||
description = stringResource(R.string.tooltip_toc_desc),
|
||||
onClick = onShowToc,
|
||||
enabled = !isTtsPlayingOrLoading,
|
||||
modifier = Modifier.testTag("TocButton")
|
||||
) {
|
||||
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_search),
|
||||
description = stringResource(R.string.tooltip_search_desc),
|
||||
onClick = onSearchClick,
|
||||
enabled = !isTtsPlayingOrLoading,
|
||||
modifier = Modifier.testTag("SearchButton")
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||
}
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss") {
|
||||
TooltipIconButton(
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ internal fun PdfVerticalReader(
|
|||
state: VerticalPdfReaderState,
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
activeTheme: com.aryan.reader.ReaderTheme,
|
||||
activeTextureAlpha: Float = 0.55f,
|
||||
excludeImages: Boolean = false,
|
||||
totalPages: Int,
|
||||
virtualPages: List<VirtualPage> = emptyList(),
|
||||
|
|
@ -988,7 +989,7 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
|
@ -1688,6 +1689,7 @@ internal fun PdfVerticalReader(
|
|||
virtualPage = virtualPage,
|
||||
totalPages = totalPages,
|
||||
activeTheme = activeTheme,
|
||||
activeTextureAlpha = activeTextureAlpha,
|
||||
excludeImages = excludeImages,
|
||||
externalScale = highResScale,
|
||||
onScaleChanged = {},
|
||||
|
|
|
|||
120
app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import me.zhanghai.android.libarchive.ArchiveException
|
|||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.zip.ZipFile
|
||||
import androidx.core.graphics.createBitmap
|
||||
|
||||
|
|
@ -88,42 +89,97 @@ object DocumentFactory {
|
|||
ArchiveDocumentWrapper(cacheFile)
|
||||
} else {
|
||||
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 =================
|
||||
|
||||
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 {
|
||||
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? {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
override suspend fun getPageWidthPoint() = pdfPage.getPageWidthPoint()
|
||||
override suspend fun getPageHeightPoint() = pdfPage.getPageHeightPoint()
|
||||
override suspend fun getPageRotation() = pdfPage.getPageRotation()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
|
||||
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) {
|
||||
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
if (!isClosed.get()) {
|
||||
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) =
|
||||
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> {
|
||||
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 {
|
||||
|
|
@ -159,29 +215,80 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
|||
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 {
|
||||
override suspend fun textPageCountChars() = textPage.textPageCountChars()
|
||||
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 textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
||||
override suspend fun textPageGetCharBox(index: Int) = textPage.textPageGetCharBox(index)
|
||||
override suspend fun textPageGetUnicode(index: Int): Int {
|
||||
return textPage.textPageGetUnicode(index).code
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
|
||||
override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else textPage.textPageCountChars()
|
||||
}
|
||||
override suspend fun loadWebLink(): ReaderWebLinks? {
|
||||
val links = textPage.loadWebLink() ?: return null
|
||||
return object : ReaderWebLinks {
|
||||
override suspend fun countWebLinks() = links.countWebLinks()
|
||||
override suspend fun getURL(linkIndex: Int, maxLength: Int) = links.getURL(linkIndex, maxLength)
|
||||
override suspend fun countRects(linkIndex: Int) = links.countRects(linkIndex)
|
||||
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = links.getRect(linkIndex, rectIndex)
|
||||
override fun close() { links.close() }
|
||||
|
||||
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 {
|
||||
return PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadWebLink(): ReaderWebLinks? {
|
||||
val links = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.loadWebLink()
|
||||
} ?: return null
|
||||
return object : ReaderWebLinks {
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
|
||||
override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else links.countWebLinks()
|
||||
}
|
||||
|
||||
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 =================
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import androidx.paging.PagingConfig
|
|||
import androidx.paging.PagingData
|
||||
import androidx.paging.flatMap
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.pdf.PdfiumEngineProvider
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
|
|
@ -171,13 +172,15 @@ class PdfTextRepository(context: Context) {
|
|||
var ocrUsed = false
|
||||
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val count = textPage.textPageCountChars()
|
||||
if (count > 0) {
|
||||
val nativeText = textPage.textPageGetText(0, count)
|
||||
if (!nativeText.isNullOrBlank()) {
|
||||
text = nativeText
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val count = textPage.textPageCountChars()
|
||||
if (count > 0) {
|
||||
val nativeText = textPage.textPageGetText(0, count)
|
||||
if (!nativeText.isNullOrBlank()) {
|
||||
text = nativeText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -187,26 +190,35 @@ class PdfTextRepository(context: Context) {
|
|||
}
|
||||
|
||||
if (text.isBlank()) {
|
||||
var bitmap: android.graphics.Bitmap? = null
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val targetWidth = 1080
|
||||
val ptrWidth = page.getPageWidthPoint()
|
||||
val ptrHeight = page.getPageHeightPoint()
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val targetWidth = 1080
|
||||
val ptrWidth = page.getPageWidthPoint()
|
||||
val ptrHeight = page.getPageHeightPoint()
|
||||
|
||||
if (ptrWidth > 0 && ptrHeight > 0) {
|
||||
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
||||
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
if (ptrWidth > 0 && ptrHeight > 0) {
|
||||
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
||||
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
|
||||
val bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
|
||||
|
||||
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onOcrModelDownloading)
|
||||
bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bitmap?.let {
|
||||
try {
|
||||
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
|
||||
text = visionText?.text ?: ""
|
||||
bitmap.recycle()
|
||||
ocrUsed = true
|
||||
} finally {
|
||||
it.recycle()
|
||||
bitmap = null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
bitmap?.recycle()
|
||||
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 {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
textPage.textPageCountChars() > 0
|
||||
}
|
||||
} ?: false
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
textPage.textPageCountChars() > 0
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import java.io.File
|
|||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
|
@ -247,11 +248,16 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
|
||||
try {
|
||||
withTimeout(startTimeout) {
|
||||
startSignal.await()
|
||||
select {
|
||||
startSignal.onAwait { }
|
||||
resultDeferred.onAwait { }
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.")
|
||||
throw ZombieEngineException()
|
||||
Timber.w(
|
||||
"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 {
|
||||
|
|
@ -296,5 +302,4 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
Timber.d("TextToSpeech engine shut down.")
|
||||
}
|
||||
|
||||
private class ZombieEngineException : Exception("Engine failed to start")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import androidx.media3.session.SessionToken
|
|||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.epubreader.loadTtsPitch
|
||||
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
||||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
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)
|
||||
?: TtsPlaybackManager.TtsMode.BASE.name
|
||||
|
||||
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank()
|
||||
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
|
||||
|
||||
return if (isCloudAllowed) {
|
||||
try {
|
||||
|
|
@ -105,8 +106,14 @@ class TtsController(context: Context) : Player.Listener {
|
|||
}
|
||||
|
||||
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 future = MediaController.Builder(context, sessionToken).buildAsync()
|
||||
controllerFuture = future
|
||||
|
|
@ -129,10 +136,14 @@ class TtsController(context: Context) : Player.Listener {
|
|||
|
||||
mediaController?.addListener(this)
|
||||
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()
|
||||
startPolling()
|
||||
} catch (e: Exception) {
|
||||
Timber.w("Failed to connect MediaController: ${e.message}")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "MediaController connection failed.")
|
||||
if (controllerFuture == future) {
|
||||
controllerFuture = null
|
||||
}
|
||||
|
|
@ -158,15 +169,21 @@ class TtsController(context: Context) : Player.Listener {
|
|||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
chapterIndex: Int? = null,
|
||||
totalChapters: Int? = null,
|
||||
continueSession: Boolean = false,
|
||||
ttsMode: TtsPlaybackManager.TtsMode,
|
||||
playbackSource: String = "READER",
|
||||
authToken: String? = null
|
||||
) {
|
||||
if (chunks.isEmpty()) {
|
||||
Timber.w("TtsController: start called with empty chunks!")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
|
||||
return
|
||||
}
|
||||
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 cfiList = ArrayList(chunks.map { it.sourceCfi })
|
||||
|
|
@ -181,6 +198,8 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||
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_PLAYBACK_SOURCE, playbackSource)
|
||||
putString(KEY_AUTH_TOKEN, authToken)
|
||||
|
|
@ -188,7 +207,15 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putFloat("playback_pitch", loadTtsPitch(context))
|
||||
}
|
||||
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() {
|
||||
|
|
@ -240,6 +267,9 @@ class TtsController(context: Context) : Player.Listener {
|
|||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +277,9 @@ class TtsController(context: Context) : Player.Listener {
|
|||
mediaController?.let { controller ->
|
||||
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
|
||||
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 serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
|
||||
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
|
||||
|
|
@ -255,9 +287,13 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val sessionFinished = customState.getBoolean("sessionFinished", false)
|
||||
val playbackSource = customState.getString("playbackSource")
|
||||
val serviceBookTitle = customState.getString("bookTitle")
|
||||
val serviceChapterTitle = customState.getString("chapterTitle")
|
||||
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 startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
|
||||
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
|
||||
|
|
@ -279,11 +315,24 @@ class TtsController(context: Context) : Player.Listener {
|
|||
} else {
|
||||
if (isLoading) currentState.bookTitle else serviceBookTitle
|
||||
},
|
||||
chapterTitle = if (isPlaybackActive || isLoading) {
|
||||
serviceChapterTitle ?: currentState.chapterTitle
|
||||
} else {
|
||||
serviceChapterTitle
|
||||
},
|
||||
chapterIndex = if (isPlaybackActive || isLoading) {
|
||||
serviceChapterIndex ?: currentState.chapterIndex
|
||||
} else {
|
||||
serviceChapterIndex
|
||||
},
|
||||
totalChapters = if (isPlaybackActive || isLoading) {
|
||||
serviceTotalChapters ?: currentState.totalChapters
|
||||
} else {
|
||||
serviceTotalChapters
|
||||
},
|
||||
currentChunkIndex = serviceCurrentChunkIndex,
|
||||
totalChunks = serviceTotalChunks,
|
||||
bookProgressPercent = serviceBookProgressPercent,
|
||||
speakerId = serviceSpeaker,
|
||||
sourceCfi = if (isPlaybackActive) {
|
||||
sourceCfi
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import androidx.core.net.toUri
|
|||
import com.aryan.reader.paginatedreader.TimedWord
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", 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 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)
|
||||
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
|
||||
|
||||
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
||||
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_AUTH_TOKEN = "KEY_AUTH_TOKEN"
|
||||
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
|
||||
|
||||
|
|
@ -102,7 +106,12 @@ class TtsPlaybackManager(
|
|||
val currentText: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val bookTitle: String? = null,
|
||||
val chapterTitle: String? = 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 sourceCfi: String? = null,
|
||||
val startOffsetInSource: Int = -1,
|
||||
|
|
@ -124,6 +133,8 @@ class TtsPlaybackManager(
|
|||
private var chapterTitle: String? = null
|
||||
private var coverImageUri: String? = null
|
||||
private var currentTtsMode = TtsMode.CLOUD
|
||||
private var chapterIndex: Int? = null
|
||||
private var totalChapters: Int? = null
|
||||
|
||||
init {
|
||||
player.addListener(this)
|
||||
|
|
@ -146,6 +157,9 @@ class TtsPlaybackManager(
|
|||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): 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()
|
||||
.add(START_TTS_COMMAND)
|
||||
.add(STOP_TTS_COMMAND)
|
||||
|
|
@ -186,6 +200,9 @@ class TtsPlaybackManager(
|
|||
START_TTS_COMMAND -> {
|
||||
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
|
||||
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 offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
|
||||
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
|
||||
|
|
@ -193,6 +210,7 @@ class TtsPlaybackManager(
|
|||
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
|
||||
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
|
||||
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 playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
||||
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)
|
||||
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 -> {
|
||||
Timber.d("Received STOP command.")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("STOP command received.")
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
CHANGE_SPEAKER_COMMAND -> {
|
||||
|
|
@ -342,17 +361,20 @@ class TtsPlaybackManager(
|
|||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
chapterIndex: Int?,
|
||||
totalChapters: Int?,
|
||||
ttsMode: TtsMode,
|
||||
playbackSource: String?,
|
||||
args: Bundle // Added this parameter
|
||||
) {
|
||||
if (chunks.isEmpty()) {
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// --- YOUR SNIPPET START ---
|
||||
val authToken = args.getString(KEY_AUTH_TOKEN)
|
||||
val continueSession = args.getBoolean(KEY_CONTINUE_SESSION, false)
|
||||
val speed = args.getFloat("playback_speed", 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_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
|
||||
currentSpeakerId = speakerId
|
||||
currentTtsMode = ttsMode
|
||||
this.bookTitle = bookTitle
|
||||
this.chapterTitle = chapterTitle
|
||||
this.coverImageUri = coverImageUri
|
||||
this.chapterIndex = chapterIndex
|
||||
this.totalChapters = totalChapters
|
||||
|
||||
onResetContext()
|
||||
loadedChunks.clear()
|
||||
lastPrefetchIndex = -1
|
||||
|
||||
_ttsState.value = TtsState(
|
||||
isLoading = true,
|
||||
bookTitle = bookTitle,
|
||||
chapterTitle = chapterTitle,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = totalChapters,
|
||||
currentChunkIndex = -1,
|
||||
totalChunks = chunks.size,
|
||||
bookProgressPercent = calculateBookProgressPercent(-1),
|
||||
speakerId = speakerId,
|
||||
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
|
||||
preparationJob = scope.launch {
|
||||
|
|
@ -411,6 +460,73 @@ class TtsPlaybackManager(
|
|||
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) {
|
||||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||
if (firstChunk == null) {
|
||||
|
|
@ -420,6 +536,9 @@ class TtsPlaybackManager(
|
|||
|
||||
val chunkStartTime = System.currentTimeMillis()
|
||||
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)
|
||||
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
||||
|
|
@ -464,17 +583,30 @@ class TtsPlaybackManager(
|
|||
}
|
||||
player.playWhenReady = playWhenReady
|
||||
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(
|
||||
isLoading = false,
|
||||
isPlaying = playWhenReady,
|
||||
currentText = serverText,
|
||||
chapterTitle = chapterTitle,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = totalChapters,
|
||||
currentChunkIndex = startAtIndex,
|
||||
totalChunks = textChunks.size,
|
||||
bookProgressPercent = calculateBookProgressPercent(startAtIndex),
|
||||
sessionFinished = false,
|
||||
sourceCfi = updatedChunk.sourceCfi,
|
||||
startOffsetInSource = updatedChunk.startOffsetInSource
|
||||
)
|
||||
}
|
||||
prefetchNextChunkAudio(startAtIndex)
|
||||
} 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) {
|
||||
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()
|
||||
preparationJob?.cancel()
|
||||
wordTrackingJob?.cancel()
|
||||
|
|
@ -527,6 +662,11 @@ class TtsPlaybackManager(
|
|||
player.stop()
|
||||
player.clearMediaItems()
|
||||
textChunks = emptyList()
|
||||
bookTitle = null
|
||||
chapterTitle = null
|
||||
coverImageUri = null
|
||||
chapterIndex = null
|
||||
totalChapters = null
|
||||
lastPrefetchIndex = -1
|
||||
prefetchLoopJob?.cancel()
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
|
|
@ -541,17 +681,27 @@ class TtsPlaybackManager(
|
|||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
val newPlaylistIndex = player.currentMediaItemIndex
|
||||
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
|
||||
|
||||
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
|
||||
|
||||
val newText = mediaItem.mediaMetadata.subtitle?.toString()
|
||||
val extras = mediaItem.mediaMetadata.extras
|
||||
val newText = extras?.getString("ttsText") ?: mediaItem.mediaMetadata.subtitle?.toString()
|
||||
val sourceCfi = extras?.getString("sourceCfi")
|
||||
val startOffset = extras?.getInt("startOffset", -1) ?: -1
|
||||
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
currentText = newText,
|
||||
chapterTitle = chapterTitle,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = totalChapters,
|
||||
currentChunkIndex = currentChunkIndex,
|
||||
totalChunks = textChunks.size,
|
||||
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
|
||||
sessionFinished = false,
|
||||
sourceCfi = sourceCfi,
|
||||
startOffsetInSource = startOffset
|
||||
)
|
||||
|
|
@ -582,6 +732,9 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
if (isPlaying) {
|
||||
|
|
@ -599,14 +752,22 @@ class TtsPlaybackManager(
|
|||
currentWordStartOffset = -1
|
||||
)
|
||||
|
||||
val currentChunkIndex = player.currentMediaItemIndex
|
||||
val currentChunkIndex = currentChunkIndexFromPlayer()
|
||||
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
|
||||
|
||||
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_NOTIFICATION_DIAG_TAG).i(
|
||||
"Player reached ENDED. currentChunkIndex=$currentChunkIndex, isLastChunk=$isLastChunkInSession, totalChunks=${textChunks.size}, sessionFinishedWillBeSet=${isLastChunkInSession || textChunks.isEmpty()}"
|
||||
)
|
||||
if (isLastChunkInSession || textChunks.isEmpty()) {
|
||||
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 {
|
||||
val nextIdx = currentChunkIndex + 1
|
||||
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
|
||||
|
|
@ -625,6 +786,7 @@ class TtsPlaybackManager(
|
|||
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
|
||||
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_NOTIFICATION_DIAG_TAG).w("Auto-stopping from IDLE/not-loading path.")
|
||||
handleStopTts(userInitiated = true)
|
||||
} else {
|
||||
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) {
|
||||
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}")
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
|
|
@ -719,10 +882,13 @@ class TtsPlaybackManager(
|
|||
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.play()
|
||||
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) {
|
||||
} else if (wasLoading && isImmediateNextChunk) {
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
|
|
@ -768,7 +934,10 @@ class TtsPlaybackManager(
|
|||
if (player.hasNextMediaItem()) {
|
||||
player.seekToNextMediaItem()
|
||||
} 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 {
|
||||
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 {
|
||||
putString("ttsText", text)
|
||||
putString("sourceCfi", chunk.sourceCfi)
|
||||
putInt("startOffset", chunk.startOffsetInSource)
|
||||
if (chunk.timedWords.isNotEmpty()) {
|
||||
|
|
@ -823,9 +1022,11 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setArtist(bookTitle)
|
||||
.setTitle(chapterTitle)
|
||||
.setSubtitle(text)
|
||||
.setTitle(bookTitle ?: chapterLabel)
|
||||
.setDisplayTitle(bookTitle ?: chapterLabel)
|
||||
.setArtist(chapterLabel)
|
||||
.setSubtitle(chunkPreview)
|
||||
.setDescription(chunkPreview)
|
||||
.setArtworkUri(coverImageUri?.toUri())
|
||||
.setTrackNumber(index + 1)
|
||||
.setTotalTrackCount(textChunks.size)
|
||||
|
|
@ -865,7 +1066,12 @@ class TtsPlaybackManager(
|
|||
putBoolean("isLoading", state.isLoading)
|
||||
putString("errorMessage", state.errorMessage)
|
||||
putString("bookTitle", state.bookTitle)
|
||||
putString("chapterTitle", state.chapterTitle)
|
||||
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)
|
||||
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
||||
putString("currentWordSourceCfi", state.currentWordSourceCfi)
|
||||
|
|
@ -905,5 +1111,8 @@ class TtsPlaybackManager(
|
|||
else -> "UNKNOWN"
|
||||
}
|
||||
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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import androidx.media3.common.util.UnstableApi
|
|||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -236,16 +239,31 @@ class TtsService : MediaSessionService() {
|
|||
private lateinit var cacheManager: TtsCacheManager
|
||||
|
||||
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 &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
if (startInForegroundRequired) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
|
||||
stopSelf()
|
||||
} else {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +297,12 @@ class TtsService : MediaSessionService() {
|
|||
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 (connectedSpeaker == speaker) {
|
||||
val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
|
||||
|
|
@ -290,11 +313,15 @@ class TtsService : MediaSessionService() {
|
|||
webSocket = null
|
||||
}
|
||||
|
||||
val sanitizedUrl = serverUrl.removeSuffix("/")
|
||||
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
|
||||
val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
|
||||
val url = if (!directGeminiApiKey.isNullOrBlank()) {
|
||||
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$directGeminiApiKey"
|
||||
} else {
|
||||
val sanitizedUrl = serverUrl.removeSuffix("/")
|
||||
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
|
||||
"$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 connectedDeferred = CompletableDeferred<Boolean>()
|
||||
|
||||
|
|
@ -316,7 +343,7 @@ class TtsService : MediaSessionService() {
|
|||
|
||||
val setupMsg = 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("parts", org.json.JSONArray().apply {
|
||||
put(JSONObject().apply {
|
||||
|
|
@ -552,8 +579,17 @@ class TtsService : MediaSessionService() {
|
|||
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
|
||||
} else {
|
||||
try {
|
||||
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken)
|
||||
liveClient.generateChunk(text, cachedFile)
|
||||
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)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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")
|
||||
|
|
@ -567,6 +603,11 @@ class TtsService : MediaSessionService() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
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)
|
||||
|
||||
|
|
@ -622,6 +663,7 @@ class TtsService : MediaSessionService() {
|
|||
.setHandleAudioBecomingNoisy(true)
|
||||
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
|
||||
.build()
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
|
||||
|
||||
playbackManager = TtsPlaybackManager(
|
||||
player = player,
|
||||
|
|
@ -634,21 +676,30 @@ class TtsService : MediaSessionService() {
|
|||
.build()
|
||||
|
||||
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?) {
|
||||
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) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
|
||||
stopSelf()
|
||||
}
|
||||
Timber.d("onTaskRemoved called, stopping service.")
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onGetSession. package=${controllerInfo.packageName}, sessionAvailable=${mediaSession != null}"
|
||||
)
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Timber.d("TtsService is being destroyed.")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
|
||||
baseTtsSynthesizer.shutdown()
|
||||
playbackManager.release()
|
||||
mediaSession?.run {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@
|
|||
<!-- "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_custom_fonts">Custom Fonts</string>
|
||||
<string name="drawer_support_project">Support the Project</string>
|
||||
<string name="drawer_help_feedback">Help & Feedback</string>
|
||||
<string name="drawer_sign_out">Sign Out</string>
|
||||
|
||||
|
|
@ -317,6 +318,15 @@
|
|||
<string name="email_support">Email Support</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 -->
|
||||
<!-- "Episteme Pro" is the product tier name — do not translate "Episteme". -->
|
||||
<string name="dialog_unlock_pro">Unlock Episteme Pro</string>
|
||||
|
|
@ -573,10 +583,14 @@
|
|||
<!-- Common.kt: ReaderThemePanel & ThemeBuilderView -->
|
||||
<string name="reading_themes">Reading Themes</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_textured_themes">My Textured Themes</string>
|
||||
<!-- "+" 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_textured_custom">No custom textured themes yet.</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_name">Theme Name</string>
|
||||
<!-- 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_page_color">Page 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>
|
||||
<!-- Short phrase shown in the live color preview area of the theme builder. -->
|
||||
<string name="theme_color_preview_text">Reading is dreaming.</string>
|
||||
|
|
@ -808,7 +826,8 @@
|
|||
<string name="visual_options_system_ui">System UI (Status & Navigation 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_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. -->
|
||||
<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>
|
||||
|
|
@ -871,6 +890,7 @@
|
|||
<string name="label_paragraph_gap">Paragraph Gap</string>
|
||||
<string name="label_image_size">Image Size</string>
|
||||
<string name="label_horizontal_margin">Horizontal Margin</string>
|
||||
<string name="label_vertical_margin">Vertical Margin</string>
|
||||
<string name="label_none">None</string>
|
||||
<!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. -->
|
||||
<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="content_desc_exit_slider_navigation">Exit slider navigation</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_annotated_page">Annotated Page</string>
|
||||
<string name="content_desc_close_image">Close Image</string>
|
||||
|
|
@ -1261,9 +1282,9 @@
|
|||
<string name="label_short">Short</string>
|
||||
<string name="label_long">Long</string>
|
||||
<!-- 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. -->
|
||||
<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_reset_speed">Reset Speed</string>
|
||||
<string name="content_desc_reset_pitch">Reset Pitch</string>
|
||||
|
|
|
|||
28
app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
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.multiplatform) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.compose.multiplatform) apply false
|
||||
}
|
||||
55
desktopApp/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
2429
desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt
Normal file
|
|
@ -21,7 +21,7 @@ androidxAnnotationJvm = "1.9.1"
|
|||
androidxTestRunner = "1.6.2"
|
||||
material3WindowSizeClassAndroid = "1.3.2"
|
||||
credentials = "1.5.0"
|
||||
|
||||
composeMultiplatform = "1.8.2"
|
||||
|
||||
[libraries]
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
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-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" }
|
||||
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||
|
||||
# Add plugins required by pdfiumandroid
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ dependencyResolutionManagement {
|
|||
google()
|
||||
mavenCentral()
|
||||
maven("https://jitpack.io")
|
||||
maven("https://jogamp.org/deployment/maven")
|
||||
maven("https.jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Reader"
|
||||
include(":app")
|
||||
include(":shared")
|
||||
include(":desktopApp")
|
||||
|
|
|
|||
49
shared/build.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
actual fun currentTimestamp(): Long = System.currentTimeMillis()
|
||||
|
|
@ -21,7 +21,6 @@ package com.aryan.reader.paginatedreader
|
|||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.ParagraphStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
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.em
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
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 {
|
||||
if (!this.isSpecified) return 0f
|
||||
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 {
|
||||
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
|
||||
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
|
||||
private val ID_SELECTOR_PATTERN = Pattern.compile("#[^\\s,]+")
|
||||
private val CLASS_ATTRIBUTE_SELECTOR_PATTERN = Pattern.compile("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
|
||||
private val TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN = Pattern.compile("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+")
|
||||
private val ID_SELECTOR_REGEX = Regex("#[^\\s,]+")
|
||||
private val CLASS_ATTRIBUTE_SELECTOR_REGEX = Regex("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
|
||||
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?)
|
||||
|
||||
// Regex to identify simple, single-part selectors for fast categorization
|
||||
|
|
@ -65,7 +86,7 @@ object CssParser {
|
|||
"thick" to 5.dp
|
||||
)
|
||||
|
||||
internal fun adaptColorForTheme(
|
||||
fun adaptColorForTheme(
|
||||
color: Color,
|
||||
isDarkTheme: Boolean,
|
||||
isBackground: Boolean,
|
||||
|
|
@ -113,16 +134,7 @@ object CssParser {
|
|||
return color
|
||||
}
|
||||
|
||||
val hsl = FloatArray(3)
|
||||
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))
|
||||
return themeText.takeIf { it.isSpecified } ?: color
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +155,7 @@ object CssParser {
|
|||
reassembled = true
|
||||
}
|
||||
if (reassembled) {
|
||||
Timber.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
|
||||
ReaderCssLog.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
|
||||
}
|
||||
result.add(current)
|
||||
}
|
||||
|
|
@ -151,21 +163,9 @@ object CssParser {
|
|||
}
|
||||
|
||||
private fun calculateSpecificity(selector: String): Int {
|
||||
val ids = ID_SELECTOR_PATTERN.matcher(selector).run {
|
||||
var count = 0
|
||||
while (find()) 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 ids = ID_SELECTOR_REGEX.findAll(selector).count()
|
||||
val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_REGEX.findAll(selector).count()
|
||||
val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_REGEX.findAll(selector).count()
|
||||
val specificity = ids * 100 + classesAndAttributes * 10 + elementsAndPseudos
|
||||
return specificity
|
||||
}
|
||||
|
|
@ -200,13 +200,13 @@ object CssParser {
|
|||
}
|
||||
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)
|
||||
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 ->
|
||||
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]
|
||||
parseFontFace(properties, cssPath)?.let { fontFaces.add(it) }
|
||||
}
|
||||
|
|
@ -269,10 +269,10 @@ object CssParser {
|
|||
|
||||
val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase()
|
||||
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) {
|
||||
Timber.w("Incomplete @font-face rule: missing font-family or src.")
|
||||
ReaderCssLog.w("Incomplete @font-face rule: missing font-family or src.")
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -280,25 +280,25 @@ object CssParser {
|
|||
|
||||
val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part ->
|
||||
val trimmedPart = part.trim()
|
||||
Timber.d("Processing src part: '$trimmedPart'")
|
||||
ReaderCssLog.d("Processing src part: '$trimmedPart'")
|
||||
|
||||
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("'"))
|
||||
} ?: URL_REGEX.find(trimmedPart)?.let {
|
||||
val url = it.groupValues[2]
|
||||
Timber.d("Matched url() only. URL: '$url'")
|
||||
ReaderCssLog.d("Matched url() only. URL: '$url'")
|
||||
val format = when {
|
||||
url.startsWith("data:", ignoreCase = true) -> {
|
||||
val mediaType = url.substringAfter("data:").substringBefore(';')
|
||||
Timber.d("Data URI detected. Media type: '$mediaType'")
|
||||
ReaderCssLog.d("Data URI detected. Media type: '$mediaType'")
|
||||
when {
|
||||
mediaType.contains("opentype") -> "opentype"
|
||||
mediaType.contains("truetype") -> "truetype"
|
||||
mediaType.contains("woff2") -> "woff2"
|
||||
mediaType.contains("woff") -> "woff"
|
||||
else -> {
|
||||
Timber.w("Unknown data URI media type: $mediaType")
|
||||
ReaderCssLog.w("Unknown data URI media type: $mediaType")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -308,11 +308,11 @@ object CssParser {
|
|||
url.endsWith(".otf", ignoreCase = true) -> "opentype"
|
||||
url.endsWith(".ttf", ignoreCase = true) -> "truetype"
|
||||
else -> {
|
||||
Timber.w("Could not determine format from URL: $url")
|
||||
ReaderCssLog.w("Could not determine format from URL: $url")
|
||||
null
|
||||
}
|
||||
}
|
||||
Timber.d("Determined format: '$format'")
|
||||
ReaderCssLog.d("Determined format: '$format'")
|
||||
if (format != null) {
|
||||
FontSource(url = url, format = format)
|
||||
} else {
|
||||
|
|
@ -322,7 +322,7 @@ object CssParser {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -337,14 +337,13 @@ object CssParser {
|
|||
}!!
|
||||
|
||||
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:")) {
|
||||
try {
|
||||
val cssParentDir = File(cssPath).parent ?: ""
|
||||
File(cssParentDir, rawSrc).normalize().path
|
||||
resolveCssRelativePath(cssPath, rawSrc)
|
||||
} 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
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1038,10 +1037,20 @@ object CssParser {
|
|||
val r = (colorLong and 0xF00) shr 8
|
||||
val g = (colorLong and 0x0F0) shr 4
|
||||
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
|
||||
8 -> Color(Color(colorLong).toArgb()) // #AARRGGBB
|
||||
6 -> Color(
|
||||
((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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||