diff --git a/.gitignore b/.gitignore index 1984a25..31a2c96 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ third_party/pdfium/ kcef-bundle/ kcef-bundle-linux-x64/ cache/ -worker/ \ No newline at end of file +worker/ +output/ \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 239f10f..0606d47 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,8 +57,8 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 52 - versionName = "1.0.48" + versionCode = 53 + versionName = "1.0.49" resourceConfigurations += configuredAppLocaleTags() .map { it.toAndroidResourceConfiguration() } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 0135e2c..b403b8d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -67,6 +67,24 @@ -keep class com.aryan.reader.paginatedreader.Woff2Converter { *; } +# R8 can fold the large EPUB Scaffold content lambda into ChapterWebViewKt with +# hundreds of captured Compose state parameters. ART rejects that release-only +# synthetic method when opening EPUBs, so keep these file facades out of those +# optimizer passes while leaving the rest of the app minified. +-keep class com.aryan.reader.epubreader.EpubReaderScreenKt { *; } +-keep class com.aryan.reader.epubreader.EpubReaderScreenKt$* { *; } +-keep class com.aryan.reader.epubreader.ChapterWebViewKt { *; } +-keep class com.aryan.reader.epubreader.ChapterWebViewKt$* { *; } + +# The PDF reader is another very large Compose surface. Keeping its file facade +# out of release optimizer folding avoids ART's compiler instruction-limit path +# and preserves the private pdfium wrapper fields read for native pointer access. +-keep class com.aryan.reader.pdf.PdfViewerScreenKt { *; } +-keep class com.aryan.reader.pdf.PdfViewerScreenKt$* { *; } +-keep class com.aryan.reader.pdf.PdfPageComposableKt { *; } +-keep class com.aryan.reader.pdf.PdfPageComposableKt$* { *; } +-keep class io.legere.pdfiumandroid.** { *; } + -dontwarn com.gemalto.jp2.** # Flexmark Markdown parser rules @@ -87,4 +105,4 @@ # Preserve ONNX Runtime Java classes -keep class ai.onnxruntime.** { *; } -keepnames class ai.onnxruntime.** { *; } --keepclassmembers class ai.onnxruntime.** { *; } \ No newline at end of file +-keepclassmembers class ai.onnxruntime.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb03ebf..4432b4a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -239,6 +239,15 @@ + + + + = elementStartIndex && childNodeIndex < elementStartIndex + elementCount) { + return { + chunkElement: chunkElement, + chunkIndex: getReaderChunkIndex(chunkElement), + indexInChunk: childNodeIndex - elementStartIndex + }; + } + } + + const fallbackChunkIndex = Math.floor(childNodeIndex / 20); + const fallbackChunkElement = container.querySelector(`.chunk-container[data-chunk-index="${fallbackChunkIndex}"]`); + if (!fallbackChunkElement) return null; + return { + chunkElement: fallbackChunkElement, + chunkIndex: fallbackChunkIndex, + indexInChunk: childNodeIndex % 20 + }; + } + function resolveCfiPath(rootElement, path, requestChunkIfMissing = false) { let currentNode = rootElement; const steps = path.substring(1).split("/").map(Number); @@ -2054,10 +2104,12 @@ // Handle virtualized content container specially if (currentNode.id === 'content-container') { const childNodeIndex = (cfiIndex - 2) / 2; - let chunkIndex = Math.floor(childNodeIndex / 20); - let indexInChunk = childNodeIndex % 20; + const chunkLookup = findReaderChunkForElementIndex(currentNode, childNodeIndex); + if (!chunkLookup) return null; - let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`); + let chunkIndex = chunkLookup.chunkIndex; + let indexInChunk = chunkLookup.indexInChunk; + let chunkElement = chunkLookup.chunkElement; if (chunkElement) { if (chunkElement.innerHTML === "") { if (window.virtualization && window.virtualization.chunksData[chunkIndex]) { @@ -2179,8 +2231,8 @@ continue; } - let chunkIndex = parseInt(parentNode.dataset.chunkIndex, 10); - let elementsInPrecedingChunks = chunkIndex * 20; + let chunkIndex = getReaderChunkIndex(parentNode); + let elementsInPrecedingChunks = getReaderChunkElementStartIndex(parentNode); let trueIndex = elementsInPrecedingChunks + indexInChunk; let cfiIndex = trueIndex * 2 + 2; @@ -2295,6 +2347,119 @@ console.log(TAG_BM + ": " + msg); } + function normalizeReaderImageSourceForMatch(value) { + if (!value) return ""; + var normalized = String(value).split("#")[0].split("?")[0].replace(/\\/g, "/"); + try { + normalized = decodeURIComponent(normalized); + } catch (e) {} + if (normalized.indexOf("file://") === 0) { + normalized = normalized.substring("file://".length); + } + return normalized.toLowerCase(); + } + + function getReaderImageSourceCandidates(element) { + if (!element) return []; + var values = [ + element.currentSrc, + element.src, + element.href && element.href.baseVal, + element.getAttribute && element.getAttribute("src"), + element.getAttribute && element.getAttribute("href"), + element.getAttribute && element.getAttribute("xlink:href"), + element.getAttribute && element.getAttribute("data-src"), + ]; + return values.filter(function (value, index, array) { + return value && array.indexOf(value) === index; + }); + } + + function readerImageCandidateMatches(candidate, normalizedTargets) { + var normalizedCandidate = normalizeReaderImageSourceForMatch(candidate); + if (!normalizedCandidate) return false; + var candidateName = normalizedCandidate.substring(normalizedCandidate.lastIndexOf("/") + 1); + + return normalizedTargets.some(function (target) { + if (!target) return false; + var targetName = target.substring(target.lastIndexOf("/") + 1); + return ( + normalizedCandidate === target || + normalizedCandidate.endsWith("/" + targetName) || + target.endsWith("/" + candidateName) || + (candidateName && targetName && candidateName === targetName) + ); + }); + } + + function findReaderImageElementsBySource(source, originalSource) { + var normalizedTargets = [source, originalSource] + .map(normalizeReaderImageSourceForMatch) + .filter(Boolean); + return getReaderImageElements().filter(function (element) { + return getReaderImageSourceCandidates(element).some(function (candidate) { + return readerImageCandidateMatches(candidate, normalizedTargets); + }); + }); + } + + function findReaderImageChunkIndex(source, originalSource) { + if (!window.virtualization || !window.virtualization.chunksData) return -1; + var normalizedTargets = [source, originalSource] + .map(normalizeReaderImageSourceForMatch) + .filter(Boolean); + var targetNames = normalizedTargets + .map(function (target) { + return target.substring(target.lastIndexOf("/") + 1); + }) + .filter(Boolean); + + for (var i = 0; i < window.virtualization.chunksData.length; i++) { + var chunkHtml = window.virtualization.chunksData[i]; + if (!chunkHtml) continue; + var normalizedChunk = normalizeReaderImageSourceForMatch(chunkHtml); + if ( + normalizedTargets.some(function (target) { + return normalizedChunk.indexOf(target) !== -1; + }) || + targetNames.some(function (name) { + return normalizedChunk.indexOf(name) !== -1; + }) + ) { + return i; + } + } + return -1; + } + + window.scrollToReaderImageSource = function(source, ordinal, originalSource) { + var safeOrdinal = Math.max(0, parseInt(ordinal || 0, 10) || 0); + var matches = findReaderImageElementsBySource(source, originalSource); + + if (!matches.length) { + var chunkIndex = findReaderImageChunkIndex(source, originalSource); + if (chunkIndex >= 0) { + var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); + if (chunkDiv && chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { + chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkDiv.style.height = ""; + } + matches = findReaderImageElementsBySource(source, originalSource); + } + } + + var target = matches[Math.min(safeOrdinal, Math.max(0, matches.length - 1))]; + if (!target) return false; + + var rect = target.getBoundingClientRect(); + var targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: Math.max(0, targetScrollY), behavior: "auto" }); + setTimeout(function () { + if (window.reportScrollState) window.reportScrollState(); + }, 80); + return true; + }; + window.scrollToCfi = function(cfi) { let cleanCfi = cfi; diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index 94a881d..6292862 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -155,9 +155,11 @@ static FPDFAnnot_SetFlags_t set_annot_flags_func = nullptr; static FPDFAnnot_GetFormFieldName_t get_form_field_name_func = nullptr; typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key); +typedef int (*FPDFAnnot_SetLinkedAnnot_t)(void* annot, const char* key, void* linked_annot); typedef void (*FPDFPage_CloseAnnot_t)(void* annot); static FPDFAnnot_GetLinkedAnnot_t get_linked_annot_func = nullptr; +static FPDFAnnot_SetLinkedAnnot_t set_linked_annot_func = nullptr; static FPDFPage_CloseAnnot_t close_annot_func = nullptr; static FPDF_LoadDocument_t load_document_func = nullptr; static FPDF_CloseDocument_t close_document_func = nullptr; @@ -219,6 +221,7 @@ static bool init_pdfium() { get_annot_string_func = (FPDFAnnot_GetStringValue_t) dlsym(pdfium_handle, "FPDFAnnot_GetStringValue"); get_annot_color_func = (FPDFAnnot_GetColor_t) dlsym(pdfium_handle, "FPDFAnnot_GetColor"); get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot"); + set_linked_annot_func = (FPDFAnnot_SetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_SetLinkedAnnot"); close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot"); get_annot_flags_func = (FPDFAnnot_GetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_GetFlags"); set_annot_flags_func = (FPDFAnnot_SetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_SetFlags"); @@ -605,6 +608,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl return result; } +static constexpr int kPdfAnnotText = 1; static constexpr int kPdfAnnotHighlight = 9; static constexpr int kPdfAnnotInk = 15; static constexpr int kAnnotColor = 0; @@ -669,6 +673,10 @@ static std::vector read_string_array(JNIEnv* env, jobjectArray arra return values; } +static int object_array_length(JNIEnv* env, jobjectArray array) { + return array ? env->GetArrayLength(array) : 0; +} + static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char* key, jstring value) { if (!set_annot_string_value_func || !annot || !key || !value) return false; jsize length = env->GetStringLength(value); @@ -685,6 +693,22 @@ static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char* return set_annot_string_value_func(annot, key, wide.data()) != 0; } +static bool set_annot_string_from_array(JNIEnv* env, void* annot, const char* key, jobjectArray array, size_t index) { + if (!array) return false; + jsize length = env->GetArrayLength(array); + if (index >= static_cast(length)) return false; + + auto value = static_cast(env->GetObjectArrayElement(array, static_cast(index))); + if (!value) return false; + if (env->GetStringLength(value) <= 0) { + env->DeleteLocalRef(value); + return false; + } + bool result = set_annot_string_from_jstring(env, annot, key, value); + env->DeleteLocalRef(value); + return result; +} + static bool set_annot_string_from_ascii(void* annot, const char* key, const std::string& value) { if (!set_annot_string_value_func || !annot || !key) return false; std::vector wide(value.size() + 1); @@ -718,6 +742,20 @@ static FS_RECTF_BRIDGE make_pdf_rect(float left, float top, float right, float b return FS_RECTF_BRIDGE{l, t, r, b}; } +static FS_RECTF_BRIDGE make_pdf_comment_rect( + float anchorRight, + float anchorTop, + float pageWidth, + float pageHeight, + int commentIndex) { + float iconSize = std::min(18.0f, std::max(10.0f, pageWidth * 0.03f)); + float left = std::min(std::max(anchorRight + 2.0f, 0.0f), std::max(0.0f, pageWidth - iconSize)); + float top = anchorTop - static_cast(commentIndex) * (iconSize + 2.0f); + if (top > pageHeight) top = pageHeight; + if (top - iconSize < 0.0f) top = std::min(pageHeight, iconSize); + return make_pdf_rect(left, top, left + iconSize, top - iconSize, 0.0f); +} + static float get_page_width_bridge(void* page) { if (get_page_width_func) return get_page_width_func(page); if (get_page_width_double_func) return static_cast(get_page_width_double_func(page)); @@ -1066,6 +1104,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( jintArray inkPointOffsetsArray, jintArray inkPointCountsArray, jfloatArray inkPointsArray, + jobjectArray inkNamesArray, + jobjectArray inkContentsArray, jintArray textPageIndicesArray, jfloatArray textBoundsArray, jintArray textColorsArray, @@ -1086,7 +1126,16 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( jintArray highlightRectOffsetsArray, jintArray highlightRectCountsArray, jfloatArray highlightRectsArray, - jobjectArray highlightContentsArray) { + jobjectArray highlightNamesArray, + jobjectArray highlightContentsArray, + jintArray highlightCommentOffsetsArray, + jintArray highlightCommentCountsArray, + jintArray highlightCommentParentIndicesArray, + jobjectArray highlightCommentNamesArray, + jobjectArray highlightCommentAuthorsArray, + jobjectArray highlightCommentContentsArray, + jobjectArray highlightCommentCreatedDatesArray, + jobjectArray highlightCommentModifiedDatesArray) { std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !validate_export_functions()) { @@ -1142,6 +1191,14 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( std::vector highlightRectOffsets = read_int_array(env, highlightRectOffsetsArray); std::vector highlightRectCounts = read_int_array(env, highlightRectCountsArray); std::vector highlightRects = read_float_array(env, highlightRectsArray); + std::vector highlightCommentOffsets = read_int_array(env, highlightCommentOffsetsArray); + std::vector highlightCommentCounts = read_int_array(env, highlightCommentCountsArray); + std::vector highlightCommentParentIndices = read_int_array(env, highlightCommentParentIndicesArray); + const int highlightCommentNamesLength = object_array_length(env, highlightCommentNamesArray); + const int highlightCommentAuthorsLength = object_array_length(env, highlightCommentAuthorsArray); + const int highlightCommentContentsLength = object_array_length(env, highlightCommentContentsArray); + const int highlightCommentCreatedDatesLength = object_array_length(env, highlightCommentCreatedDatesArray); + const int highlightCommentModifiedDatesLength = object_array_length(env, highlightCommentModifiedDatesArray); void* document = load_document_func(source.c_str(), nullptr); if (!document) { @@ -1247,7 +1304,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( hadFailure = true; } - set_annot_string_from_ascii(annot, "Contents", "Ink"); + set_annot_string_from_array(env, annot, "NM", inkNamesArray, i); + set_annot_string_from_array(env, annot, "Contents", inkContentsArray, i); if (generate_content_func) generate_content_func(page); close_annot_func(annot); close_page_func(page); @@ -1268,6 +1326,20 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( continue; } + void* page = load_page_func(document, pageIndex); + if (!page) { + hadFailure = true; + continue; + } + + float pageWidth = get_page_width_bridge(page); + float pageHeight = get_page_height_bridge(page); + if (pageWidth <= 0.0f || pageHeight <= 0.0f) { + close_page_func(page); + hadFailure = true; + continue; + } + std::vector quads; quads.reserve(static_cast(rectCount)); float unionLeft = 0.0f; @@ -1279,31 +1351,32 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( int sourceIndex = (rectOffset + j) * 4; float left = std::min(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]); float right = std::max(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]); - float top = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); - float bottom = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); - if (right <= left || top <= bottom) continue; + float top = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); + float bottom = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); + if (right <= left || bottom <= top) continue; - quads.push_back(FS_QUADPOINTSF_BRIDGE{left, top, right, top, left, bottom, right, bottom}); + float pdfLeft = clamp_unit(left) * pageWidth; + float pdfRight = clamp_unit(right) * pageWidth; + float pdfTop = (1.0f - clamp_unit(top)) * pageHeight; + float pdfBottom = (1.0f - clamp_unit(bottom)) * pageHeight; + if (pdfRight <= pdfLeft || pdfTop <= pdfBottom) continue; + + quads.push_back(FS_QUADPOINTSF_BRIDGE{pdfLeft, pdfTop, pdfRight, pdfTop, pdfLeft, pdfBottom, pdfRight, pdfBottom}); if (quads.size() == 1) { - unionLeft = left; - unionRight = right; - unionTop = top; - unionBottom = bottom; + unionLeft = pdfLeft; + unionRight = pdfRight; + unionTop = pdfTop; + unionBottom = pdfBottom; } else { - unionLeft = std::min(unionLeft, left); - unionRight = std::max(unionRight, right); - unionTop = std::max(unionTop, top); - unionBottom = std::min(unionBottom, bottom); + unionLeft = std::min(unionLeft, pdfLeft); + unionRight = std::max(unionRight, pdfRight); + unionTop = std::max(unionTop, pdfTop); + unionBottom = std::min(unionBottom, pdfBottom); } } if (quads.empty()) { - hadFailure = true; - continue; - } - - void* page = load_page_func(document, pageIndex); - if (!page) { + close_page_func(page); hadFailure = true; continue; } @@ -1329,15 +1402,66 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( set_annot_color_func(annot, kAnnotColor, r, g, b, a); if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint); - if (highlightContentsArray && i < static_cast(env->GetArrayLength(highlightContentsArray))) { - auto content = static_cast(env->GetObjectArrayElement(highlightContentsArray, static_cast(i))); - if (content) { - set_annot_string_from_jstring(env, annot, "Contents", content); - env->DeleteLocalRef(content); + set_annot_string_from_array(env, annot, "NM", highlightNamesArray, i); + set_annot_string_from_array(env, annot, "Contents", highlightContentsArray, i); + + int commentOffset = i < highlightCommentOffsets.size() ? highlightCommentOffsets[i] : 0; + int commentCount = i < highlightCommentCounts.size() ? highlightCommentCounts[i] : 0; + bool commentPayloadValid = commentCount <= 0 || + (commentOffset >= 0 && + commentOffset + commentCount <= static_cast(highlightCommentParentIndices.size()) && + commentOffset + commentCount <= highlightCommentNamesLength && + commentOffset + commentCount <= highlightCommentAuthorsLength && + commentOffset + commentCount <= highlightCommentContentsLength && + commentOffset + commentCount <= highlightCommentCreatedDatesLength && + commentOffset + commentCount <= highlightCommentModifiedDatesLength); + if (!commentPayloadValid) { + hadFailure = true; + commentCount = 0; + } + + std::vector commentAnnots; + commentAnnots.resize(static_cast(std::max(0, commentCount)), nullptr); + for (int commentIndex = 0; commentIndex < commentCount; commentIndex++) { + int globalCommentIndex = commentOffset + commentIndex; + void* commentAnnot = create_annot_func(page, kPdfAnnotText); + if (!commentAnnot) { + hadFailure = true; + continue; + } + commentAnnots[static_cast(commentIndex)] = commentAnnot; + + FS_RECTF_BRIDGE commentRect = make_pdf_comment_rect( + unionRight, + unionTop, + pageWidth, + pageHeight, + 0 + ); + set_annot_rect_func(commentAnnot, &commentRect); + set_annot_color_func(commentAnnot, kAnnotColor, r, g, b, 255); + if (set_annot_flags_func) set_annot_flags_func(commentAnnot, kAnnotFlagPrint); + set_annot_string_from_array(env, commentAnnot, "NM", highlightCommentNamesArray, globalCommentIndex); + set_annot_string_from_array(env, commentAnnot, "T", highlightCommentAuthorsArray, globalCommentIndex); + set_annot_string_from_array(env, commentAnnot, "Contents", highlightCommentContentsArray, globalCommentIndex); + set_annot_string_from_array(env, commentAnnot, "CreationDate", highlightCommentCreatedDatesArray, globalCommentIndex); + set_annot_string_from_array(env, commentAnnot, "M", highlightCommentModifiedDatesArray, globalCommentIndex); + + int parentIndex = highlightCommentParentIndices[static_cast(globalCommentIndex)]; + void* parentAnnot = annot; + if (parentIndex >= 0 && parentIndex < commentIndex) { + void* candidate = commentAnnots[static_cast(parentIndex)]; + if (candidate) parentAnnot = candidate; + } + if (set_linked_annot_func && parentAnnot) { + set_linked_annot_func(commentAnnot, "IRT", parentAnnot); } } if (generate_content_func) generate_content_func(page); + for (void* commentAnnot : commentAnnots) { + if (commentAnnot) close_annot_func(commentAnnot); + } close_annot_func(annot); close_page_func(page); } diff --git a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt index acd0072..f050e92 100644 --- a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt +++ b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt @@ -24,6 +24,7 @@ fun androidSettingsHubInput( isDebugBuild = isDebugBuild, isSignedIn = uiState.currentUser != null, isProUser = uiState.isProUser, + accountAvailable = supportsSync, syncAvailable = supportsSync, folderSyncAvailable = supportsSync, aiSettingsAvailable = supportsOssAiKeys, @@ -36,6 +37,7 @@ fun androidSettingsHubInput( includeRecentLimit = true, includeCustomFonts = true, includeStrictFileFilter = true, + includePdfFileNameDisplayName = true, includeHideReaderAi = !isOfflineBuild, includeCloudLocalDataClear = supportsSync, supportProjectAvailable = isOssBuild, @@ -43,6 +45,7 @@ fun androidSettingsHubInput( isSyncEnabled = uiState.isSyncEnabled, isFolderSyncEnabled = uiState.isFolderSyncEnabled, useStrictFileFilter = uiState.useStrictFileFilter, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, isScreenCaptureProtectionEnabled = uiState.isScreenCaptureProtectionEnabled, hideReaderAi = hideReaderAi ) diff --git a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt index 1539a6f..4f0bb68 100644 --- a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt +++ b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt @@ -71,8 +71,8 @@ internal object AndroidSharedStateBridge { val reduced = current.toBridgeSharedState(projectedState).reduce(action) return current.copy( searchQuery = reduced.searchQuery, - sortOrder = reduced.sortOrder.toAndroidSortOrder(), - libraryFilters = reduced.libraryFilters.toAndroidLibraryFilters(), + sortOrder = reduced.sortOrder, + libraryFilters = reduced.libraryFilters, contextualActionItems = reduced.selectedBookIds.mapNotNullTo(mutableSetOf()) { androidBooksById[it] }, contextualActionShelfIds = reduced.selectedShelfIds, libraryScreenStartPage = reduced.libraryScreenStartPage, @@ -87,12 +87,13 @@ internal object AndroidSharedStateBridge { ): ReaderScreenState { val reduced = current.toBridgeSharedState(projectedState).reduce(action) return current.copy( - appThemeMode = reduced.appThemeMode.toAndroidAppThemeMode(), - appContrastOption = reduced.appContrastOption.toAndroidAppContrastOption(), + appThemeMode = reduced.appThemeMode, + appContrastOption = reduced.appContrastOption, appTextDimFactorLight = reduced.appTextDimFactorLight, appTextDimFactorDark = reduced.appTextDimFactorDark, appSeedColor = reduced.appSeedColor, - customAppThemes = reduced.customAppThemes.map { it.toAndroidCustomAppTheme() } + appFontPreference = reduced.appFontPreference, + customAppThemes = reduced.customAppThemes ) } diff --git a/app/src/main/java/com/aryan/reader/AppFontResolver.kt b/app/src/main/java/com/aryan/reader/AppFontResolver.kt new file mode 100644 index 0000000..597c650 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/AppFontResolver.kt @@ -0,0 +1,22 @@ +package com.aryan.reader + +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import com.aryan.reader.data.CustomFontEntity +import java.io.File + +fun AppFontPreference.toAndroidAppFontFamily(customFonts: List): FontFamily? { + val sanitized = sanitized() + return when (sanitized.kind) { + AppFontPreferenceKind.SYSTEM -> null + AppFontPreferenceKind.SERIF -> FontFamily.Serif + AppFontPreferenceKind.SANS_SERIF -> FontFamily.SansSerif + AppFontPreferenceKind.MONOSPACE -> FontFamily.Monospace + AppFontPreferenceKind.CUSTOM -> { + val fontId = sanitized.customFontId ?: return null + val font = customFonts.firstOrNull { it.id == fontId && !it.isDeleted } ?: return null + val file = File(font.path).takeIf { it.isFile } ?: return null + runCatching { FontFamily(Font(file)) }.getOrNull() + } + } +} diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index 626dbb8..5f4bbee 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -22,13 +22,21 @@ package com.aryan.reader import android.os.Build import timber.log.Timber import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -54,6 +62,10 @@ 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 com.aryan.reader.shared.ReaderFeatureSurface +import com.aryan.reader.tts.ReaderTtsMiniBar +import com.aryan.reader.tts.readerTtsMiniBarBottomPaddingDp +import com.aryan.reader.tts.shouldShowReaderTtsMiniBar import kotlinx.coroutines.delay object AppDestinations { @@ -143,18 +155,30 @@ fun AppNavigation( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentBackStackEntry?.destination?.route + val ttsController = viewModel.ttsController + val ttsState by ttsController.ttsState.collectAsStateWithLifecycle() + val isOnReaderRoute = currentRoute == AppDestinations.PDF_VIEWER_ROUTE || + currentRoute == AppDestinations.EPUB_READER_ROUTE + val showTtsMiniBar = shouldShowReaderTtsMiniBar( + ttsState = ttsState, + isOnReaderRoute = isOnReaderRoute + ) + val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp( + isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE + ).dp LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { if (!uiState.isLoading) { - when (uiState.selectedFileType) { - FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX -> { + when (uiState.selectedFileType?.readerSurfaceOnAndroid()) { + ReaderFeatureSurface.PDF_VIEWER -> { if (uiState.selectedPdfUri != null) { if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) { navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE) } } } - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { + ReaderFeatureSurface.EPUB_READER, + ReaderFeatureSurface.TEXT_READER -> { if (uiState.selectedEpubBook != null) { if (currentRoute != AppDestinations.EPUB_READER_ROUTE) { navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE) @@ -166,16 +190,12 @@ fun AppNavigation( navController.syncRouteTo(AppDestinations.MAIN_ROUTE) } } - FileType.UNKNOWN -> { - if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) { - navController.syncRouteTo(AppDestinations.MAIN_ROUTE) - } - } } } } - NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { + Box(modifier = Modifier.fillMaxSize()) { + NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) { Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") MainScreen( @@ -379,5 +399,33 @@ fun AppNavigation( onBackClick = { navController.popBackStackIfReady() } ) } + } + + AnimatedVisibility( + visible = showTtsMiniBar, + enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)), + exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(start = 16.dp, end = 16.dp, bottom = miniBarBottomPadding) + ) { + ReaderTtsMiniBar( + ttsController = ttsController, + ttsState = ttsState, + onOpenReader = { + ttsState.bookId?.let { bookId -> + viewModel.openTtsNotificationTarget( + bookId = bookId, + sourceCfi = ttsState.sourceCfi, + startOffset = ttsState.startOffsetInSource.takeIf { it >= 0 }, + chapterIndex = ttsState.chapterIndex, + pageIndex = ttsState.pageIndex + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } } } diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/aryan/reader/AppUiModels.kt index 2907a9c..f5236d6 100644 --- a/app/src/main/java/com/aryan/reader/AppUiModels.kt +++ b/app/src/main/java/com/aryan/reader/AppUiModels.kt @@ -12,6 +12,8 @@ typealias BannerMessage = com.aryan.reader.shared.BannerMessage typealias UserData = com.aryan.reader.shared.UserData typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption +typealias AppFontPreference = com.aryan.reader.shared.AppFontPreference +typealias AppFontPreferenceKind = com.aryan.reader.shared.AppFontPreferenceKind typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme data class ImportResult( @@ -50,6 +52,8 @@ data class ReaderScreenState( val initialBookmarksJson: String? = null, val initialHighlightsJson: String? = null, val initialPageInBook: Int? = null, + val initialPageInBookIsExplicit: Boolean = false, + val isOpeningFromTtsNotification: Boolean = false, val shelves: List = emptyList(), val viewingShelfId: String? = null, val isAddingBooksToShelf: Boolean = false, @@ -95,11 +99,13 @@ data class ReaderScreenState( val showExternalFileSavePromptFor: String? = null, val externalFileBehavior: String = "ASK", val useStrictFileFilter: Boolean = false, + val usePdfFileNameAsDisplayName: 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 appFontPreference: AppFontPreference = AppFontPreference.System, val customAppThemes: List = emptyList(), val allTags: List = emptyList(), val showTagSelectionDialogFor: Set = emptySet(), diff --git a/app/src/main/java/com/aryan/reader/BookImporter.kt b/app/src/main/java/com/aryan/reader/BookImporter.kt index 55714c7..5aae54b 100644 --- a/app/src/main/java/com/aryan/reader/BookImporter.kt +++ b/app/src/main/java/com/aryan/reader/BookImporter.kt @@ -29,6 +29,7 @@ import java.io.FileOutputStream import java.io.InputStream import java.util.UUID import androidx.core.net.toUri +import com.aryan.reader.shared.SharedFileCapabilities private const val BOOKS_DIR = "books" @@ -103,15 +104,18 @@ class BookImporter(private val context: Context) { } private fun getFileExtension(uri: Uri): String { - val path = uri.path ?: return "tmp" - return File(path).extension.lowercase().ifEmpty { - // Fallback for URIs that don't have a clear extension in the path - when (context.contentResolver.getType(uri)) { - "application/pdf" -> "pdf" - "application/epub+zip" -> "epub" - "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx" - else -> "tmp" - } - } + val path = uri.path + val pathExtension = path + ?.let(::File) + ?.extension + ?.lowercase() + ?.takeIf { it.isNotBlank() } + if (pathExtension != null) return pathExtension + + val metadataType = SharedFileCapabilities.resolveFileTypeForMetadata( + fileName = uri.lastPathSegment ?: path, + mimeType = context.contentResolver.getType(uri) + ) + return metadataType?.let(SharedFileCapabilities::primaryExtensionFor) ?: "tmp" } } diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index ce71fcc..8aeab8f 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -169,6 +169,11 @@ import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES import com.aryan.reader.epubreader.PREF_READER_THEME import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.pdf.PdfHighlightColor +import com.aryan.reader.shared.BuiltInReaderThemes +import com.aryan.reader.shared.ReaderTextureFilePrefix +import com.aryan.reader.shared.normalizeReaderTextureExtension +import com.aryan.reader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName +import com.aryan.reader.shared.readerTextureMimeTypeForExtension import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsCacheManager @@ -212,6 +217,9 @@ import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt +typealias ReaderTexture = com.aryan.reader.shared.ReaderTexture +typealias ReaderTheme = com.aryan.reader.shared.ReaderTheme + const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val summarizeEndpoint = "/summarize" const val summarizationUrl = aiServerBasePath + summarizeEndpoint @@ -465,18 +473,9 @@ data class SearchResult( val chunkIndex: Int ) -data class AiDefinitionResult( - val definition: String? = null, - val error: String? = null -) +typealias AiDefinitionResult = com.aryan.reader.shared.AiDefinitionResult -data class SummarizationResult( - val summary: String? = null, - val error: String? = null, - val cost: Double? = null, - val freeRemaining: Int? = null, - val isCacheHit: Boolean = false -) +typealias SummarizationResult = com.aryan.reader.shared.SummarizationResult data class CachedSummaryItem( val chapterIndex: Int, @@ -2449,51 +2448,26 @@ fun ColorComparePill( } } -enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String? = null, val resId: Int? = null) { - NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"), - NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"), - LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"), - RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"), - GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"), - CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"), - RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"), - PAPER("paper", "Paper", resId = R.drawable.texture_paper), - CANVAS("canvas", "Canvas", resId = R.drawable.texture_canvas), - EINK("eink", "E-Ink", resId = R.drawable.texture_eink), - SLATE("slate", "Slate", resId = R.drawable.texture_slate) -} - -private const val TEXTURE_FILE_PREFIX = "file:" private const val READER_TEXTURE_DIR = "reader_textures" fun readerTextureDisplayName(textureId: String?): String { - if (textureId == null) return "None" - return ReaderTexture.entries.find { it.id == textureId }?.displayName - ?: File(textureId.removePrefix(TEXTURE_FILE_PREFIX)).nameWithoutExtension.ifBlank { "Custom Image" } + return sharedReaderTextureDisplayName(textureId) } fun importReaderTexture(context: Context, uri: Uri): String? { return try { - val extension = context.contentResolver.getType(uri) - ?.substringAfterLast('/') - ?.lowercase(Locale.ROOT) - ?.let { - when (it) { - "jpeg", "jpg" -> "jpg" - "png", "webp", "gif", "bmp" -> it - else -> null - } - } ?: uri.lastPathSegment - ?.substringAfterLast('.', "") - ?.lowercase(Locale.ROOT) - ?.takeIf { it in setOf("jpg", "jpeg", "png", "webp", "gif", "bmp") } + val extension = normalizeReaderTextureExtension( + context.contentResolver.getType(uri)?.substringAfterLast('/') + ) ?: normalizeReaderTextureExtension( + uri.lastPathSegment?.substringAfterLast('.', "") + ) ?: "img" val dir = File(context.filesDir, READER_TEXTURE_DIR).apply { mkdirs() } val output = File(dir, "texture_${System.currentTimeMillis()}.$extension") context.contentResolver.openInputStream(uri)?.use { input -> output.outputStream().use { out -> input.copyTo(out) } } ?: return null - TEXTURE_FILE_PREFIX + output.absolutePath + ReaderTextureFilePrefix + output.absolutePath } catch (e: Exception) { Timber.e(e, "Failed to import reader texture") null @@ -2564,17 +2538,17 @@ private fun calculateBitmapSampleSize(width: Int, height: Int, maxDimension: Int fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? { if (textureId == null) return null return try { - val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { + val bitmap = if (textureId.startsWith(ReaderTextureFilePrefix)) { decodeSampledBitmapFile( - path = textureId.removePrefix(TEXTURE_FILE_PREFIX), + path = textureId.removePrefix(ReaderTextureFilePrefix), maxDimension = MAX_READER_TEXTURE_DIMENSION_PX ) } else { val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null + val resourceId = texture.androidTextureResourceId() when { - texture.assetPath != null -> context.assets.open(texture.assetPath).use(BitmapFactory::decodeStream) - texture.resId != null -> BitmapFactory.decodeResource(context.resources, texture.resId) - else -> null + resourceId != null -> BitmapFactory.decodeResource(context.resources, resourceId) + else -> context.assets.open(texture.assetPath).use(BitmapFactory::decodeStream) } } val safeBitmap = bitmap?.scaledToCanvasLimit( @@ -2595,8 +2569,8 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? { if (textureId == null) return null return try { var mimeType = "image/png" - val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { - val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX)) + val bytes = if (textureId.startsWith(ReaderTextureFilePrefix)) { + val file = File(textureId.removePrefix(ReaderTextureFilePrefix)) mimeType = "image/png" val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX) ?: return null @@ -2617,19 +2591,19 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? { } } else { val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null + val resourceId = texture.androidTextureResourceId() when { - texture.assetPath != null -> { - mimeType = imageMimeTypeForExtension(texture.assetPath.substringAfterLast('.', "png")) - context.assets.open(texture.assetPath).use { it.readBytes() } - } - texture.resId != null -> { - val bitmap = BitmapFactory.decodeResource(context.resources, texture.resId) + resourceId != null -> { + val bitmap = BitmapFactory.decodeResource(context.resources, resourceId) ByteArrayOutputStream().use { out -> bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out) out.toByteArray() } } - else -> null + else -> { + mimeType = readerTextureMimeTypeForExtension(texture.assetPath.substringAfterLast('.', "png")) + context.assets.open(texture.assetPath).use { it.readBytes() } + } } } ?: return null "data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP) @@ -2639,40 +2613,17 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? { } } -private fun imageMimeTypeForExtension(extension: String): String { - return when (extension.lowercase(Locale.ROOT)) { - "jpg", "jpeg" -> "image/jpeg" - "webp" -> "image/webp" - "gif" -> "image/gif" - "bmp" -> "image/bmp" - else -> "image/png" +private fun ReaderTexture.androidTextureResourceId(): Int? { + return when (this) { + ReaderTexture.PAPER -> R.drawable.texture_paper + ReaderTexture.CANVAS -> R.drawable.texture_canvas + ReaderTexture.EINK -> R.drawable.texture_eink + ReaderTexture.SLATE -> R.drawable.texture_slate + else -> null } } -data class ReaderTheme( - val id: String, - val name: String, - val backgroundColor: Color, - val textColor: Color, - val isDark: Boolean, - val textureId: String? = null, - val isCustom: Boolean = false -) - -val BuiltInThemes = listOf( - ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false), - ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), - 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("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id), - ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), - ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), - ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id), - ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id), - ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) -) +val BuiltInThemes = BuiltInReaderThemes fun saveReaderThemeId(context: Context, themeId: String) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) @@ -2700,7 +2651,10 @@ fun getImportedTextures(context: Context): List { return try { val dir = File(context.filesDir, READER_TEXTURE_DIR) if (!dir.exists()) emptyList() - else dir.listFiles()?.map { TEXTURE_FILE_PREFIX + it.absolutePath } ?: emptyList() + else dir.listFiles() + ?.filter { it.isFile } + ?.map { ReaderTextureFilePrefix + it.absolutePath } + ?: emptyList() } catch (_: Exception) { emptyList() } @@ -3181,7 +3135,7 @@ private fun TexturePickerSection( ) TextureChoice( label = stringResource(R.string.theme_texture_upload), - textureId = selectedTextureId?.takeIf { it.startsWith(TEXTURE_FILE_PREFIX) }, + textureId = selectedTextureId?.takeIf { it.startsWith(ReaderTextureFilePrefix) }, selectedTextureId = selectedTextureId, onTextureSelected = { onImportTexture() }, isUpload = true, @@ -3189,7 +3143,7 @@ private fun TexturePickerSection( ) } Spacer(Modifier.height(8.dp)) - ReaderTexture.entries.filter { it.assetPath != null }.chunked(2).forEach { rowTextures -> + ReaderTexture.entries.filter { it.androidTextureResourceId() == null }.chunked(2).forEach { rowTextures -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { rowTextures.forEach { texture -> TextureChoice( @@ -3234,7 +3188,7 @@ private fun TextureChoice( ) { val context = LocalContext.current val textureBitmap = remember(textureId) { loadReaderTextureBitmap(context, textureId) } - val selected = if (isUpload) selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true else selectedTextureId == textureId + val selected = if (isUpload) selectedTextureId?.startsWith(ReaderTextureFilePrefix) == true else selectedTextureId == textureId Surface( onClick = { onTextureSelected(textureId) }, modifier = modifier.height(52.dp), @@ -3254,7 +3208,7 @@ private fun TextureChoice( contentAlignment = Alignment.Center ) { Text( - text = if (isUpload && selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true) { + text = if (isUpload && selectedTextureId?.startsWith(ReaderTextureFilePrefix) == true) { readerTextureDisplayName(selectedTextureId) } else label, style = MaterialTheme.typography.labelSmall, @@ -3868,8 +3822,11 @@ fun AiResultContentView( val showUsageBadge = result?.isCacheHit == true || (BuildConfig.FLAVOR != "oss" && (result?.cost != null || isLoading)) if (result != null && showUsageBadge && (!result.summary.isNullOrBlank() || isLoading)) { + val cost = result.cost + val freeRemaining = result.freeRemaining + val isFreeGeneratedResult = cost == 0.0 && freeRemaining != null Surface( - color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color( + color = if (result.isCacheHit || isFreeGeneratedResult) Color( 0xFF4CAF50 ).copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(12.dp) @@ -3877,19 +3834,19 @@ fun AiResultContentView( Text( text = if (result.isCacheHit) { stringResource(R.string.ai_cache_hit_free) - } else if (result.cost != null) { - if (result.cost == 0.0 && result.freeRemaining != null) { - stringResource(R.string.ai_generated_free_remaining, - result.freeRemaining + } else if (cost != null) { + if (isFreeGeneratedResult) { + safeStringResource(R.string.ai_generated_free_remaining, + freeRemaining ) } else { - stringResource(R.string.ai_generated_cost, result.cost.toString()) + safeStringResource(R.string.ai_generated_cost, cost.toString()) } } else { stringResource(R.string.ai_generating_cost_calculating) }, style = MaterialTheme.typography.labelSmall, - color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color( + color = if (result.isCacheHit || isFreeGeneratedResult) Color( 0xFF388E3C ) else MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) diff --git a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt index d5bd87b..adb94da 100644 --- a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt +++ b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt @@ -7,42 +7,7 @@ internal fun resolveFileTypeFromName(fileName: String?): FileType? { } internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? { - val normalizedMimeType = mimeType - ?.substringBefore(';') - ?.trim() - ?.lowercase() - return when (normalizedMimeType) { - "application/vnd.oasis.opendocument.text" -> FileType.ODT - "application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX - "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX - "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> { - when { - fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ - fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2 - else -> null - } - } - "application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> { - if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null - } - "application/x-cb7", "application/x-7z-compressed" -> { - if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null - } - "application/pdf" -> FileType.PDF - "application/epub+zip" -> FileType.EPUB - "application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2 - "application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI - "text/markdown", "text/x-markdown" -> FileType.MD - "text/html", "application/xhtml+xml" -> FileType.HTML - "text/csv", "text/comma-separated-values", "text/tab-separated-values", - "application/json", "application/xml", "text/xml", - "text/x-java-source", "text/x-python", "text/x-kotlin", - "text/javascript", "application/javascript", - "text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML - "text/plain" -> resolveFileTypeFromName(fileName) ?: FileType.TXT - else -> resolveFileTypeFromName(fileName) - } + return SharedFileCapabilities.resolveFileTypeForMetadata(fileName, mimeType) } internal fun isCodeOrDataFileName(fileName: String): Boolean { diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index d0ebab8..97bdde9 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -38,6 +38,16 @@ import kotlinx.coroutines.withContext import androidx.core.content.edit import com.aryan.reader.data.LocalSyncUtils import com.aryan.reader.data.FolderBookMetadata +import com.aryan.reader.data.toSharedFolderBookMetadata +import com.aryan.reader.shared.BookItem as SharedBookItem +import com.aryan.reader.shared.EpubAnnotationSerializer +import com.aryan.reader.shared.EpubBookmark +import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import com.aryan.reader.shared.LocalFolderSyncEngine +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.SharedFolderScannedFile +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.reader.ReaderBookmark import java.io.File import android.provider.DocumentsContract @@ -53,7 +63,6 @@ class 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() } @@ -151,11 +160,7 @@ class FolderSyncWorker( 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 @@ -179,7 +184,7 @@ class FolderSyncWorker( return false } - ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped") + ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared") val folderMetadataMap = ReaderPerfLog.measureSuspend( name = "FolderSync phase metadata-sidecars", @@ -192,324 +197,148 @@ class FolderSyncWorker( "FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString" ) - val preloadedSidecars = mutableMapOf>() val existingFolderBooks = ReaderPerfLog.measureSuspend( name = "FolderSync phase load-existing-db", minLogMs = 25L ) { recentFilesRepository.getFilesBySourceFolder(folderUriString) } - val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId } - val remoteMetadataUpdates = mutableListOf() + val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap() - folderMetadataMap.forEach { (bookId, remoteMeta) -> - val existingItem = existingFolderBooksById[bookId] + val scanResult = if (metadataOnly) { + AndroidFolderScanResult() + } else { + ReaderPerfLog.measureSuspend( + name = "FolderSync phase scan-folder", + minLogMs = 25L + ) { + scanFolderFiles( + folderUri = folderUri, + folderUriString = folderUriString, + allowedFileTypes = allowedFileTypes + ) + } + } + dirsScanned = scanResult.dirsScanned + filesSeen = scanResult.filesSeen + supportedBooksSeen = scanResult.files.size + stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder - if (existingItem != null) { - if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { - Timber.tag("PdfPositionDebug").w("FolderSyncWorker applies remote progress for $bookId | Local Page: ${existingItem.lastPage} -> Remote Page: ${remoteMeta.lastPage}") - val itemToUpdate = existingItem.copy( - lastChapterIndex = remoteMeta.lastChapterIndex, - lastPage = remoteMeta.lastPage, - lastPositionCfi = remoteMeta.lastPositionCfi, - progressPercentage = remoteMeta.progressPercentage, - bookmarksJson = remoteMeta.bookmarksJson, - highlightsJson = remoteMeta.highlightsJson, - customName = remoteMeta.customName, - locatorBlockIndex = remoteMeta.locatorBlockIndex, - locatorCharOffset = remoteMeta.locatorCharOffset, - lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, - isRecent = remoteMeta.isRecent || existingItem.isRecent, - timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp - ) - remoteMetadataUpdates.add(itemToUpdate) - } else { - Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}") - } + if (isStopped || stoppedForUnlinkedFolder) { + ReaderPerfLog.w( + "FolderSync folder aborted before shared engine stopped=$isStopped " + + "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString" + ) + return true + } + + val nowMillis = System.currentTimeMillis() + val folder = SyncedFolder( + uriString = folderUriString, + name = documentTree.name ?: "Local Folder", + lastScanTime = nowMillis, + allowedFileTypes = allowedFileTypes + ) + val sharedState = SharedReaderScreenState( + rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() }, + syncedFolders = listOf(folder) + ) + val syncResult = LocalFolderSyncEngine.syncFolder( + state = sharedState, + folder = folder, + files = scanResult.files, + remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() }, + nowMillis = nowMillis, + metadataOnly = metadataOnly + ) + + if (syncResult.idMigrations.isNotEmpty()) { + val preloadedSidecars = ReaderPerfLog.measureSuspend( + name = "FolderSync phase migration-sidecars", + minLogMs = 25L + ) { + LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap() + } + syncResult.idMigrations.forEach { (oldId, newId) -> + Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId") + migrateFolderBookId( + folderUriString = folderUriString, + oldId = oldId, + newId = newId, + folderMetadataMap = folderMetadataMap, + preloadedSidecars = preloadedSidecars, + existingItemsMap = existingItemsMap + ) } } - if (remoteMetadataUpdates.isNotEmpty()) { - recentFilesRepository.addRecentFiles(remoteMetadataUpdates) + if (!isFolderStillLinked(folderUriString)) { + ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString") + stoppedForUnlinkedFolder = true + return true + } + + val scannedFilesById = scanResult.files.associateBy { it.stableBookId } + val syncedItems = syncResult.state.rawLibraryBooks.map { book -> + val existing = existingItemsMap[book.id] + val metadata = appliedMetadataFor( + book = book, + existing = existing, + metadata = folderMetadataMap[book.id] + ) + book.toFolderSyncRecentFileItem( + existing = existing, + appliedMetadata = metadata, + scannedFile = scannedFilesById[book.id], + nowMillis = nowMillis + ) + } + val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item } + + changedItems + .filter { item -> + val previous = existingItemsMap[item.bookId] + previous != null && folderFileContentChanged(previous, item) + } + .forEach { item -> + Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.") + recentFilesRepository.clearLocalCachesForBook(item.bookId) + } + + if (changedItems.isNotEmpty()) { + recentFilesRepository.addRecentFiles(changedItems) dbFlushes++ - ReaderPerfLog.d( - "FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString" - ) } - if (metadataOnly) { - sidecarsImported += importAnnotationSidecarsForBooks( - folderUri = folderUri, - folderUriString = folderUriString, - books = existingFolderBooks, - phase = "metadata-only" - ) + if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) { + Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.") + recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList()) } - if (!metadataOnly) { - Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...") - val contentResolver = appContext.contentResolver - val foundBookIds = mutableSetOf() - val newOrUpdatedItems = mutableListOf() - 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().apply { addAll(entry.value) } - } - - val rootDocId = DocumentsContract.getTreeDocumentId(folderUri) - val dirQueue = ArrayDeque() - dirQueue.add(rootDocId) - - val projection = arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentsContract.Document.COLUMN_SIZE, - DocumentsContract.Document.COLUMN_LAST_MODIFIED - ) - - 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 { - contentResolver.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) - val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) - val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) - - 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") { - dirQueue.add(docId) - } - } else { - val size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L - val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L - - val type = getFileType(name, mimeType) - if ( - type != null && - type in allowedFileTypes && - isLocalFolderSyncEligibleFile(name, mimeType) && - !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 != docUriString) { - val collidedItem = existingItem - val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId) - if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) { - Timber.tag("FolderSync").i("Resolving folder ID collision for ${collidedItem.displayName}: ${collidedItem.bookId} -> $collidedStableId") - migrateFolderBookId( - folderUriString = folderUriString, - oldId = collidedItem.bookId, - newId = collidedStableId, - folderMetadataMap = folderMetadataMap, - preloadedSidecars = preloadedSidecars, - existingItemsMap = existingItemsMap - ) - existingItem = existingItemsMap[stableId] - } - } - - if (existingItem == null) { - 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") - - migrateFolderBookId( - folderUriString = folderUriString, - oldId = oldId, - newId = stableId, - folderMetadataMap = folderMetadataMap, - preloadedSidecars = preloadedSidecars, - existingItemsMap = existingItemsMap - ) - legacyItemsByName[name]?.remove(oldItem) - existingItem = existingItemsMap[stableId] - } - } - - if (existingItem == null) { - val remoteMeta = folderMetadataMap[stableId] - - val newItem = RecentFileItem( - bookId = stableId, - uriString = docUri.toString(), - type = type, - displayName = name, - timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), - lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), - coverImagePath = null, - title = name.substringBeforeLast('.', name), - author = remoteMeta?.author, - isAvailable = true, - isDeleted = false, - isRecent = remoteMeta?.isRecent ?: false, - sourceFolderUri = folderUriString, - lastChapterIndex = remoteMeta?.lastChapterIndex, - lastPage = remoteMeta?.lastPage, - lastPositionCfi = remoteMeta?.lastPositionCfi, - progressPercentage = remoteMeta?.progressPercentage, - bookmarksJson = remoteMeta?.bookmarksJson, - highlightsJson = remoteMeta?.highlightsJson, - customName = remoteMeta?.customName, - locatorBlockIndex = remoteMeta?.locatorBlockIndex, - locatorCharOffset = remoteMeta?.locatorCharOffset, - fileSize = size, - fileContentModifiedTimestamp = lastModified - ) - newOrUpdatedItems.add(newItem) - newBooks++ - } else { - var needsUpdate = false - var updatedItem = existingItem - - val modifiedChanged = lastModified > 0L && - existingItem.fileContentModifiedTimestamp != lastModified - if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) { - Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.") - recentFilesRepository.clearLocalCachesForBook(stableId) - updatedItem = updatedItem.copy( - fileSize = size, - fileContentModifiedTimestamp = lastModified, - lastModifiedTimestamp = lastModified, - coverImagePath = null, - title = name.substringBeforeLast('.', name), - author = null, - seriesName = null, - seriesIndex = null, - description = null, - originalTitle = null, - originalAuthor = null, - originalSeriesName = null, - originalSeriesIndex = null, - originalDescription = null, - folderTextMetadataParsed = false, - folderCoverMetadataParsed = false - ) - needsUpdate = true - } - - if (updatedItem.isDeleted || !updatedItem.isAvailable) { - updatedItem = updatedItem.copy(isDeleted = false, isAvailable = true) - needsUpdate = true - } - - if (updatedItem.uriString != docUri.toString()) { - updatedItem = updatedItem.copy(uriString = docUri.toString()) - needsUpdate = true - } - - if (needsUpdate) { - newOrUpdatedItems.add(updatedItem) - updatedBooks++ - } else { - unchangedBooks++ - } - } - - 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() - } - - } - } - } - } - } catch (e: Exception) { - Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId") - } - - if (stoppedForUnlinkedFolder) break - } - - if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) { - recentFilesRepository.addRecentFiles(newOrUpdatedItems) - dbFlushes++ - scanDbFlushes++ - newOrUpdatedItems.clear() - } - - 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.") - recentFilesRepository.deleteFilePermanently(idsToRemove) - } - } - } - - if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) { - val booksForAnnotationSync = ReaderPerfLog.measureSuspend( + val booksForAnnotationSync = if (metadataOnly) { + syncedItems + } else { + 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" - ) } + sidecarsImported += importAnnotationSidecarsForBooks( + folderUri = folderUri, + folderUriString = folderUriString, + books = booksForAnnotationSync, + phase = if (metadataOnly) "metadata-only" else "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 " + + "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " + + "remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " + + "removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " + "dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " + "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString" ) @@ -601,6 +430,294 @@ class FolderSyncWorker( return imported } + private data class AndroidFolderScanResult( + val files: List = emptyList(), + val dirsScanned: Int = 0, + val filesSeen: Int = 0, + val stoppedForUnlinkedFolder: Boolean = false + ) + + private fun scanFolderFiles( + folderUri: android.net.Uri, + folderUriString: String, + allowedFileTypes: Set + ): AndroidFolderScanResult { + Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...") + val contentResolver = appContext.contentResolver + val rootDocId = DocumentsContract.getTreeDocumentId(folderUri) + val dirQueue = ArrayDeque() + val scannedFiles = mutableListOf() + var dirsScanned = 0 + var filesSeen = 0 + var stoppedForUnlinkedFolder = false + dirQueue.add(rootDocId) + + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + + 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 { + contentResolver.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) + val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) + val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + + 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 != LOCAL_FOLDER_SYNC_DATA_DIR) { + dirQueue.add(docId) + } + continue + } + + val type = getFileType(name, mimeType) + if ( + type == null || + type !in allowedFileTypes || + !isLocalFolderSyncEligibleFile(name, mimeType) || + name.endsWith(".json") || + name.startsWith(".") + ) { + continue + } + + val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) + val relativePath = buildRelativePath(rootDocId, docId, name) + scannedFiles += SharedFolderScannedFile( + name = name, + path = docUri.toString(), + sourceFolder = folderUriString, + relativePath = relativePath, + type = type, + size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L, + lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L + ) + } + } + } catch (e: Exception) { + Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId") + } + + if (stoppedForUnlinkedFolder) break + } + + return AndroidFolderScanResult( + files = scannedFiles, + dirsScanned = dirsScanned, + filesSeen = filesSeen, + stoppedForUnlinkedFolder = stoppedForUnlinkedFolder + ) + } + + private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem { + return SharedBookItem( + id = bookId, + path = uriString, + type = type, + displayName = displayName, + timestamp = lastModifiedTimestamp, + coverImagePath = coverImagePath, + title = title, + author = author, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription, + progressPercentage = progressPercentage, + isRecent = isRecent, + fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, + sourceFolder = sourceFolderUri, + folderTextMetadataParsed = folderTextMetadataParsed, + seriesName = seriesName, + seriesIndex = seriesIndex, + lastPageIndex = lastPage, + readerPosition = readerPositionOrNull(), + readerBookmarks = parseReaderBookmarks(), + readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) + ) + } + + private fun SharedBookItem.toFolderSyncRecentFileItem( + existing: RecentFileItem?, + appliedMetadata: FolderBookMetadata?, + scannedFile: SharedFolderScannedFile?, + nowMillis: Long + ): RecentFileItem { + val contentChanged = existing != null && folderFileContentChanged(existing, this) + val localModifiedTimestamp = when { + appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp + contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp + timestamp > 0L -> timestamp + else -> nowMillis + } + val legacyPosition = readerPosition + val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id) + val mappedHighlightsJson = readerHighlights + .takeIf { it.isNotEmpty() } + ?.let(EpubAnnotationSerializer::highlightsToJson) + val bookmarksJson = if (appliedMetadata != null || existing == null) { + mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson + } else { + existing.bookmarksJson + } + val highlightsJson = if (appliedMetadata != null || existing == null) { + mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson + } else { + existing.highlightsJson + } + + return RecentFileItem( + bookId = id, + uriString = path, + type = type, + displayName = scannedFile?.name ?: existing?.displayName ?: displayName, + timestamp = when { + existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp + appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp + else -> existing.timestamp + }, + coverImagePath = coverImagePath, + title = title, + author = author, + lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex, + lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage, + lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi, + locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex, + locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset, + progressPercentage = progressPercentage, + isRecent = isRecent, + isAvailable = true, + lastModifiedTimestamp = localModifiedTimestamp, + isDeleted = false, + bookmarksJson = bookmarksJson, + sourceFolderUri = sourceFolder, + isReflowPreferred = existing?.isReflowPreferred ?: false, + customName = appliedMetadata?.customName ?: existing?.customName, + highlightsJson = highlightsJson, + fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, + seriesName = seriesName, + seriesIndex = seriesIndex, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription, + folderTextMetadataParsed = folderTextMetadataParsed, + folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false, + tags = existing?.tags.orEmpty() + ) + } + + private fun appliedMetadataFor( + book: SharedBookItem, + existing: RecentFileItem?, + metadata: FolderBookMetadata? + ): FolderBookMetadata? { + if (metadata == null) return null + val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE + return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified } + } + + private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? { + if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null + return ReaderLocator.fromLegacy( + chapterIndex = lastChapterIndex, + cfi = lastPositionCfi, + pageIndex = lastPage + ) + } + + private fun RecentFileItem.parseReaderBookmarks(): List { + return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson) + .mapIndexed { index, bookmark -> + val locator = bookmark.locator.withFallbacks( + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi, + pageIndex = bookmark.pageInChapter?.minus(1), + textQuote = bookmark.snippet + ) + val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0 + ReaderBookmark( + id = "bookmark_${bookId}_$index", + pageIndex = pageIndex.coerceAtLeast(0), + chapterTitle = bookmark.chapterTitle, + preview = bookmark.snippet, + locator = locator + ) + } + } + + private fun List.toAndroidBookmarksJson(bookId: String): String? { + val bookmarks = mapIndexed { index, bookmark -> + val locator = bookmark.locator + val chapterIndex = locator.chapterIndex ?: 0 + val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}" + EpubBookmark( + cfi = cfi, + chapterTitle = bookmark.chapterTitle, + label = null, + snippet = bookmark.preview, + pageInChapter = bookmark.pageIndex + 1, + totalPagesInChapter = null, + chapterIndex = chapterIndex, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = bookmark.pageIndex, + textQuote = bookmark.preview + ) + ) + } + return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson) + } + + private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean { + val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize + val modifiedChanged = next.fileContentModifiedTimestamp > 0L && + previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp + return sizeChanged || modifiedChanged + } + + private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean { + val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize + val modifiedChanged = next.fileContentModifiedTimestamp > 0L && + previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp + return sizeChanged || modifiedChanged + } + 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) @@ -621,11 +738,6 @@ class FolderSyncWorker( return resolveFileTypeFromMetadata(name, mimeType) } - private fun buildStableBookId(name: String, rootDocId: String, docId: String): String { - val relativePath = buildRelativePath(rootDocId, docId, name) - return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath) - } - private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String { val rootPath = rootDocId.substringAfter(':', "") val docPath = docId.substringAfter(':', "") @@ -638,16 +750,6 @@ class FolderSyncWorker( return relative.ifBlank { fallbackName } } - private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? { - val uriString = item.uriString ?: return null - return try { - val docId = DocumentsContract.getDocumentId(uriString.toUri()) - buildStableBookId(item.displayName, rootDocId, docId) - } catch (_: Exception) { - null - } - } - private suspend fun migrateFolderBookId( folderUriString: String, oldId: String, diff --git a/app/src/main/java/com/aryan/reader/FontsScreen.kt b/app/src/main/java/com/aryan/reader/FontsScreen.kt index 6426473..4e86a24 100644 --- a/app/src/main/java/com/aryan/reader/FontsScreen.kt +++ b/app/src/main/java/com/aryan/reader/FontsScreen.kt @@ -61,6 +61,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.aryan.reader.shared.CustomFontItem +import com.aryan.reader.shared.ui.SharedAppFontSelector +import com.aryan.reader.shared.ui.SharedFontSettingsSection +import com.aryan.reader.shared.ui.SharedFontSettingsTabs import com.aryan.reader.data.CustomFontEntity import java.io.File @@ -80,6 +84,7 @@ fun FontsScreen( var showDeleteDialog by remember { mutableStateOf(false) } var fontToDelete by remember { mutableStateOf(null) } var showGoogleFontsSheet by remember { mutableStateOf(false) } + var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) } val pickFontLauncher = rememberFilePickerLauncher { uris -> uris.firstOrNull()?.let { viewModel.importFont(it) } @@ -105,7 +110,7 @@ fun FontsScreen( ) }, floatingActionButton = { - if (fonts.isNotEmpty()) { + if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) { Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(16.dp) @@ -130,31 +135,61 @@ fun FontsScreen( } ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { - if (fonts.isEmpty()) { - val secondaryText = if (showGoogleFontsOption) stringResource(R.string.action_browse_google_fonts) else null - val secondaryClick: (() -> Unit)? = if (showGoogleFontsOption) { { showGoogleFontsSheet = true } } else null - - EmptyState( - title = stringResource(R.string.no_custom_fonts), - message = stringResource(R.string.import_fonts_desc), - onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) }, - modifier = Modifier.fillMaxSize(), - secondaryButtonText = secondaryText, - onSecondaryClick = secondaryClick + val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() } + Column(modifier = Modifier.fillMaxSize()) { + SharedFontSettingsTabs( + selectedSection = selectedSection, + onSectionChange = { selectedSection = it }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(fonts, key = { it.id }) { font -> - FontListItem( - font = font, - onDelete = { - fontToDelete = font - showDeleteDialog = true + + when (selectedSection) { + SharedFontSettingsSection.READER_FONTS -> { + if (fonts.isEmpty()) { + val secondaryText = if (showGoogleFontsOption) stringResource(R.string.action_browse_google_fonts) else null + val secondaryClick: (() -> Unit)? = if (showGoogleFontsOption) { { showGoogleFontsSheet = true } } else null + + EmptyState( + title = stringResource(R.string.no_custom_fonts), + message = stringResource(R.string.import_fonts_desc), + onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) }, + modifier = Modifier.weight(1f), + secondaryButtonText = secondaryText, + onSecondaryClick = secondaryClick + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(fonts, key = { it.id }) { font -> + FontListItem( + font = font, + onDelete = { + fontToDelete = font + showDeleteDialog = true + } + ) + } } + } + } + + SharedFontSettingsSection.APP_TEXT -> { + SharedAppFontSelector( + preference = uiState.appFontPreference, + customFonts = sharedFonts, + onPreferenceChange = viewModel::setAppFontPreference, + fontFamilyForPreview = { font -> + val file = File(font.path) + if (file.isFile) { + runCatching { FontFamily(Font(file)) }.getOrNull() + } else { + null + } + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp) ) } } @@ -424,6 +459,22 @@ fun FontListItem( } } +private fun List.toSharedCustomFontItems(): List { + return filterNot { it.isDeleted } + .sortedBy { it.displayName.lowercase() } + .map { font -> + CustomFontItem( + id = font.id, + displayName = font.displayName, + fileName = font.fileName, + fileExtension = font.fileExtension, + path = font.path, + timestamp = font.timestamp, + isDeleted = font.isDeleted + ) + } +} + @Composable fun DeleteFontConfirmationDialog( fontName: String, diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index ff35766..926488a 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -351,6 +352,9 @@ fun HomeScreen( showStrictFilterDialog = true } }, + onUsePdfFileNameAsDisplayNameToggle = { + viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName) + }, onAppThemeClick = { showAppThemePanel = true }, onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) @@ -432,7 +436,8 @@ fun HomeScreen( onRefresh = { viewModel.refreshLibrary() }, isRefreshing = uiState.isRefreshing, isSyncEnabled = uiState.isSyncEnabled, - hasSyncedFolder = uiState.syncedFolders.isNotEmpty() + hasSyncedFolder = uiState.syncedFolders.isNotEmpty(), + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName ) } } @@ -500,6 +505,7 @@ fun HomeScreen( if (showInfoDialog) { FileInfoDialog( item = item, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, onDismiss = { showInfoDialog = false itemForInfoDialog = null @@ -629,7 +635,8 @@ private fun RecentFilesContent( onRefresh: () -> Unit, isRefreshing: Boolean, isSyncEnabled: Boolean, - hasSyncedFolder: Boolean + hasSyncedFolder: Boolean, + usePdfFileNameAsDisplayName: Boolean ) { val canRefresh = isSyncEnabled || hasSyncedFolder val selectedItemUris = remember(selectedContextItems) { @@ -653,7 +660,8 @@ private fun RecentFilesContent( onItemLongClick = onItemLongClick, windowSizeClass = windowSizeClass, contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp), - downloadingBookIds = downloadingBookIds + downloadingBookIds = downloadingBookIds, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName ) Row( @@ -702,6 +710,7 @@ private fun RecentFilesGrid( windowSizeClass: WindowSizeClass, contentPadding: PaddingValues = PaddingValues(vertical = 8.dp), downloadingBookIds: Set, + usePdfFileNameAsDisplayName: Boolean, ) { val gridCells = when (windowSizeClass.widthSizeClass) { WindowWidthSizeClass.Compact -> GridCells.Fixed(3) @@ -741,7 +750,7 @@ private fun RecentFilesGrid( onClick = { onItemClick(tab) }, label = { Text( - text = tab.customName ?: tab.title ?: tab.displayName, + text = tab.cardTitle(usePdfFileNameAsDisplayName), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 150.dp) @@ -783,7 +792,8 @@ private fun RecentFilesGrid( isPinned = item.bookId in pinnedHomeBookIds, onClick = { onItemClick(item) }, onLongClick = { onItemLongClick(item) }, - isDownloading = item.bookId in downloadingBookIds + isDownloading = item.bookId in downloadingBookIds, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName ) } } @@ -800,6 +810,7 @@ fun RecentFileCard( onClick: () -> Unit, onLongClick: () -> Unit, isDownloading: Boolean, + usePdfFileNameAsDisplayName: Boolean = false, ) { val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt() val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " " @@ -822,11 +833,14 @@ fun RecentFileCard( ) ) { Column(modifier = Modifier.fillMaxWidth()) { - Box( + BoxWithConstraints( modifier = Modifier .fillMaxWidth() .aspectRatio(0.74f) ) { + val useCompactCoverBadges = maxWidth < 128.dp + val coverBadgePadding = if (useCompactCoverBadges) 5.dp else 8.dp + ThemedBookCover( item = item, contentDescription = item.displayName, @@ -894,30 +908,25 @@ fun RecentFileCard( } } - Box(modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) { - FileTypeBadge(type = item.type, overlay = true) - } - - progressPercent?.let { percent -> - Surface( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(8.dp), - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f), - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - border = androidx.compose.foundation.BorderStroke( - 1.dp, - Color.White.copy(alpha = 0.14f) - ) - ) { - Text( - text = "$percent%", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(coverBadgePadding), + verticalAlignment = Alignment.CenterVertically + ) { + progressPercent?.let { percent -> + CoverProgressBadge( + percent = percent, + compact = useCompactCoverBadges ) } + Spacer(modifier = Modifier.weight(1f)) + FileTypeBadge( + type = item.type, + overlay = true, + compact = useCompactCoverBadges + ) } } @@ -929,7 +938,7 @@ fun RecentFileCard( horizontalAlignment = Alignment.Start ) { Text( - text = item.cardTitle(), + text = item.cardTitle(usePdfFileNameAsDisplayName), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 2, @@ -1008,6 +1017,39 @@ fun RecentFileCard( } } +@Composable +private fun CoverProgressBadge( + percent: Int, + compact: Boolean, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + border = androidx.compose.foundation.BorderStroke( + 1.dp, + Color.White.copy(alpha = 0.14f) + ) + ) { + Text( + text = "$percent%", + style = if (compact) { + MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp) + } else { + MaterialTheme.typography.labelSmall + }, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding( + horizontal = if (compact) 6.dp else 10.dp, + vertical = if (compact) 3.dp else 4.dp + ) + ) + } +} + @Suppress("unused", "KotlinConstantConditions") @Composable fun DefaultTopAppBar( @@ -1024,6 +1066,7 @@ fun DefaultTopAppBar( onTabsToggle: (Boolean) -> Unit, onExternalFileBehaviorClick: () -> Unit, onStrictFilterToggleClick: () -> Unit, + onUsePdfFileNameAsDisplayNameToggle: () -> Unit, onAppThemeClick: () -> Unit, onSettingsClick: () -> Unit, onTestPanelDetectionClick: () -> Unit, @@ -1132,6 +1175,15 @@ fun DefaultTopAppBar( } }) + DropdownMenuItem(text = { Text(stringResource(R.string.options_use_pdf_filename_display_name)) }, onClick = { + onUsePdfFileNameAsDisplayNameToggle() + showOptionsMenu = false + }, trailingIcon = { + if (uiState.usePdfFileNameAsDisplayName) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) + } + }) + HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.options_language)) }, onClick = { @@ -1262,7 +1314,7 @@ private fun AppDrawerContent( Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.FormatListNumbered, contentDescription = stringResource(R.string.credits_tab), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer) Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.credits_count, uiState.credits), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer) + Text(safeStringResource(R.string.credits_count, uiState.credits), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer) } } } diff --git a/app/src/main/java/com/aryan/reader/LibraryModels.kt b/app/src/main/java/com/aryan/reader/LibraryModels.kt index 627e57e..6e5c3f1 100644 --- a/app/src/main/java/com/aryan/reader/LibraryModels.kt +++ b/app/src/main/java/com/aryan/reader/LibraryModels.kt @@ -1,6 +1,7 @@ package com.aryan.reader import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.shared.ReaderFeatureSurface import com.aryan.reader.shared.ReaderPlatform import com.aryan.reader.shared.SharedFileCapabilities @@ -11,13 +12,16 @@ typealias SortOrder = com.aryan.reader.shared.SortOrder typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder +typealias ShelfType = com.aryan.reader.shared.ShelfType internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID) internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID) internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES -enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER } +internal fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? { + return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID) +} data class Shelf( val id: String, diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 47b7856..90370a0 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -141,6 +141,7 @@ import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.TagEntity import com.aryan.reader.opds.OpdsAcquisition import com.aryan.reader.opds.OpdsCatalog +import com.aryan.reader.opds.OpdsDownloadState import com.aryan.reader.opds.OpdsEntry import com.aryan.reader.opds.OpdsViewModel import kotlinx.coroutines.CoroutineScope @@ -343,7 +344,8 @@ fun LibraryScreen( ) }, onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog, - onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) } + onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName ) @@ -392,6 +394,7 @@ fun LibraryScreen( if (showInfoDialog) { FileInfoDialog( item = item, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, onDismiss = { showInfoDialog = false itemForInfoDialog = null @@ -458,7 +461,8 @@ fun ShelfScreen( onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) }, onBack = viewModel::dismissAddBooksToShelf, onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) }, - downloadingBookIds = uiState.downloadingBookIds + downloadingBookIds = uiState.downloadingBookIds, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName ) } else { ShelfDetailScreen( @@ -483,7 +487,8 @@ fun ShelfScreen( onDeleteClick = { showRemoveFromShelfDialog = true }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, - downloadingBookIds = uiState.downloadingBookIds + downloadingBookIds = uiState.downloadingBookIds, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName ) } } @@ -523,6 +528,7 @@ fun ShelfScreen( if (showInfoDialog) { FileInfoDialog( item = item, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) }, onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) }, @@ -588,6 +594,7 @@ fun LibraryScreenContent( onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onDeleteCatalogStreams: (String) -> Unit, onSettingsClick: () -> Unit, + usePdfFileNameAsDisplayName: Boolean, ) { val isBookContextualModeActive = selectedItems.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty() @@ -860,7 +867,8 @@ fun LibraryScreenContent( isPinned = item.bookId in pinnedLibraryBookIds, onItemClick = { onItemClick(item) }, onItemLongClick = { onItemLongClick(item) }, - isDownloading = item.bookId in downloadingBookIds + isDownloading = item.bookId in downloadingBookIds, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName ) } } @@ -1027,6 +1035,7 @@ private fun ShelfDetailScreen( onRenameShelf: () -> Unit, onDeleteShelf: () -> Unit, downloadingBookIds: Set, + usePdfFileNameAsDisplayName: Boolean, ) { val isContextualModeActive = selectedItems.isNotEmpty() val isFolderShelf = shelf.type == ShelfType.FOLDER @@ -1162,7 +1171,11 @@ private fun ShelfDetailScreen( Text( text = when { isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 -> - "${pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount)} • ${getBookCountString(shelf.directBookCount)}" + stringResource( + R.string.folder_subtitle_folder_book_counts, + pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount), + getBookCountString(shelf.directBookCount) + ) isFolderShelf && shelf.childShelfCount > 0 -> pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount) isFolderShelf -> getBookCountString(shelf.directBookCount) @@ -1322,7 +1335,8 @@ private fun ShelfDetailScreen( isSelected = selectedItems.any { it.bookId == item.bookId }, onItemClick = { onBookClick(item) }, onItemLongClick = { onBookLongClick(item) }, - isDownloading = item.bookId in downloadingBookIds + isDownloading = item.bookId in downloadingBookIds, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName ) } } @@ -1344,6 +1358,7 @@ private fun AddBooksModeScreen( onBack: () -> Unit, onAddSelectedBooks: () -> Unit, downloadingBookIds: Set, + usePdfFileNameAsDisplayName: Boolean, ) { var showSortMenu by remember { mutableStateOf(false) } @@ -1445,7 +1460,8 @@ private fun AddBooksModeScreen( isSelected = isSelected, onItemClick = { onBookClick(item) }, onItemLongClick = { onBookClick(item) }, - isDownloading = item.bookId in downloadingBookIds + isDownloading = item.bookId in downloadingBookIds, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName ) } } @@ -1631,6 +1647,7 @@ private fun LibraryListItem( onItemClick: () -> Unit, onItemLongClick: () -> Unit, isDownloading: Boolean, + usePdfFileNameAsDisplayName: Boolean = false, ) { androidx.compose.material3.ElevatedCard( shape = MaterialTheme.shapes.large, @@ -1695,7 +1712,7 @@ private fun LibraryListItem( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = item.cardTitle(), + text = item.cardTitle(usePdfFileNameAsDisplayName), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 2, @@ -2358,8 +2375,7 @@ fun OpdsTab( opdsViewModel: OpdsViewModel = viewModel() ) { val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle() - val downloadingState by opdsViewModel.downloadingState.collectAsStateWithLifecycle() - val downloadingEntries by opdsViewModel.downloadingEntries.collectAsStateWithLifecycle() + val downloadingState = uiState.downloadingState val context = LocalContext.current var selectedEntry by remember { mutableStateOf(null) } var showCatalogDialog by remember { mutableStateOf(false) } @@ -2836,7 +2852,7 @@ fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) { fun OpdsBookCard( entry: OpdsEntry, localLibraryFiles: List, - downloadState: OpdsViewModel.DownloadState?, + downloadState: OpdsDownloadState?, onDownloadClick: (OpdsAcquisition) -> Unit, onReadClick: (RecentFileItem) -> Unit, onStreamClick: () -> Unit, @@ -2967,7 +2983,7 @@ fun OpdsBookCard( fun OpdsBookDetailsSheet( entry: OpdsEntry, localLibraryFiles: List, - downloadState: OpdsViewModel.DownloadState?, + downloadState: OpdsDownloadState?, onDownloadFormat: (OpdsAcquisition) -> Unit, onReadClick: (RecentFileItem) -> Unit, onStreamClick: () -> Unit, diff --git a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt index 004dbde..586f036 100644 --- a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt +++ b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt @@ -142,13 +142,13 @@ fun applyLibraryFilters(files: List, filters: LibraryFilters): L return files.mapSharedResults( sharedApplyLibraryFilters( books = files.map { it.toSharedProjectionBookItem() }, - filters = filters.toSharedLibraryFilters() + filters = filters ) ) } fun sortFiles(files: List, sortOrder: SortOrder): List { - return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder())) + return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder)) } private fun List.mapSharedResults(sharedBooks: List): List { diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index da7e01e..e5835ed 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController @@ -49,6 +50,12 @@ import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi +import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION +import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID +import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX +import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX +import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI +import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET @UnstableApi class MainActivity : AppCompatActivity() { @@ -89,6 +96,7 @@ class MainActivity : AppCompatActivity() { setContent { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() ScreenCaptureProtectionEffect(enabled = uiState.isScreenCaptureProtectionEnabled) @@ -99,13 +107,17 @@ class MainActivity : AppCompatActivity() { } val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight + val appFontFamily = remember(uiState.appFontPreference, customFonts) { + uiState.appFontPreference.toAndroidAppFontFamily(customFonts) + } AppTheme( darkTheme = darkTheme, dynamicColor = uiState.appSeedColor == null, seedColor = uiState.appSeedColor, contrastLevel = uiState.appContrastOption.value, - textDimFactor = textDimFactor + textDimFactor = textDimFactor, + appFontFamily = appFontFamily ) { Surface( modifier = Modifier.fillMaxSize(), @@ -125,10 +137,26 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) handleIntent(intent) } private fun handleIntent(intent: Intent?) { + if (intent?.action == ACTION_OPEN_TTS_SESSION) { + val bookId = intent.getStringExtra(EXTRA_TTS_BOOK_ID) + if (!bookId.isNullOrBlank()) { + Timber.d("Received TTS notification intent for bookId=$bookId") + viewModel.openTtsNotificationTarget( + bookId = bookId, + sourceCfi = intent.getStringExtra(EXTRA_TTS_SOURCE_CFI), + startOffset = intent.getIntExtra(EXTRA_TTS_START_OFFSET, -1).takeIf { it >= 0 }, + chapterIndex = intent.getIntExtra(EXTRA_TTS_CHAPTER_INDEX, -1).takeIf { it >= 0 }, + pageIndex = intent.getIntExtra(EXTRA_TTS_PAGE_INDEX, -1).takeIf { it >= 0 } + ) + } + return + } + if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { Timber.d("Received VIEW intent with URI: ${intent.data}") val uri = intent.data!! diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 9194d90..654ec84 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -86,9 +86,15 @@ import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.pdf.PdfCoverGenerator +import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG import com.aryan.reader.pdf.PdfUserHighlight +import com.aryan.reader.pdf.PdfiumCoreProvider +import com.aryan.reader.pdf.PdfiumEngineProvider import com.aryan.reader.pdf.PdfiumAnnotationExporter import com.aryan.reader.pdf.ReflowWorker +import com.aryan.reader.pdf.pdfLayoutDebugSummary +import com.aryan.reader.pdf.remapPdfAnnotationsForLayoutChange +import com.aryan.reader.pdf.remapPdfBookmarksJsonForLayoutChange import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotationRepository @@ -98,17 +104,18 @@ import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pptx.PptxCoverGenerator +import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedLibraryEditor import com.aryan.reader.shared.SharedImportOutcomeCounts import com.aryan.reader.shared.SharedImportPlanner import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import com.aryan.reader.shared.AppAction as SharedAppAction import com.aryan.reader.shared.LibraryAction as SharedLibraryAction -import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel @@ -558,6 +565,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK", useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false), + usePdfFileNameAsDisplayName = prefs.getBoolean(KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME, false), isScreenCaptureProtectionEnabled = prefs.getBoolean(KEY_SCREEN_CAPTURE_PROTECTION, false), appThemeMode = try { AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name) @@ -568,6 +576,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 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, + appFontPreference = loadAppFontPreference(prefs), customAppThemes = loadCustomAppThemes(prefs) ) ) @@ -718,6 +727,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio selectedBookId = bookId, selectedFileType = item.type, initialPageInBook = item.lastPage, + initialPageInBookIsExplicit = false, + isOpeningFromTtsNotification = false, initialBookmarksJson = item.bookmarksJson, isLoading = false, errorMessage = null @@ -1015,57 +1026,50 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio currentBookmarksJson: String, referenceWidth: Int, referenceHeight: Int, + blankPageId: String? = null, wasManuallyAdded: Boolean = false - ): PageModificationResult = withContext(Dispatchers.Default) { + ): PageModificationResult = withContext(Dispatchers.Default + NonCancellable) { Timber.d("Adding page at index $insertIndex for book $bookId (manual=$wasManuallyAdded)") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.addPage.start bookId=$bookId insertIndex=$insertIndex blankPageId=$blankPageId " + + "manual=$wasManuallyAdded ref=${referenceWidth}x$referenceHeight " + + "current=${currentLayout.pdfLayoutDebugSummary()} annotationPages=${currentAnnotations.keys.sorted()} " + + "bookmarksBytes=${currentBookmarksJson.length}" + ) val newLayout = currentLayout.toMutableList() val safeIndex = insertIndex.coerceIn(0, newLayout.size) val newPage = VirtualPage.BlankPage( - id = UUID.randomUUID().toString(), + id = blankPageId ?: UUID.randomUUID().toString(), width = referenceWidth, height = referenceHeight, wasManuallyAdded = wasManuallyAdded ) newLayout.add(safeIndex, newPage) - val newAnnotations = mutableMapOf>() - currentAnnotations.forEach { (pageIdx, annots) -> - val newIdx = if (pageIdx >= safeIndex) pageIdx + 1 else pageIdx - val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) } - newAnnotations[newIdx] = shiftedAnnots - } + val newAnnotations = remapPdfAnnotationsForLayoutChange( + currentLayout = currentLayout, + updatedLayout = newLayout, + annotations = currentAnnotations + ) - val newTotalPages = newLayout.size val newBookmarksJson = try { - if (currentBookmarksJson.isNotBlank()) { - val jsonArray = JSONArray(currentBookmarksJson) - val newArray = JSONArray() - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - val bmPageIndex = obj.getInt("pageIndex") - val title = obj.getString("title") - - val newBmPageIndex = if (bmPageIndex >= safeIndex) bmPageIndex + 1 - else bmPageIndex - - val newObj = JSONObject() - newObj.put("pageIndex", newBmPageIndex) - newObj.put("title", title) - newObj.put("totalPages", newTotalPages) - newArray.put(newObj) - } - newArray.toString() - } else { - "[]" - } + remapPdfBookmarksJsonForLayoutChange( + currentLayout = currentLayout, + updatedLayout = newLayout, + currentBookmarksJson = currentBookmarksJson + ) } catch (e: Exception) { Timber.e(e, "Error shifting bookmarks") currentBookmarksJson } pageLayoutRepository.saveLayout(bookId, newLayout) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.addPage.done bookId=$bookId safeIndex=$safeIndex new=${newLayout.pdfLayoutDebugSummary()} " + + "newAnnotationPages=${newAnnotations.keys.sorted()} bookmarksBytes=${newBookmarksJson.length}" + ) PageModificationResult(newLayout, newAnnotations, newBookmarksJson) } @@ -1076,58 +1080,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio removeIndex: Int, currentAnnotations: Map>, currentBookmarksJson: String - ): PageModificationResult = withContext(Dispatchers.Default) { + ): PageModificationResult = withContext(Dispatchers.Default + NonCancellable) { Timber.d("Removing page at index $removeIndex for book $bookId") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.removePage.start bookId=$bookId removeIndex=$removeIndex " + + "current=${currentLayout.pdfLayoutDebugSummary()} annotationPages=${currentAnnotations.keys.sorted()} " + + "bookmarksBytes=${currentBookmarksJson.length}" + ) val newLayout = currentLayout.toMutableList() if (removeIndex in newLayout.indices) { newLayout.removeAt(removeIndex) } else { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "vm.removePage.ignored bookId=$bookId removeIndex=$removeIndex current=${currentLayout.pdfLayoutDebugSummary()}" + ) return@withContext PageModificationResult( currentLayout, currentAnnotations, currentBookmarksJson ) } - val newAnnotations = mutableMapOf>() - currentAnnotations.forEach { (pageIdx, annots) -> - if (pageIdx != removeIndex) { - val newIdx = if (pageIdx > removeIndex) pageIdx - 1 else pageIdx - val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) } - newAnnotations[newIdx] = shiftedAnnots - } - } + val newAnnotations = remapPdfAnnotationsForLayoutChange( + currentLayout = currentLayout, + updatedLayout = newLayout, + annotations = currentAnnotations + ) - val newTotalPages = newLayout.size val newBookmarksJson = try { - if (currentBookmarksJson.isNotBlank()) { - val jsonArray = JSONArray(currentBookmarksJson) - val newArray = JSONArray() - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - val bmPageIndex = obj.getInt("pageIndex") - - if (bmPageIndex == removeIndex) continue - - val title = obj.getString("title") - val newBmPageIndex = if (bmPageIndex > removeIndex) bmPageIndex - 1 - else bmPageIndex - - val newObj = JSONObject() - newObj.put("pageIndex", newBmPageIndex) - newObj.put("title", title) - newObj.put("totalPages", newTotalPages) - newArray.put(newObj) - } - newArray.toString() - } else { - "[]" - } + remapPdfBookmarksJsonForLayoutChange( + currentLayout = currentLayout, + updatedLayout = newLayout, + currentBookmarksJson = currentBookmarksJson + ) } catch (e: Exception) { Timber.e(e, "Error shifting bookmarks") currentBookmarksJson } pageLayoutRepository.saveLayout(bookId, newLayout) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.removePage.done bookId=$bookId removeIndex=$removeIndex new=${newLayout.pdfLayoutDebugSummary()} " + + "newAnnotationPages=${newAnnotations.keys.sorted()} bookmarksBytes=${newBookmarksJson.length}" + ) PageModificationResult(newLayout, newAnnotations, newBookmarksJson) } @@ -1325,7 +1319,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio initialCfi = null, initialBookmarksJson = item.bookmarksJson, initialHighlightsJson = null, - initialPageInBook = item.lastPage + initialPageInBook = item.lastPage, + initialPageInBookIsExplicit = false, + isOpeningFromTtsNotification = false ) } } @@ -1360,7 +1356,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio initialCfi = item.lastPositionCfi, initialBookmarksJson = item.bookmarksJson, initialHighlightsJson = item.highlightsJson, - initialPageInBook = null + initialPageInBook = null, + initialPageInBookIsExplicit = false, + isOpeningFromTtsNotification = false ) } } @@ -1652,7 +1650,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun deleteFont(fontId: String) { - viewModelScope.launch { fontsRepository.deleteFont(fontId) } + viewModelScope.launch { + fontsRepository.deleteFont(fontId) + if (_internalState.value.appFontPreference.referencesCustomFont(fontId)) { + setAppFontPreference(AppFontPreference.System) + } + } } fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) { @@ -1943,7 +1946,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val sanitizedFilters = filters.copy( fileTypes = filters.fileTypes.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES } ) - _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters.toSharedLibraryFilters())) } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters)) } prefs.edit { putStringSet(KEY_FILTER_FILE_TYPES, sanitizedFilters.fileTypes.map { it.name }.toSet()) @@ -2264,7 +2267,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isLoading = false, errorMessage = null, initialLocator = null, - initialPageInBook = null + initialPageInBook = null, + initialPageInBookIsExplicit = false, + isOpeningFromTtsNotification = false ) } clearPersistedReaderSession() @@ -2351,7 +2356,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio bookTitle = book.title, chapterTitle = book.chapters.getOrNull(nextIdx)?.title, coverImageUri = backgroundTtsCoverPath?.let { Uri.fromFile(File(it)).toString() }, + bookId = bookId, chapterIndex = nextIdx, + totalChapters = totalChapters, ttsMode = mode, playbackSource = "READER", authToken = token @@ -3476,7 +3483,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio var description: String? = bundleResult?.description var bookForMetadata = epubBook - if (bookForMetadata == null && bundleResult == null && (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)) { + if (bookForMetadata == null && bundleResult == null && type in EPUB_READER_FILE_TYPES) { Timber.d("Parsing downloaded book for cover/metadata: $displayName") Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") @@ -3547,7 +3554,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val finalBookMetadata = bookForMetadata - 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) && finalBookMetadata != null) { + if (type in EPUB_READER_FILE_TYPES && finalBookMetadata != null) { title = title ?: finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName author = author ?: finalBookMetadata.author.takeIf { @@ -3568,22 +3575,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (type == FileType.PDF) { try { - val pdfiumCore = PdfiumCore(appContext) appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> - val pdfDocument = pdfiumCore.newDocument(pfd) - val meta = pdfiumCore.getDocumentMeta(pdfDocument) + PdfiumEngineProvider.withPdfium { + PdfiumCoreProvider.core.newDocument(pfd).use { pdfDocument -> + val meta = pdfDocument.getDocumentMeta() - val extractedTitle = meta.title - if (!extractedTitle.isNullOrBlank() && title == displayName) { - title = extractedTitle + val extractedTitle = meta.title + if (!extractedTitle.isNullOrBlank() && title == displayName) { + title = extractedTitle + } + + val extractedAuthor = meta.author + if (!extractedAuthor.isNullOrBlank() && author == null) { + author = extractedAuthor + } + } } - - val extractedAuthor = meta.author - if (!extractedAuthor.isNullOrBlank() && author == null) { - author = extractedAuthor - } - - pdfiumCore.closeDocument(pdfDocument) } } catch (e: Exception) { Timber.e(e, "Failed to extract PDF title using PdfiumCore") @@ -3686,7 +3693,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun setSortOrder(sortOrder: SortOrder) { - _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder.toSharedSortOrder())) } + _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder)) } prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) } } @@ -3814,7 +3821,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy( bannerMessage = BannerMessage( - message = appContext.getString(R.string.banner_importing_multiple, uris.size), + message = appContext.resources.getQuantityString( + R.plurals.banner_importing_books_count, + uris.size, + uris.size + ), isPersistent = true ), contextualActionItems = emptySet() @@ -3866,8 +3877,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio unsupportedCount = unsupportedCount, failedCount = failedCount ), - importedMessage = "Imported $importedCount books. You can find them in the Library tab.", - duplicateMessage = "Those files are already in the library.", + importedMessage = appContext.resources.getQuantityString( + R.plurals.banner_books_imported_library_tab, + importedCount, + importedCount + ), + duplicateMessage = appContext.getString(R.string.banner_duplicate_files_already_in_library), unsupportedMessage = appContext.getString(R.string.error_unsupported_file_type), failedMessage = appContext.getString(R.string.error_import_file_failed) ) @@ -3901,6 +3916,52 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun openTtsNotificationTarget( + bookId: String, + sourceCfi: String?, + startOffset: Int?, + chapterIndex: Int?, + pageIndex: Int? + ) { + viewModelScope.launch { + val item = recentFilesRepository.getFileByBookId(bookId) + if (item == null) { + _internalState.update { + it.copy(errorMessage = appContext.getString(R.string.error_recent_item_not_found)) + } + return@launch + } + + val uri = item.getUri() + if (uri == null) { + _internalState.update { + it.copy(errorMessage = appContext.getString(R.string.error_file_location_not_found)) + } + return@launch + } + + val initialLocator = chapterIndex?.let { + Locator( + chapterIndex = it, + blockIndex = 0, + charOffset = startOffset ?: 0 + ) + } + + openBook( + uri = uri, + bookId = item.bookId, + type = item.type, + originalDisplayName = item.displayName, + initialPageOverride = pageIndex, + isInitialPageExplicit = pageIndex != null, + initialLocatorOverride = initialLocator, + initialCfiOverride = sourceCfi?.takeIf { it.isNotBlank() }, + preserveTtsOnOpen = true + ) + } + } + private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) { _internalState.update { it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet()) @@ -4011,6 +4072,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio selectedBookId = bookId, selectedPdfUri = uri, initialPageInBook = syncPosition, + initialPageInBookIsExplicit = true, + isOpeningFromTtsNotification = false, initialBookmarksJson = item.bookmarksJson, isLoading = false ) @@ -4201,7 +4264,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun openBook( - uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null + uri: Uri, + bookId: String, + type: FileType, + originalDisplayName: String? = null, + suppressNavigation: Boolean = false, + bundleResult: CalibreBundleResult? = null, + initialPageOverride: Int? = null, + isInitialPageExplicit: Boolean = false, + initialLocatorOverride: Locator? = null, + initialCfiOverride: String? = null, + preserveTtsOnOpen: Boolean = false ) { val openBookStartTime = System.currentTimeMillis() ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type") @@ -4270,8 +4343,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio selectedFileType = type, isLoading = true, errorMessage = null, - initialLocator = null, - initialPageInBook = null + initialLocator = initialLocatorOverride, + initialCfi = initialCfiOverride, + initialPageInBook = initialPageOverride, + initialPageInBookIsExplicit = isInitialPageExplicit, + isOpeningFromTtsNotification = preserveTtsOnOpen ) } @@ -4284,7 +4360,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy( selectedPdfUri = uri, - initialPageInBook = recentItem?.lastPage, + initialPageInBook = initialPageOverride ?: recentItem?.lastPage, + initialPageInBookIsExplicit = isInitialPageExplicit, + isOpeningFromTtsNotification = preserveTtsOnOpen, initialBookmarksJson = recentItem?.bookmarksJson, isLoading = false ) @@ -4308,13 +4386,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition") } } - } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) { + } else if (type in EPUB_READER_FILE_TYPES) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) Timber.tag("FileOpenPerf") .d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") - val locator = - if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { + val locator = initialLocatorOverride + ?: if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { Locator( chapterIndex = recentItem.lastChapterIndex, blockIndex = recentItem.locatorBlockIndex, @@ -4328,7 +4406,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio it.copy( selectedEpubUri = uri, initialLocator = locator, - initialCfi = recentItem?.lastPositionCfi, + initialCfi = initialCfiOverride ?: recentItem?.lastPositionCfi, initialBookmarksJson = recentItem?.bookmarksJson, initialHighlightsJson = recentItem?.highlightsJson, ) @@ -4372,7 +4450,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio selectedFileType = null, selectedBookId = null, isLoading = false, - errorMessage = appContext.getString(R.string.error_unsupported_file_type) + errorMessage = appContext.getString(R.string.error_unsupported_file_type), + initialPageInBookIsExplicit = false, + isOpeningFromTtsNotification = false ) } } @@ -5399,6 +5479,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (legacyId == newId) return@withContext Timber.tag("FolderAnnotationSync") .d("Checking migration from legacyId=$legacyId to newId=$newId") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.migrate.check legacyId=$legacyId newId=$newId" + ) try { fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) { @@ -5430,6 +5513,95 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun layoutBlankScore(file: File?): Pair { + if (file == null || !file.exists()) return 0 to 0 + return try { + val array = JSONArray(file.readText()) + var blankCount = 0 + var manualBlankCount = 0 + for (i in 0 until array.length()) { + val page = array.optJSONObject(i) ?: continue + if (page.optString("type") == "blank") { + blankCount++ + if (page.optBoolean("manual", false)) manualBlankCount++ + } + } + manualBlankCount to blankCount + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").w(e, "Unable to score layout for migration: ${file.name}") + 0 to 0 + } + } + + fun safeMigrateLayout(legacyFile: File?, newFile: File?) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.migrate.layout.start legacyId=$legacyId newId=$newId " + + "legacyPath=${legacyFile?.absolutePath} legacyExists=${legacyFile?.exists()} " + + "legacyBytes=${legacyFile?.takeIf { it.exists() }?.length() ?: 0L} " + + "legacyMtime=${legacyFile?.takeIf { it.exists() }?.lastModified() ?: 0L} " + + "newPath=${newFile?.absolutePath} newExists=${newFile?.exists()} " + + "newBytes=${newFile?.takeIf { it.exists() }?.length() ?: 0L} " + + "newMtime=${newFile?.takeIf { it.exists() }?.lastModified() ?: 0L}" + ) + if (legacyFile == null || !legacyFile.exists()) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.migrate.layout.noLegacy legacyId=$legacyId newId=$newId" + ) + return + } + if (newFile == null) { + Timber.tag("FolderAnnotationSync") + .w("Destination file for layout is null. Skipping.") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "vm.migrate.layout.noDestination legacyId=$legacyId newId=$newId" + ) + return + } + + if (newFile.exists()) { + val legacyTs = legacyFile.lastModified() + val newTs = newFile.lastModified() + val legacyScore = layoutBlankScore(legacyFile) + val newScore = layoutBlankScore(newFile) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.migrate.layout.compare legacyId=$legacyId newId=$newId " + + "legacyScore=$legacyScore legacyTs=$legacyTs newScore=$newScore newTs=$newTs" + ) + val shouldKeepExisting = + newScore.first > legacyScore.first || + (newScore.first == legacyScore.first && newScore.second > legacyScore.second) || + (newScore == legacyScore && newTs >= legacyTs) + + if (shouldKeepExisting) { + Timber.tag("FolderAnnotationSync") + .i("Skipping layout migration: destination preserves newer or richer blank-page layout.") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.migrate.layout.keepExisting legacyId=$legacyId newId=$newId" + ) + legacyFile.delete() + return + } + + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "vm.migrate.layout.replaceExisting legacyId=$legacyId newId=$newId" + ) + newFile.delete() + } + + if (legacyFile.renameTo(newFile)) { + Timber.tag("FolderAnnotationSync").i("Migrated layout successfully.") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "vm.migrate.layout.done legacyId=$legacyId newId=$newId " + + "newExists=${newFile.exists()} newBytes=${newFile.length()} newMtime=${newFile.lastModified()}" + ) + } else { + Timber.tag("FolderAnnotationSync").w("Failed to rename layout file.") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "vm.migrate.layout.renameFailed legacyId=$legacyId newId=$newId" + ) + } + } + // 1. Annotations safeMigrate( pdfAnnotationRepository.getAnnotationFileForSync(legacyId), @@ -5445,10 +5617,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) // 3. Layout - safeMigrate( + safeMigrateLayout( pageLayoutRepository.getLayoutFile(legacyId), - pageLayoutRepository.getLayoutFile(newId), - "layout" + pageLayoutRepository.getLayoutFile(newId) ) // 4. Text Boxes @@ -5616,6 +5787,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(useStrictFileFilter = enabled) } } + fun setUsePdfFileNameAsDisplayName(enabled: Boolean) { + prefs.edit { putBoolean(KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME, enabled) } + _internalState.update { it.copy(usePdfFileNameAsDisplayName = enabled) } + } + fun setScreenCaptureProtectionEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_SCREEN_CAPTURE_PROTECTION, enabled) } _internalState.update { it.copy(isScreenCaptureProtectionEnabled = enabled) } @@ -5642,13 +5818,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return themes } + private fun loadAppFontPreference(prefs: SharedPreferences): AppFontPreference { + val kind = try { + AppFontPreferenceKind.valueOf( + prefs.getString(KEY_APP_FONT_KIND, AppFontPreferenceKind.SYSTEM.name) + ?: AppFontPreferenceKind.SYSTEM.name + ) + } catch (_: Exception) { + AppFontPreferenceKind.SYSTEM + } + return AppFontPreference( + kind = kind, + customFontId = prefs.getString(KEY_APP_FONT_CUSTOM_ID, null) + ).sanitized() + } + + fun setAppFontPreference(preference: AppFontPreference) { + val sanitized = preference.sanitized() + _internalState.update { it.withSharedAppAction(SharedAppAction.AppFontPreferenceChanged(sanitized)) } + prefs.edit { + putString(KEY_APP_FONT_KIND, sanitized.kind.name) + if (sanitized.customFontId == null) { + remove(KEY_APP_FONT_CUSTOM_ID) + } else { + putString(KEY_APP_FONT_CUSTOM_ID, sanitized.customFontId) + } + } + } + fun setAppThemeMode(mode: AppThemeMode) { - _internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode.toSharedAppThemeMode())) } + _internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode)) } prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) } } fun setAppContrastOption(option: AppContrastOption) { - _internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option.toSharedAppContrastOption())) } + _internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option)) } prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) } } @@ -5674,7 +5878,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun addCustomAppTheme(theme: CustomAppTheme) { - _internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme.toSharedCustomAppTheme())) } + _internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme)) } val current = _internalState.value.customAppThemes saveCustomAppThemes(current) prefs.edit { putInt(KEY_APP_SEED_COLOR, theme.seedColor.toArgb()) } @@ -5905,6 +6109,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" + private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name" private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled" private const val KEY_APP_THEME_MODE = "app_theme_mode" private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option" @@ -5912,23 +6117,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 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_APP_FONT_KIND = "app_font_kind" + private const val KEY_APP_FONT_CUSTOM_ID = "app_font_custom_id" private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes" - val SUPPORTED_MIME_TYPES = arrayOf( - "application/pdf", "application/epub+zip", "application/x-mobipocket-ebook", - "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook", "text/markdown", - "text/x-markdown", "text/plain", "text/html", "application/xhtml+xml", - "application/x-fictionbook+xml", "application/x-zip-compressed-fb2", "application/zip", - "application/vnd.comicbook+zip", "application/x-cbz", "application/vnd.comicbook-rar", - "application/x-cbr", "application/x-rar-compressed", "application/x-cb7", - "application/x-7z-compressed", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "application/vnd.oasis.opendocument.text", "application/x-vnd.oasis.opendocument.text-flat-xml", - "text/csv", "text/comma-separated-values", "text/tab-separated-values", "application/json", - "application/xml", "text/xml", "text/x-java-source", "text/x-python", "text/x-kotlin", - "text/javascript", "application/javascript", "text/x-c", "text/x-c++", - "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" - ) + val SUPPORTED_MIME_TYPES = SharedFileCapabilities.androidFilePickerMimeTypes.toTypedArray() } } diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index fe5db42..c3dc3ab 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -12,7 +12,8 @@ import androidx.work.WorkerParameters import androidx.work.WorkManager import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository -import io.legere.pdfiumandroid.PdfiumCore +import com.aryan.reader.pdf.PdfiumCoreProvider +import com.aryan.reader.pdf.PdfiumEngineProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.xmlpull.v1.XmlPullParser @@ -277,16 +278,14 @@ class MetadataExtractionWorker( return parseXmlTextMetadata(xml) } - private fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata { + private suspend 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) + PdfiumEngineProvider.withPdfium { + PdfiumCoreProvider.core.newDocument(pfd).use { pdfDocument -> + val meta = pdfDocument.getDocumentMeta() + TextMetadata(title = meta.title, author = meta.author) + } } } ?: TextMetadata() } catch (e: Exception) { diff --git a/app/src/main/java/com/aryan/reader/ReaderBrightness.kt b/app/src/main/java/com/aryan/reader/ReaderBrightness.kt new file mode 100644 index 0000000..9e9e1d9 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderBrightness.kt @@ -0,0 +1,193 @@ +package com.aryan.reader + +import android.content.Context +import android.view.Window +import android.view.WindowManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import kotlin.math.roundToInt + +private const val READER_PREFS_NAME = "reader_prefs" +private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system" +private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value" +private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f +private const val MIN_CUSTOM_BRIGHTNESS = 0.05f + +data class ReaderBrightnessSettings( + val useSystemBrightness: Boolean = true, + val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS +) { + val safeCustomBrightness: Float + get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f) +} + +fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + return ReaderBrightnessSettings( + useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true), + customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS) + .coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f) + ) +} + +fun saveReaderBrightnessSettings(context: Context, settings: ReaderBrightnessSettings) { + context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE).edit { + putBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, settings.useSystemBrightness) + putFloat(PREF_READER_BRIGHTNESS_VALUE, settings.safeCustomBrightness) + } +} + +@Composable +fun ReaderBrightnessEffect( + window: Window?, + settings: ReaderBrightnessSettings +) { + DisposableEffect(window) { + val originalBrightness = window?.attributes?.screenBrightness + ?: WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + onDispose { + window?.setReaderBrightness(originalBrightness) + } + } + + LaunchedEffect(window, settings) { + val brightness = if (settings.useSystemBrightness) { + WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } else { + settings.safeCustomBrightness + } + window?.setReaderBrightness(brightness) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReaderBrightnessSheet( + settings: ReaderBrightnessSettings, + onSettingsChange: (ReaderBrightnessSettings) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.reader_brightness_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_done)) + } + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.reader_brightness_system), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.reader_brightness_system_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = settings.useSystemBrightness, + onCheckedChange = { useSystem -> + onSettingsChange(settings.copy(useSystemBrightness = useSystem)) + } + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.reader_brightness_custom), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource( + R.string.reader_brightness_percent, + (settings.safeCustomBrightness * 100f).roundToInt() + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + Slider( + value = settings.safeCustomBrightness, + onValueChange = { brightness -> + onSettingsChange( + settings.copy( + useSystemBrightness = false, + customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f) + ) + ) + }, + valueRange = MIN_CUSTOM_BRIGHTNESS..1f + ) + Text( + text = stringResource(R.string.reader_brightness_custom_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.height(4.dp)) + } + } +} + +private fun Window.setReaderBrightness(brightness: Float) { + attributes = attributes.apply { + screenBrightness = brightness + } +} diff --git a/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt b/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt new file mode 100644 index 0000000..5de4838 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt @@ -0,0 +1,104 @@ +package com.aryan.reader + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import com.aryan.reader.data.RecentFileItem + +@Composable +private fun rememberReaderFileInfoItem( + uiState: ReaderScreenState, + primaryBookId: String?, + secondaryBookId: String? = null, + uriString: String? = null +): RecentFileItem? { + return remember( + uiState.allRecentFiles, + uiState.recentFiles, + primaryBookId, + secondaryBookId, + uriString + ) { + findReaderFileInfoItem( + uiState = uiState, + primaryBookId = primaryBookId, + secondaryBookId = secondaryBookId, + uriString = uriString + ) + } +} + +private fun findReaderFileInfoItem( + uiState: ReaderScreenState, + primaryBookId: String?, + secondaryBookId: String?, + uriString: String? +): RecentFileItem? { + val bookId = primaryBookId ?: secondaryBookId + return uiState.allRecentFiles.firstOrNull { it.bookId == bookId } + ?: uiState.recentFiles.firstOrNull { it.bookId == bookId } + ?: uiState.allRecentFiles.firstOrNull { it.uriString == uriString } + ?: uiState.recentFiles.firstOrNull { it.uriString == uriString } +} + +@Composable +internal fun ReaderFileInfoDialogs( + isFileInfoVisible: Boolean, + onFileInfoVisibleChange: (Boolean) -> Unit, + uiState: ReaderScreenState, + primaryBookId: String?, + secondaryBookId: String? = null, + uriString: String? = null, + viewModel: MainViewModel +) { + val item = rememberReaderFileInfoItem( + uiState = uiState, + primaryBookId = primaryBookId, + secondaryBookId = secondaryBookId, + uriString = uriString + ) + + LaunchedEffect(item?.bookId) { + if (item == null) { + onFileInfoVisibleChange(false) + } + } + + item?.let { fileInfoItem -> + if (isFileInfoVisible) { + FileInfoDialog( + item = fileInfoItem, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, + onDismiss = { onFileInfoVisibleChange(false) }, + onSaveMetadata = { metadata -> + viewModel.updateBookMetadata(fileInfoItem.bookId, metadata) + }, + onSaveDisplayName = { name -> + viewModel.updateCustomName(fileInfoItem.bookId, name) + }, + onRestoreMetadata = { + viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId) + }, + onOpenTags = { + onFileInfoVisibleChange(false) + viewModel.openTagSelection(setOf(fileInfoItem.bookId)) + } + ) + } + } + + if (uiState.showTagSelectionDialogFor.isNotEmpty()) { + TagSelectionBottomSheet( + allTags = uiState.allTags, + selectedBookIds = uiState.showTagSelectionDialogFor, + booksWithTags = uiState.rawLibraryFiles, + onCreateAndAssign = { name -> + viewModel.createAndAssignTag(name, uiState.showTagSelectionDialogFor) + }, + onToggleTag = { tagId, assign -> + viewModel.toggleTagForBooks(tagId, uiState.showTagSelectionDialogFor, assign) + }, + onDismiss = viewModel::closeTagSelection + ) + } +} diff --git a/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt b/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt new file mode 100644 index 0000000..9fb9623 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt @@ -0,0 +1,137 @@ +package com.aryan.reader + +import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.core.content.edit +import kotlin.math.max +import kotlin.math.min + +private const val READER_SLIDER_CHROME_PREFS = "reader_slider_chrome_prefs" +private const val READER_SLIDER_TOGGLE_PREFIX = "reader_slider_toggle_" +private const val MIN_SLIDER_ACCENT_CONTRAST = 3f +private const val MIN_SLIDER_CONTENT_CONTRAST = 4.5f + +internal data class ReaderSliderBookmarkPosition( + val startPage: Int, + val currentPage: Float +) + +internal data class ReaderSliderToggleState( + val isToggledOn: Boolean, + val bookmarkPosition: ReaderSliderBookmarkPosition +) + +internal data class ReaderSliderChromeColors( + val activeTrackColor: Color, + val inactiveTrackColor: Color, + val thumbColor: Color, + val bookmarkColor: Color, + val contentColor: Color, + val thumbnailSurfaceColor: Color, + val thumbnailContentColor: Color +) + +internal fun readerSliderBookmarkPosition(currentPage: Int): ReaderSliderBookmarkPosition { + val sanitizedPage = currentPage.coerceAtLeast(0) + return ReaderSliderBookmarkPosition( + startPage = sanitizedPage, + currentPage = sanitizedPage.toFloat() + ) +} + +internal fun readerSliderToggleState( + isCurrentlyToggledOn: Boolean, + currentPage: Int +): ReaderSliderToggleState { + return ReaderSliderToggleState( + isToggledOn = !isCurrentlyToggledOn, + bookmarkPosition = readerSliderBookmarkPosition(currentPage) + ) +} + +internal fun shouldRenderReaderSlider( + isToggledOn: Boolean, + isBottomChromeVisible: Boolean, + isSearchActive: Boolean +): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive + +internal fun readerSliderTogglePreferenceKey(bookId: String): String = + READER_SLIDER_TOGGLE_PREFIX + bookId + +internal fun loadReaderSliderToggled(context: Context, bookId: String): Boolean { + return context + .getSharedPreferences(READER_SLIDER_CHROME_PREFS, Context.MODE_PRIVATE) + .getBoolean(readerSliderTogglePreferenceKey(bookId), false) +} + +internal fun saveReaderSliderToggled( + context: Context, + bookId: String, + isToggledOn: Boolean +) { + context + .getSharedPreferences(READER_SLIDER_CHROME_PREFS, Context.MODE_PRIVATE) + .edit { putBoolean(readerSliderTogglePreferenceKey(bookId), isToggledOn) } +} + +internal fun readerSliderChromeColors( + pageBackground: Color, + pageText: Color, + themePrimary: Color +): ReaderSliderChromeColors { + val background = specifiedColorOr(pageBackground, Color.White) + val fallbackContent = highContrastColorFor(background) + val content = specifiedColorOr(pageText, fallbackContent) + .takeIf { contrastRatio(it, background) >= MIN_SLIDER_CONTENT_CONTRAST } + ?: fallbackContent + val active = specifiedColorOr(themePrimary, content) + .takeIf { contrastRatio(it, background) >= MIN_SLIDER_ACCENT_CONTRAST } + ?: content + val inactiveAlpha = if (background.luminance() > 0.5f) 0.44f else 0.52f + val thumbnailSurface = blendColors( + foreground = content, + background = background, + alpha = if (background.luminance() > 0.5f) 0.08f else 0.12f + ).copy(alpha = 0.96f) + + return ReaderSliderChromeColors( + activeTrackColor = active, + inactiveTrackColor = content.copy(alpha = inactiveAlpha), + thumbColor = active, + bookmarkColor = active, + contentColor = content, + thumbnailSurfaceColor = thumbnailSurface, + thumbnailContentColor = content + ) +} + +private fun specifiedColorOr(color: Color, fallback: Color): Color { + return if (color == Color.Unspecified) fallback else color +} + +private fun highContrastColorFor(background: Color): Color { + return if (contrastRatio(Color.Black, background) >= contrastRatio(Color.White, background)) { + Color.Black + } else { + Color.White + } +} + +private fun contrastRatio(first: Color, second: Color): Float { + val firstLuminance = first.luminance() + val secondLuminance = second.luminance() + val lighter = max(firstLuminance, secondLuminance) + val darker = min(firstLuminance, secondLuminance) + return (lighter + 0.05f) / (darker + 0.05f) +} + +private fun blendColors(foreground: Color, background: Color, alpha: Float): Color { + val clampedAlpha = alpha.coerceIn(0f, 1f) + return Color( + red = foreground.red * clampedAlpha + background.red * (1f - clampedAlpha), + green = foreground.green * clampedAlpha + background.green * (1f - clampedAlpha), + blue = foreground.blue * clampedAlpha + background.blue * (1f - clampedAlpha), + alpha = 1f + ) +} diff --git a/app/src/main/java/com/aryan/reader/SafeStringResources.kt b/app/src/main/java/com/aryan/reader/SafeStringResources.kt new file mode 100644 index 0000000..4515a31 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/SafeStringResources.kt @@ -0,0 +1,35 @@ +package com.aryan.reader + +import android.content.Context +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import java.util.IllegalFormatException +import java.util.Locale + +@Composable +fun safeStringResource(@StringRes id: Int, vararg formatArgs: Any?): String { + LocalConfiguration.current + return LocalContext.current.safeGetString(id, *formatArgs) +} + +fun Context.safeGetString(@StringRes id: Int, vararg formatArgs: Any?): String { + return try { + resources.getString(id, *formatArgs) + } catch (_: IllegalFormatException) { + try { + englishResources().getString(id, *formatArgs) + } catch (_: IllegalFormatException) { + resources.getString(id) + } + } +} + +private fun Context.englishResources() = + createConfigurationContext( + Configuration(resources.configuration).apply { + setLocale(Locale.ENGLISH) + } + ).resources diff --git a/app/src/main/java/com/aryan/reader/SettingsScreen.kt b/app/src/main/java/com/aryan/reader/SettingsScreen.kt index 70dcd58..f3c2c69 100644 --- a/app/src/main/java/com/aryan/reader/SettingsScreen.kt +++ b/app/src/main/java/com/aryan/reader/SettingsScreen.kt @@ -29,11 +29,8 @@ import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings -import com.aryan.reader.epubreader.PageInfoMode as AndroidPageInfoMode -import com.aryan.reader.epubreader.PageInfoPosition as AndroidPageInfoPosition import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign -import com.aryan.reader.epubreader.SystemUiMode as AndroidSystemUiMode import com.aryan.reader.epubreader.loadFormatSettings import com.aryan.reader.epubreader.loadPageInfoMode import com.aryan.reader.epubreader.loadPageInfoPosition @@ -56,12 +53,11 @@ import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible import com.aryan.reader.shared.BuiltInPdfReaderThemes import com.aryan.reader.shared.CustomFontItem -import com.aryan.reader.shared.PageInfoMode as SharedPageInfoMode -import com.aryan.reader.shared.PageInfoPosition as SharedPageInfoPosition import com.aryan.reader.shared.SharedSettingsAction import com.aryan.reader.shared.SharedSettingsDestination -import com.aryan.reader.shared.SystemUiMode as SharedSystemUiMode import com.aryan.reader.shared.parentDestination +import com.aryan.reader.shared.toReaderSettingsFontFamily +import com.aryan.reader.shared.toSharedReaderTextAlign import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.SharedReaderTextAlign @@ -204,6 +200,9 @@ fun SettingsScreen( } } SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true + SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME -> { + viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName) + } SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> { val next = !uiState.isScreenCaptureProtectionEnabled viewModel.setScreenCaptureProtectionEnabled(next) @@ -438,9 +437,9 @@ private fun loadAndroidEpubReaderDefaultSettings( themeId = loadReaderThemeId(context), textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), customFontPath = format.customPath?.takeIf { it.isNotBlank() }, - systemUiMode = loadSystemUiMode(context).toSharedSystemUiMode(), - pageInfoMode = loadPageInfoMode(context).toSharedPageInfoMode(), - pageInfoPosition = loadPageInfoPosition(context).toSharedPageInfoPosition(), + systemUiMode = loadSystemUiMode(context), + pageInfoMode = loadPageInfoMode(context), + pageInfoPosition = loadPageInfoPosition(context), seamlessChapterNavigation = loadPullToTurn(context), chapterTurnDragMultiplier = loadPullToTurnMultiplier(context) ) @@ -453,7 +452,7 @@ private fun loadAndroidPdfReaderDefaultSettings( val base = ReaderSettings( themeId = loadPdfThemeId(context), textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), - systemUiMode = loadPdfSystemUiMode(context).toSharedSystemUiMode(), + systemUiMode = loadPdfSystemUiMode(context), pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context), pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context) ) @@ -476,9 +475,9 @@ private fun saveAndroidEpubReaderDefaultSettings( customFontPath = settings.customFontPath, textAlign = settings.textAlign.toAndroidTextAlign() ) - saveSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode()) - savePageInfoMode(context, settings.pageInfoMode.toAndroidPageInfoMode()) - savePageInfoPosition(context, settings.pageInfoPosition.toAndroidPageInfoPosition()) + saveSystemUiMode(context, settings.systemUiMode) + savePageInfoMode(context, settings.pageInfoMode) + savePageInfoPosition(context, settings.pageInfoPosition) savePullToTurn(context, settings.seamlessChapterNavigation) savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier) saveReaderThemeId(context, settings.themeId ?: "system") @@ -489,7 +488,7 @@ private fun saveAndroidPdfReaderDefaultSettings( context: Context, settings: ReaderSettings ) { - savePdfSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode()) + savePdfSystemUiMode(context, settings.systemUiMode) savePdfThemeId(context, settings.themeId ?: "no_theme") savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible) savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible) @@ -514,23 +513,7 @@ private fun List.toSharedCustomFontItems(): List "Default" - AndroidReaderFont.MERRIWEATHER, - AndroidReaderFont.LORA -> "Serif" - AndroidReaderFont.LATO, - AndroidReaderFont.LEXEND -> "Sans" - AndroidReaderFont.ROBOTO_MONO -> "Mono" - } -} - -private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign { - return when (this) { - AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY - AndroidReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT - AndroidReaderTextAlign.DEFAULT, - AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START - } + ?: font.toReaderSettingsFontFamily() } private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont { @@ -564,27 +547,3 @@ private fun ReaderSettings.toAndroidRenderMode(): RenderMode { ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL } } - -private fun AndroidSystemUiMode.toSharedSystemUiMode(): SharedSystemUiMode { - return SharedSystemUiMode.valueOf(name) -} - -private fun SharedSystemUiMode.toAndroidSystemUiMode(): AndroidSystemUiMode { - return AndroidSystemUiMode.valueOf(name) -} - -private fun AndroidPageInfoMode.toSharedPageInfoMode(): SharedPageInfoMode { - return SharedPageInfoMode.valueOf(name) -} - -private fun SharedPageInfoMode.toAndroidPageInfoMode(): AndroidPageInfoMode { - return AndroidPageInfoMode.valueOf(name) -} - -private fun AndroidPageInfoPosition.toSharedPageInfoPosition(): SharedPageInfoPosition { - return SharedPageInfoPosition.valueOf(name) -} - -private fun SharedPageInfoPosition.toAndroidPageInfoPosition(): AndroidPageInfoPosition { - return AndroidPageInfoPosition.valueOf(name) -} diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 878bdf8..71e3996 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -144,6 +144,7 @@ import androidx.core.net.toUri import androidx.core.text.HtmlCompat import com.aryan.reader.data.BookMetadataEdit import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.shared.SharedText import com.aryan.reader.shared.ui.SharedMarkdownText import timber.log.Timber import java.text.SimpleDateFormat @@ -239,7 +240,8 @@ fun rememberFilePickerLauncher( contract = ActivityResultContracts.OpenMultipleDocuments(), onResult = { uris: List -> if (uris.isNotEmpty()) { - Timber.d("${uris.size} file(s) selected.") + val fileLabel = if (uris.size == 1) "file" else "files" + Timber.d("${uris.size} $fileLabel selected.") onFilesSelected(uris) } else { Timber.d("File selection cancelled.") @@ -383,6 +385,7 @@ fun DeleteConfirmationDialog( @Composable fun FileInfoDialog( item: RecentFileItem, + usePdfFileNameAsDisplayName: Boolean = false, onDismiss: () -> Unit, onSaveMetadata: (BookMetadataEdit) -> Unit, onSaveDisplayName: (String?) -> Unit, @@ -399,8 +402,8 @@ fun FileInfoDialog( mutableStateOf(item.seriesIndex?.formatMetadataNumber().orEmpty()) } var descriptionInput by remember(item.bookId, item.description) { mutableStateOf(item.description.orEmpty()) } - var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName) { - mutableStateOf(item.customName ?: item.cardTitle()) + var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName, usePdfFileNameAsDisplayName) { + mutableStateOf(item.customName ?: item.cardTitle(usePdfFileNameAsDisplayName)) } var showRestoreConfirmation by remember(item.bookId) { mutableStateOf(false) } @@ -455,7 +458,7 @@ fun FileInfoDialog( } else { stringResource(R.string.file_information) }, - subtitle = item.cardTitle(), + subtitle = item.cardTitle(usePdfFileNameAsDisplayName), onClose = { if (isEditing) { isEditing = false @@ -498,6 +501,7 @@ fun FileInfoDialog( } else { BookMetadataInfoContent( item = item, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName, formattedDate = formattedDate, lastModifiedDate = lastModifiedDate, pathText = pathTextFinal, @@ -611,6 +615,7 @@ private fun FileInfoTopBar( @Composable private fun BookMetadataInfoContent( item: RecentFileItem, + usePdfFileNameAsDisplayName: Boolean, formattedDate: String, lastModifiedDate: String?, pathText: String, @@ -624,7 +629,7 @@ private fun BookMetadataInfoContent( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - item.cardTitle(), + item.cardTitle(usePdfFileNameAsDisplayName), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, maxLines = 3, @@ -1120,6 +1125,8 @@ private fun Double.formatMetadataNumber(): String { @Composable fun CustomTopBanner(bannerMessage: BannerMessage?) { + val context = LocalContext.current + val bannerText = bannerMessage?.localizedMessage(context).orEmpty() AnimatedVisibility( visible = bannerMessage != null, enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), @@ -1138,7 +1145,7 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) { shadowElevation = 8.dp ) { Text( - text = bannerMessage?.message ?: "", + text = bannerText, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer, style = MaterialTheme.typography.bodyMedium, @@ -1149,6 +1156,25 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) { } } +private fun BannerMessage.localizedMessage(context: Context): String { + return text?.resolveAndroidText(context) ?: message +} + +private fun SharedText.resolveAndroidText(context: Context): String { + val resources = context.resources + val packageName = context.packageName + val formatArgs = args.toTypedArray() + val quantityValue = quantity + val resolved = if (quantityValue == null) { + val id = resources.getIdentifier(name, "string", packageName) + if (id == 0) null else runCatching { resources.getString(id, *formatArgs) }.getOrNull() + } else { + val id = resources.getIdentifier(name, "plurals", packageName) + if (id == 0) null else runCatching { resources.getQuantityString(id, quantityValue, *formatArgs) }.getOrNull() + } + return resolved ?: fallbackMessage() +} + @Suppress("KotlinConstantConditions") @Composable fun AboutDialog(onDismiss: () -> Unit) { @@ -1429,7 +1455,12 @@ fun AutoSizeText( } @Composable -fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) { +fun FileTypeBadge( + type: FileType, + modifier: Modifier = Modifier, + overlay: Boolean = false, + compact: Boolean = false +) { val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer @@ -1442,9 +1473,17 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea ) { Text( text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(), - style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), + style = if (compact) { + MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp, letterSpacing = 0.sp) + } else { + MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp) + }, fontWeight = FontWeight.ExtraBold, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + maxLines = 1, + modifier = Modifier.padding( + horizontal = if (compact) 6.dp else 10.dp, + vertical = if (compact) 3.dp else 4.dp + ) ) } } @@ -1498,8 +1537,12 @@ fun BookTagChipsRow( private const val UNKNOWN_AUTHOR_LABEL = "No author listed" -fun RecentFileItem.cardTitle(): String { - return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName +fun RecentFileItem.cardTitle(usePdfFileNameAsDisplayName: Boolean = false): String { + customName?.takeIf { it.isNotBlank() }?.let { return it } + if (usePdfFileNameAsDisplayName && type == FileType.PDF) { + return displayName + } + return title?.takeIf { it.isNotBlank() } ?: displayName } fun RecentFileItem.cardAuthor(): String { diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt index abc0c11..fa6c19e 100644 --- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt +++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt @@ -5,31 +5,34 @@ 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.EpubAnnotationSerializer 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.SharedReaderScreenState import com.aryan.reader.shared.Shelf as SharedShelf import com.aryan.reader.shared.ShelfRecord -import com.aryan.reader.shared.ShelfType as SharedShelfType -import com.aryan.reader.shared.SortOrder as SharedSortOrder import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder import com.aryan.reader.shared.Tag as SharedTag +fun FileType.toSharedFileType(): SharedFileType = this + +fun SharedFileType.toAndroidFileType(): FileType = this + +fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters = this + +fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters = this + +fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this + +fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this + fun RecentFileItem.toSharedBookItem(): SharedBookItem { return SharedBookItem( id = bookId, path = uriString, - type = type.toSharedFileType(), + type = type, displayName = customName ?: displayName, timestamp = timestamp, coverImagePath = coverImagePath, @@ -67,7 +70,7 @@ fun SharedBookItem.toRecentFileItem( return androidBooksById[id]?.copy(tags = resolvedTags) ?.copy( uriString = path, - type = type.toAndroidFileType(), + type = type, displayName = androidBooksById[id]?.displayName ?: displayName, timestamp = timestamp, coverImagePath = coverImagePath, @@ -92,7 +95,7 @@ fun SharedBookItem.toRecentFileItem( ?: RecentFileItem( bookId = id, uriString = path, - type = type.toAndroidFileType(), + type = type, displayName = displayName, timestamp = timestamp, coverImagePath = coverImagePath, @@ -169,11 +172,11 @@ fun ReaderScreenState.toSharedReaderScreenState( return SharedReaderScreenState( selectedBookId = selectedBookId, selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(), - selectedFileType = selectedFileType?.toSharedFileType(), + selectedFileType = selectedFileType, isLoading = isLoading, errorMessage = errorMessage, - renderMode = renderMode.toSharedRenderMode(), - sortOrder = sortOrder.toSharedSortOrder(), + renderMode = renderMode, + sortOrder = sortOrder, viewingShelfId = viewingShelfId, isAddingBooksToShelf = isAddingBooksToShelf, showCreateShelfDialog = showCreateShelfDialog, @@ -181,7 +184,7 @@ fun ReaderScreenState.toSharedReaderScreenState( libraryScreenStartPage = libraryScreenStartPage, showRenameShelfDialogFor = showRenameShelfDialogFor, showDeleteShelfDialogFor = showDeleteShelfDialogFor, - addBooksSource = addBooksSource.toSharedAddBooksSource(), + addBooksSource = addBooksSource, booksSelectedForAdding = booksSelectedForAdding, selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId }, selectedShelfIds = contextualActionShelfIds, @@ -189,10 +192,10 @@ fun ReaderScreenState.toSharedReaderScreenState( credits = credits, isSyncEnabled = isSyncEnabled, isFolderSyncEnabled = isFolderSyncEnabled, - bannerMessage = bannerMessage?.toSharedBannerMessage(), + bannerMessage = bannerMessage, downloadingBookIds = downloadingBookIds, uploadingBookIds = uploadingBookIds, - syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() }, + syncedFolders = syncedFolders, lastFolderScanTime = lastFolderScanTime, hasUnreadFeedback = hasUnreadFeedback, searchQuery = searchQuery, @@ -204,7 +207,7 @@ fun ReaderScreenState.toSharedReaderScreenState( rawLibraryBooks = rawBooks.map { it.toSharedBookItem() }, pinnedHomeBookIds = pinnedHomeBookIds, pinnedLibraryBookIds = pinnedLibraryBookIds, - libraryFilters = libraryFilters.toSharedLibraryFilters(), + libraryFilters = libraryFilters, recentFilesLimit = recentFilesLimit, isTabsEnabled = isTabsEnabled, openTabIds = openTabIds, @@ -213,12 +216,14 @@ fun ReaderScreenState.toSharedReaderScreenState( showExternalFileSavePromptFor = showExternalFileSavePromptFor, externalFileBehavior = externalFileBehavior, useStrictFileFilter = useStrictFileFilter, - appThemeMode = appThemeMode.toSharedAppThemeMode(), - appContrastOption = appContrastOption.toSharedAppContrastOption(), + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName, + appThemeMode = appThemeMode, + appContrastOption = appContrastOption, appTextDimFactorLight = appTextDimFactorLight, appTextDimFactorDark = appTextDimFactorDark, appSeedColor = appSeedColor, - customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() }, + appFontPreference = appFontPreference, + customAppThemes = customAppThemes, allTags = dbTags.map { it.toSharedTag() }, showTagSelectionDialogFor = showTagSelectionDialogFor ) @@ -271,7 +276,7 @@ fun SharedShelf.toAndroidShelf( return Shelf( id = id, name = name, - type = type.toAndroidShelfType(), + type = type, books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, parentShelfId = parentShelfId, @@ -280,91 +285,3 @@ fun SharedShelf.toAndroidShelf( sortKey = sortKey ) } - -fun FileType.toSharedFileType(): SharedFileType { - return this -} - -fun SharedFileType.toAndroidFileType(): FileType { - return this -} - -fun RenderMode.toSharedRenderMode(): SharedRenderMode { - return this -} - -fun SharedRenderMode.toAndroidRenderMode(): RenderMode { - return this -} - -fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource { - return this -} - -fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource { - return this -} - -fun SortOrder.toSharedSortOrder(): SharedSortOrder { - return this -} - -fun SharedSortOrder.toAndroidSortOrder(): SortOrder { - return this -} - -fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter { - return this -} - -fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter { - return this -} - -fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters { - return this -} - -fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters { - return this -} - -fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder { - return this -} - -fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder { - return this -} - -private fun SharedShelfType.toAndroidShelfType(): ShelfType { - return ShelfType.valueOf(name) -} - -fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage { - return this -} - -fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode { - return this -} - -fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode { - return this -} - -fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption { - return this -} - -fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption { - return this -} - -fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme { - return this -} - -fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme { - return this -} diff --git a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt index 260890c..7d5a7a0 100644 --- a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt +++ b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt @@ -2,7 +2,7 @@ package com.aryan.reader.data import com.aryan.reader.FileType -import org.json.JSONObject +import com.aryan.reader.shared.SharedFolderBookMetadata data class FolderBookMetadata( val bookId: String, @@ -31,67 +31,76 @@ data class FolderBookMetadata( val originalDescription: String? = null ) { fun toJsonString(): String { - val json = JSONObject() - json.put("bookId", bookId) - json.put("displayName", displayName) - json.put("type", type) - json.put("lastChapterIndex", lastChapterIndex ?: -1) - json.put("lastPage", lastPage ?: -1) - json.put("lastPositionCfi", lastPositionCfi) - json.put("progressPercentage", progressPercentage.toDouble()) - json.put("isRecent", isRecent) - json.put("lastModifiedTimestamp", lastModifiedTimestamp) - json.put("bookmarksJson", bookmarksJson) - json.put("locatorBlockIndex", locatorBlockIndex ?: -1) - json.put("locatorCharOffset", locatorCharOffset ?: -1) - json.put("customName", customName) - json.put("highlightsJson", highlightsJson) - return json.toString() + return toSharedFolderBookMetadata().toJsonString() } companion object { fun fromJsonString(jsonString: String): FolderBookMetadata { - val json = JSONObject(jsonString) - - fun JSONObject.optStringNull(key: String): String? { - return if (has(key) && !isNull(key)) getString(key) else null - } - - fun JSONObject.optIntNull(key: String): Int? { - val value = optInt(key, -1) - return if (value == -1) null else value - } - - return FolderBookMetadata( - bookId = json.getString("bookId"), - title = null, - author = null, - displayName = json.optString("displayName", "Unknown"), - type = json.optString("type", "PDF"), - lastChapterIndex = json.optIntNull("lastChapterIndex"), - lastPage = json.optIntNull("lastPage"), - lastPositionCfi = json.optStringNull("lastPositionCfi"), - progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(), - isRecent = json.optBoolean("isRecent", true), - lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L), - bookmarksJson = json.optStringNull("bookmarksJson"), - locatorBlockIndex = json.optIntNull("locatorBlockIndex"), - locatorCharOffset = json.optIntNull("locatorCharOffset"), - customName = json.optStringNull("customName"), - highlightsJson = json.optStringNull("highlightsJson"), - seriesName = null, - seriesIndex = null, - description = null, - originalTitle = null, - originalAuthor = null, - originalSeriesName = null, - originalSeriesIndex = null, - originalDescription = null - ) + return SharedFolderBookMetadata.fromJsonString(jsonString) + ?.toFolderBookMetadata() + ?: error("Invalid folder metadata JSON") } } } +fun FolderBookMetadata.toSharedFolderBookMetadata(): SharedFolderBookMetadata { + return SharedFolderBookMetadata( + bookId = bookId, + title = title, + author = author, + displayName = displayName, + type = type, + lastChapterIndex = lastChapterIndex, + lastPage = lastPage, + lastPositionCfi = lastPositionCfi, + progressPercentage = progressPercentage, + isRecent = isRecent, + lastModifiedTimestamp = lastModifiedTimestamp, + bookmarksJson = bookmarksJson, + locatorBlockIndex = locatorBlockIndex, + locatorCharOffset = locatorCharOffset, + customName = customName, + highlightsJson = highlightsJson, + seriesName = seriesName, + seriesIndex = seriesIndex, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription + ) +} + +fun SharedFolderBookMetadata.toFolderBookMetadata(): FolderBookMetadata { + return FolderBookMetadata( + bookId = bookId, + title = title, + author = author, + displayName = displayName, + type = type, + lastChapterIndex = lastChapterIndex, + lastPage = lastPage, + lastPositionCfi = lastPositionCfi, + progressPercentage = progressPercentage, + isRecent = isRecent, + lastModifiedTimestamp = lastModifiedTimestamp, + bookmarksJson = bookmarksJson, + locatorBlockIndex = locatorBlockIndex, + locatorCharOffset = locatorCharOffset, + customName = customName, + highlightsJson = highlightsJson, + seriesName = seriesName, + seriesIndex = seriesIndex, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription + ) +} + fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem { return RecentFileItem( bookId = this.bookId, diff --git a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt index 3b5be54..ad07f81 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt @@ -53,6 +53,10 @@ data class EpubBook( val description: String? = null, ) +fun epubContentFilePath(path: String): String = path.substringBefore('#').substringBefore('?') + +fun EpubChapter.contentFilePath(): String = epubContentFilePath(htmlFilePath) + fun EpubBook.hasReadableExtractedContent(): Boolean { if (extractionBasePath.isBlank()) return false val extractionDir = File(extractionBasePath) @@ -60,6 +64,6 @@ fun EpubBook.hasReadableExtractedContent(): Boolean { if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true return chapters.all { chapter -> - File(extractionDir, chapter.htmlFilePath).isFile + File(extractionDir, chapter.contentFilePath()).isFile } } diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 155377c..0d6c679 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -52,6 +52,11 @@ class SingleFileImporter(private val context: Context) { companion object { private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L + private const val MAX_HTML_CHAPTER_CHARS = 1_000_000 + private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000 + private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000 + private const val MAX_HTML_INLINE_CSS_CHARS = 256_000 + private const val PAGE_BREAK_MARKER = "" } private val htmlSafelist = Safelist.relaxed() @@ -519,57 +524,140 @@ class SingleFileImporter(private val context: Context) { var pageNum = 1 val headBuilder = java.lang.StringBuilder() val currentChapterBuilder = java.lang.StringBuilder() + var titleFound = false + var authorFound = false - var line: String? - while (reader.readLine().also { line = it } != null) { - val trimmed = line!!.trim() + fun appendCss(style: String) { + if (style.isBlank() || cssBuilder.length >= MAX_HTML_INLINE_CSS_CHARS) return + val remaining = MAX_HTML_INLINE_CSS_CHARS - cssBuilder.length + cssBuilder.append(style, 0, minOf(style.length, remaining)).append('\n') + } + + fun appendHeadSample(sample: String) { + if (headBuilder.length >= MAX_HTML_HEAD_SCAN_CHARS) return + val remaining = MAX_HTML_HEAD_SCAN_CHARS - headBuilder.length + headBuilder.append(sample, 0, minOf(sample.length, remaining)).append('\n') + } + + fun flushChapter() { + if (currentChapterBuilder.isBlank()) { + currentChapterBuilder.clear() + return + } + chapters.add( + writeHtmlChapter( + extractionDir, + bookId, + pageNum++, + title, + cssBuilder.toString(), + currentChapterBuilder.toString() + ) + ) + currentChapterBuilder.clear() + } + + fun appendBodySegment(segment: String, startIndex: Int = 0, endIndex: Int = segment.length, addNewline: Boolean = true) { + var start = startIndex + while (start < endIndex) { + val remainingCapacity = (MAX_HTML_CHAPTER_CHARS - currentChapterBuilder.length).coerceAtLeast(1) + val requestedEnd = minOf(endIndex, start + remainingCapacity) + val chunkEnd = findHtmlChunkEnd(segment, start, requestedEnd, endIndex) + currentChapterBuilder.append(segment, start, chunkEnd) + start = chunkEnd + if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) { + flushChapter() + } + } + if (addNewline) { + currentChapterBuilder.append('\n') + if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) { + flushChapter() + } + } + } + + fun appendBodyLine(line: String) { + var start = 0 + var markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true) + while (markerIndex >= 0) { + appendBodySegment(line, start, markerIndex) + flushChapter() + start = markerIndex + PAGE_BREAK_MARKER.length + markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true) + } + appendBodySegment(line, start, line.length) + } + + reader.forEachBoundedLine(MAX_HTML_BUFFERED_LINE_CHARS) { line -> + val trimmed = line.trim() if (inScript) { if (trimmed.contains("").substringBefore("") - if (styleContent.isNotBlank()) cssBuilder.append(styleContent).append("\n") + appendCss(styleContent) if (trimmed.contains("")) { inStyle = false } - continue + return@forEachBoundedLine } if (inStyle) { if (trimmed.contains("")) { - cssBuilder.append(line.substringBefore("")).append("\n") + appendCss(line.substringBefore("")) inStyle = false } else { - cssBuilder.append(line).append("\n") + appendCss(line) } - continue + return@forEachBoundedLine } if (trimmed.equals("", ignoreCase = true)) { inBody = true - continue + return@forEachBoundedLine } if (trimmed.startsWith("", "") - if (afterBody.isNotBlank()) currentChapterBuilder.append(afterBody).append("\n") - continue + if (afterBody.isNotBlank()) appendBodyLine(afterBody) + return@forEachBoundedLine + } + val embeddedBodyIndex = line.indexOf("= 0) { + val bodyContentIndex = line.indexOf('>', startIndex = embeddedBodyIndex) + if (bodyContentIndex >= 0) { + inBody = true + val afterBody = line.substring(bodyContentIndex + 1) + if (afterBody.isNotBlank()) appendBodyLine(afterBody) + return@forEachBoundedLine + } } if (trimmed.startsWith("") || (trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("", ignoreCase = true) || trimmed.equals("", ignoreCase = true)) { - continue + return@forEachBoundedLine } - if (line.contains("")) { - val parts = line.split("") - for (i in parts.indices) { - currentChapterBuilder.append(parts[i]).append("\n") - if (i < parts.size - 1) { - val chapterHtml = currentChapterBuilder.toString() - if (chapterHtml.isNotBlank()) { - chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), chapterHtml)) - } - currentChapterBuilder.clear() // Clean memory allocation - } - } - continue - } - - currentChapterBuilder.append(line).append("\n") - - if (currentChapterBuilder.length > 2_000_000) { - chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), currentChapterBuilder.toString())) - currentChapterBuilder.clear() - } + appendBodyLine(line) } } - val finalChapterHtml = currentChapterBuilder.toString() - if (finalChapterHtml.isNotBlank()) { - chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), finalChapterHtml)) - } + flushChapter() } if (chapters.isEmpty()) { @@ -761,7 +826,7 @@ class SingleFileImporter(private val context: Context) { FileOutputStream(tempFile).bufferedWriter().use { writer -> val title = originalBookNameHint.substringBeforeLast(".") writer.write("\n\n\n$title\n\n\n") - writer.write(htmlContent) + writeHtmlBodyContentChunked(writer, htmlContent) writer.write("\n\n") } @@ -816,12 +881,19 @@ class SingleFileImporter(private val context: Context) { val fileName = "page_$pageNum.html" val file = File(extractionDir, fileName) val sanitizedBodyContent = sanitizeHtmlFragment(bodyContent) + val escapedTitle = title.replace("\"", """) - val fullHtml = "\n\n\n${title.replace("\"", """)}\n\n\n\n${sanitizedBodyContent.trim()}\n\n" + file.bufferedWriter().use { writer -> + writer.write("\n\n\n") + writer.write(escapedTitle) + writer.write("\n\n\n\n") + writer.write(sanitizedBodyContent.trim()) + writer.write("\n\n") + } - file.writeText(fullHtml) - - val plainText = Jsoup.parse(fullHtml).text() + val plainText = Jsoup.parse(sanitizedBodyContent).text() return EpubChapter( chapterId = "${bookId}_$pageNum", @@ -834,4 +906,70 @@ class SingleFileImporter(private val context: Context) { isInToc = true ) } + + private fun java.io.BufferedReader.forEachBoundedLine( + maxLineChars: Int, + onLine: (String) -> Unit + ) { + val buffer = CharArray(16_384) + val currentLine = StringBuilder() + while (true) { + val read = read(buffer) + if (read == -1) break + + var start = 0 + var index = 0 + while (index < read) { + val char = buffer[index] + val reachesLimit = currentLine.length + (index - start + 1) >= maxLineChars + if (char == '\n' || reachesLimit) { + val count = index - start + if (char == '\n') 0 else 1 + if (count > 0) { + currentLine.append(buffer, start, count) + } + onLine(currentLine.toString().trimEnd('\r')) + currentLine.clear() + start = index + 1 + } + index++ + } + + if (start < read) { + currentLine.append(buffer, start, read - start) + } + } + + if (currentLine.isNotEmpty()) { + onLine(currentLine.toString().trimEnd('\r')) + } + } + + private fun findHtmlChunkEnd( + source: String, + start: Int, + requestedEnd: Int, + absoluteEnd: Int + ): Int { + if (requestedEnd >= absoluteEnd) return absoluteEnd + + val tagStart = source.lastIndexOf('<', requestedEnd - 1) + val tagEnd = source.lastIndexOf('>', requestedEnd - 1) + if (tagStart > start && tagStart > tagEnd && requestedEnd - tagStart <= 4096) { + return tagStart + } + + return requestedEnd + } + + private fun writeHtmlBodyContentChunked(writer: java.io.Writer, htmlContent: String) { + var charsSinceBreak = 0 + for (char in htmlContent) { + writer.write(char.code) + charsSinceBreak++ + if (char == '>' || charsSinceBreak >= MAX_HTML_BUFFERED_LINE_CHARS) { + writer.write('\n'.code) + charsSinceBreak = 0 + } + } + } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 86d2353..34001a3 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -468,6 +468,9 @@ fun ChapterWebView( ttsScope: CoroutineScope, tocFragments: List, initialFragmentId: String? = null, + initialImageSource: String? = null, + initialImageOriginalSource: String? = null, + initialImageOrdinal: Int = 0, onTtsTextReady: suspend (String) -> Unit, isProUser: Boolean, isOss: Boolean = false, @@ -1011,6 +1014,14 @@ fun ChapterWebView( } onChapterInitiallyScrolled() scrollActionTaken = true + } else if (!initialImageSource.isNullOrBlank()) { + val imageJsCommand = + "javascript:window.scrollToReaderImageSource('${escapeJsString(initialImageSource)}', $initialImageOrdinal, '${escapeJsString(initialImageOriginalSource.orEmpty())}');" + Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to image source: $initialImageSource") + view?.evaluateJavascript(imageJsCommand) { + onChapterInitiallyScrolled() + scrollActionTaken = true + } } else if (initialScrollTarget != null) { val scrollJsCommand = when (initialScrollTarget) { ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();" diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt index 672a4e4..176176e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt @@ -41,6 +41,7 @@ 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.epub.contentFilePath import com.aryan.reader.fetchRecap import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.summarizationUrl @@ -209,8 +210,7 @@ suspend fun executeRecapLogic( val textToSummarize = paginator?.getPlainTextForChapter(i) ?: withContext(Dispatchers.IO) { try { val chapter = chapters[i] - val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" - val doc = Jsoup.parse(File(fullPath), "UTF-8") + val doc = Jsoup.parse(File(epubBook.extractionBasePath, chapter.contentFilePath()), "UTF-8") doc.body().text() } catch (_: Exception) { "" } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt index 420c1d6..0e9d8c6 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt @@ -23,10 +23,13 @@ import android.content.Context import com.aryan.reader.R import timber.log.Timber import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.contentFilePath import com.aryan.reader.paginatedreader.LocatorConverter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node import java.io.File data class ChapterLoadingResult( @@ -34,9 +37,44 @@ data class ChapterLoadingResult( val chunks: List, val startChunkIndex: Int, val isSuccess: Boolean, - val errorMessage: String? = null + val errorMessage: String? = null, + val chunkElementStartIndices: List = emptyList(), + val chunkElementCounts: List = emptyList() ) +internal data class ReaderHtmlChunk( + val html: String, + val elementStartIndex: Int, + val elementCount: Int +) + +internal fun splitBodyNodesIntoReaderChunks( + bodyNodes: List, + chunkSize: Int = 20 +): List { + var elementStartIndex = 0 + return bodyNodes.chunked(chunkSize).map { nodes -> + val elementCount = nodes.count { it is Element } + ReaderHtmlChunk( + html = nodes.joinToString(separator = "\n") { it.outerHtml() }, + elementStartIndex = elementStartIndex, + elementCount = elementCount + ).also { + elementStartIndex += elementCount + } + } +} + +internal fun readerChunkContainerAttributes( + index: Int, + chunkElementStartIndices: List, + chunkElementCounts: List +): String { + val startIndex = chunkElementStartIndices.getOrElse(index) { index * 20 } + val elementCount = chunkElementCounts.getOrElse(index) { 20 } + return "data-chunk-index='$index' data-element-start-index='$startIndex' data-element-count='$elementCount'" +} + /** * loads the chapter HTML, splits it into chunks, and calculates * the initial chunk to display based on navigation state (CFI, overrides, etc.). @@ -56,24 +94,36 @@ suspend fun loadChapterContent( ) try { - val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" - val htmlFile = File(fullPath) + val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath()) - val (headContent, chunks) = if (htmlFile.exists()) { + val (headContent, chunks, chunkElementStartIndices, chunkElementCounts) = if (htmlFile.exists()) { val doc = Jsoup.parse(htmlFile, "UTF-8") val head = doc.head().html() doc.select("script").remove() val bodyNodes = doc.body().childNodes().toList() - val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes -> - chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() } - } - if (chunkedList.isEmpty()) { - head to listOf("

${context.getString(R.string.chapter_empty)}

") + val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes) + if (htmlChunks.isEmpty()) { + ChapterHtmlPayload( + head = head, + chunks = listOf("

${context.getString(R.string.chapter_empty)}

"), + chunkElementStartIndices = listOf(0), + chunkElementCounts = listOf(1) + ) } else { - head to chunkedList + ChapterHtmlPayload( + head = head, + chunks = htmlChunks.map { it.html }, + chunkElementStartIndices = htmlChunks.map { it.elementStartIndex }, + chunkElementCounts = htmlChunks.map { it.elementCount } + ) } } else { - "" to listOf("

${context.getString(R.string.chapter_not_found)}

") + ChapterHtmlPayload( + head = "", + chunks = listOf("

${context.getString(R.string.chapter_not_found)}

"), + chunkElementStartIndices = listOf(0), + chunkElementCounts = listOf(1) + ) } var targetChunk = 0 @@ -101,7 +151,9 @@ suspend fun loadChapterContent( head = headContent, chunks = chunks, startChunkIndex = targetChunk, - isSuccess = true + isSuccess = true, + chunkElementStartIndices = chunkElementStartIndices, + chunkElementCounts = chunkElementCounts ) } catch (e: Exception) { @@ -114,4 +166,11 @@ suspend fun loadChapterContent( errorMessage = e.message ) } -} \ No newline at end of file +} + +private data class ChapterHtmlPayload( + val head: String, + val chunks: List, + val chunkElementStartIndices: List, + val chunkElementCounts: List +) diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index a99801d..c3799da 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -52,7 +52,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -62,7 +61,6 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding @@ -83,6 +81,7 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Pause @@ -92,6 +91,8 @@ import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.ScreenRotation import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.CircularProgressIndicator @@ -119,6 +120,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale @@ -157,12 +159,14 @@ import kotlin.math.roundToInt enum class ReaderTool(@StringRes val titleRes: Int, val category: String) { DICTIONARY(R.string.tool_external_apps, "Top Bar"), THEME(R.string.tooltip_theme_desc, "Top Bar"), + BRIGHTNESS(R.string.tool_brightness, "Top Bar"), SLIDER(R.string.tool_navigation_slider, "Bottom Bar"), TOC(R.string.tool_sidebar, "Bottom Bar"), FORMAT(R.string.content_desc_text_formatting, "Bottom Bar"), SEARCH(R.string.action_search, "Bottom Bar"), AI_FEATURES(R.string.ai_features_title, "Bottom Bar"), TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"), + FILE_INFO(R.string.file_information, "Overflow Menu"), READING_MODE(R.string.tool_reading_mode, "Overflow Menu"), BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"), TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"), @@ -259,6 +263,7 @@ class DragDropState( private val epubToolbarTools = setOf( ReaderTool.DICTIONARY, ReaderTool.THEME, + ReaderTool.BRIGHTNESS, ReaderTool.SLIDER, ReaderTool.TOC, ReaderTool.FORMAT, @@ -268,7 +273,57 @@ private val epubToolbarTools = setOf( ReaderTool.SCREEN_ORIENTATION ) -internal fun defaultReaderHiddenTools(): Set = setOf(ReaderTool.SCREEN_ORIENTATION.name) +internal enum class EpubOverflowMenuSection { + CUSTOMIZE_TOOLBAR, + HIDDEN_TOOLS, + VIEW_ORIGINAL_PDF, + DELETE_TEXT_VIEW, + READING_MODE, + BOOKMARK, + TAP_TO_TURN, + VOLUME_SCROLL, + PAGE_TURN_ANIM, + KEEP_SCREEN_ON, + VISUAL_OPTIONS, + AUTO_SCROLL, + TTS_SETTINGS, + FILE_INFO +} + +internal fun epubOverflowMenuSections( + hiddenTools: Set, + hasHiddenToolbarTools: Boolean, + hasToggleReflow: Boolean, + hasDeleteReflow: Boolean, + hasFileInfo: Boolean = true +): List = buildList { + add(EpubOverflowMenuSection.CUSTOMIZE_TOOLBAR) + if (hasHiddenToolbarTools) add(EpubOverflowMenuSection.HIDDEN_TOOLS) + if (hasToggleReflow) add(EpubOverflowMenuSection.VIEW_ORIGINAL_PDF) + if (hasDeleteReflow) add(EpubOverflowMenuSection.DELETE_TEXT_VIEW) + if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) add(EpubOverflowMenuSection.READING_MODE) + if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) add(EpubOverflowMenuSection.BOOKMARK) + if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) add(EpubOverflowMenuSection.TAP_TO_TURN) + if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) add(EpubOverflowMenuSection.VOLUME_SCROLL) + if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) add(EpubOverflowMenuSection.PAGE_TURN_ANIM) + if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON) + if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS) + if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL) + if ( + !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) || + !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name) + ) { + add(EpubOverflowMenuSection.TTS_SETTINGS) + } + if (hasFileInfo && !hiddenTools.contains(ReaderTool.FILE_INFO.name)) { + add(EpubOverflowMenuSection.FILE_INFO) + } +} + +internal fun defaultReaderHiddenTools(): Set = setOf( + ReaderTool.SCREEN_ORIENTATION.name, + ReaderTool.BRIGHTNESS.name +) internal fun defaultReaderToolOrder(): List = ReaderTool.entries.toList() @@ -321,6 +376,7 @@ fun EpubReaderTopBar( currentRenderMode: RenderMode, isBookmarked: Boolean, isTtsActive: Boolean, + isSliderActive: Boolean, tapToNavigateEnabled: Boolean, volumeScrollEnabled: Boolean, isPageTurnAnimationEnabled: Boolean, @@ -340,6 +396,7 @@ fun EpubReaderTopBar( onOpenTtsReplacements: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, + onOpenBrightness: () -> Unit, onOpenVisualOptions: () -> Unit, onOpenScreenOrientation: () -> Unit, onOpenSlider: () -> Unit, @@ -348,6 +405,7 @@ fun EpubReaderTopBar( onToggleSearch: () -> Unit, onOpenAiHub: () -> Unit, onToggleTts: () -> Unit, + onOpenFileInfo: () -> Unit, searchFocusRequester: androidx.compose.ui.focus.FocusRequester, hiddenTools: Set, toolOrder: List, @@ -420,13 +478,23 @@ fun EpubReaderTopBar( ) { Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc)) } + ReaderTool.BRIGHTNESS -> TooltipIconButton( + text = stringResource(R.string.reader_brightness_title), + description = stringResource(R.string.reader_brightness_system_desc), + onClick = onOpenBrightness + ) { + Icon(painter = painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title)) + } ReaderTool.SLIDER -> TooltipIconButton( text = stringResource(R.string.tooltip_slider), description = stringResource(R.string.tooltip_slider_desc), - onClick = onOpenSlider, - enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL + onClick = onOpenSlider ) { - Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider)) + Icon( + painter = painterResource(id = R.drawable.slider), + contentDescription = stringResource(R.string.content_desc_navigate_slider), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) } ReaderTool.TOC -> TooltipIconButton( text = stringResource(R.string.tooltip_toc), @@ -511,318 +579,330 @@ fun EpubReaderTopBar( } ) { val hiddenToolbarTools = toolOrder.filter { it in epubToolbarTools && hiddenTools.contains(it.name) } - DropdownMenuItem( - text = { Text(stringResource(R.string.title_customize_toolbar)) }, - onClick = { - showMoreMenu = false - onCustomizeTools() - }, - leadingIcon = { - Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp)) - } - ) - HorizontalDivider() - - if (hiddenToolbarTools.isNotEmpty()) { - DropdownMenuItem( - text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) }, - onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f) - ) - } - ) - if (showHiddenToolsExpanded) { - hiddenToolbarTools.forEach { tool -> - HiddenEpubToolMenuItem( - tool = tool, - currentRenderMode = currentRenderMode, - isTtsActive = isTtsActive, - showMoreMenu = { - showHiddenToolsExpanded = false - showMoreMenu = false - }, - onOpenDictionarySettings = onOpenDictionarySettings, - onOpenThemeSettings = onOpenThemeSettings, - onOpenSlider = onOpenSlider, - onOpenDrawer = onOpenDrawer, - onToggleFormat = onToggleFormat, - onToggleSearch = onToggleSearch, - onOpenAiHub = onOpenAiHub, - onToggleTts = onToggleTts, - onOpenScreenOrientation = onOpenScreenOrientation - ) - } - } - HorizontalDivider() - } - - if (onToggleReflow != null) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_view_original_pdf)) }, - onClick = { - showMoreMenu = false - onToggleReflow() - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.picture_as_pdf), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - } - ) - HorizontalDivider() - } - - onDeleteReflow?.let { - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_delete_text_view)) }, - onClick = { - showMoreMenu = false - it() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - }, - colors = androidx.compose.material3.MenuDefaults.itemColors( - textColor = MaterialTheme.colorScheme.error - ) - ) - } - - if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_change_reading_mode)) }, - onClick = { showReadingModeExpanded = !showReadingModeExpanded }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f) - ) - } - ) - if (showReadingModeExpanded) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, - enabled = !isTtsActive, - onClick = { - showMoreMenu = false - onChangeRenderMode(RenderMode.VERTICAL_SCROLL) - }, - trailingIcon = { - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected) - ) - }) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, - enabled = !isTtsActive, - onClick = { - onSetRightToLeftPagination(false) - showMoreMenu = false - onChangeRenderMode(RenderMode.PAGINATED) - }, - trailingIcon = { - if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected) - ) - } - }) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_right_to_left_pagination)) }, - enabled = !isTtsActive, - onClick = { - onSetRightToLeftPagination(true) - showMoreMenu = false - onChangeRenderMode(RenderMode.PAGINATED) - }, - trailingIcon = { - if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected) - ) - } - }) - } - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) { - DropdownMenuItem(text = { - Text( - if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource( - R.string.menu_bookmark_this_page - ) - ) - }, onClick = { - showMoreMenu = false - onToggleBookmark() - }) - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, - enabled = currentRenderMode == RenderMode.PAGINATED, - onClick = { - onToggleTapToNavigate(!tapToNavigateEnabled) - showMoreMenu = false - }, - trailingIcon = { - if (tapToNavigateEnabled) Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_enabled) - ) - }) - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) { - DropdownMenuItem( - text = { - Text( - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource( - R.string.menu_volume_button_scrolling - ) - else stringResource(R.string.menu_volume_button_page_turn) - ) - }, - enabled = true, - onClick = { - onToggleVolumeScroll(!volumeScrollEnabled) - showMoreMenu = false - }, - trailingIcon = { - if (volumeScrollEnabled) Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_enabled) - ) - }) - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_realistic_page_turns)) }, - enabled = currentRenderMode == RenderMode.PAGINATED, - onClick = { - onTogglePageTurnAnimation(!isPageTurnAnimationEnabled) - showMoreMenu = false - }, - trailingIcon = { - if (isPageTurnAnimationEnabled) Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_enabled) - ) - }) - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_keep_screen_on)) }, - onClick = { - onToggleKeepScreenOn(!isKeepScreenOn) - showMoreMenu = false - }, - trailingIcon = { - if (isKeepScreenOn) Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_enabled) - ) - }) - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_visual_options)) }, - onClick = { - showMoreMenu = false - onOpenVisualOptions() - }, - leadingIcon = { - Icon( - Icons.Default.Visibility, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - }) - HorizontalDivider() - } - if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_auto_scroll)) }, - enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL, - onClick = { - showMoreMenu = false - onStartAutoScroll() - }) - HorizontalDivider() - } val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name) - if (showTtsVoiceSettings || showTtsReplacements) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_settings)) }, - onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded }, - leadingIcon = { - Icon( - Icons.Default.GraphicEq, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f) - ) - } - ) - if (showTtsSettingsExpanded) { - if (showTtsVoiceSettings) { + epubOverflowMenuSections( + hiddenTools = hiddenTools, + hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(), + hasToggleReflow = onToggleReflow != null, + hasDeleteReflow = onDeleteReflow != null + ).forEachIndexed { index, section -> + if (index > 0) HorizontalDivider() + when (section) { + EpubOverflowMenuSection.CUSTOMIZE_TOOLBAR -> { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, - enabled = !isTtsActive, + text = { Text(stringResource(R.string.title_customize_toolbar)) }, onClick = { showMoreMenu = false - onOpenTtsSettings() + onCustomizeTools() + }, + leadingIcon = { + Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp)) + } + ) + } + EpubOverflowMenuSection.HIDDEN_TOOLS -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) }, + onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f) + ) + } + ) + if (showHiddenToolsExpanded) { + hiddenToolbarTools.forEach { tool -> + HiddenEpubToolMenuItem( + tool = tool, + isSliderActive = isSliderActive, + showMoreMenu = { + showHiddenToolsExpanded = false + showMoreMenu = false + }, + onOpenDictionarySettings = onOpenDictionarySettings, + onOpenThemeSettings = onOpenThemeSettings, + onOpenBrightness = onOpenBrightness, + onOpenSlider = onOpenSlider, + onOpenDrawer = onOpenDrawer, + onToggleFormat = onToggleFormat, + onToggleSearch = onToggleSearch, + onOpenAiHub = onOpenAiHub, + onToggleTts = onToggleTts, + onOpenScreenOrientation = onOpenScreenOrientation + ) + } + } + } + EpubOverflowMenuSection.FILE_INFO -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.file_information)) }, + onClick = { + showMoreMenu = false + onOpenFileInfo() }, leadingIcon = { Icon( - Icons.Default.GraphicEq, + imageVector = Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp) ) } ) } - if (showTtsReplacements) { + EpubOverflowMenuSection.VIEW_ORIGINAL_PDF -> { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, + text = { Text(stringResource(R.string.menu_view_original_pdf)) }, onClick = { showMoreMenu = false - onOpenTtsReplacements() + onToggleReflow?.invoke() }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.picture_as_pdf), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } + EpubOverflowMenuSection.DELETE_TEXT_VIEW -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_delete_text_view)) }, + onClick = { + showMoreMenu = false + onDeleteReflow?.invoke() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + colors = androidx.compose.material3.MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error + ) + ) + } + EpubOverflowMenuSection.READING_MODE -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_change_reading_mode)) }, + onClick = { showReadingModeExpanded = !showReadingModeExpanded }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f) + ) + } + ) + if (showReadingModeExpanded) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, + enabled = !isTtsActive, + onClick = { + showMoreMenu = false + onChangeRenderMode(RenderMode.VERTICAL_SCROLL) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + }) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, + enabled = !isTtsActive, + onClick = { + onSetRightToLeftPagination(false) + showMoreMenu = false + onChangeRenderMode(RenderMode.PAGINATED) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) { + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + } + }) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_right_to_left_pagination)) }, + enabled = !isTtsActive, + onClick = { + onSetRightToLeftPagination(true) + showMoreMenu = false + onChangeRenderMode(RenderMode.PAGINATED) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) { + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + } + }) + } + } + EpubOverflowMenuSection.BOOKMARK -> { + DropdownMenuItem(text = { + Text( + if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource( + R.string.menu_bookmark_this_page + ) + ) + }, onClick = { + showMoreMenu = false + onToggleBookmark() + }) + } + EpubOverflowMenuSection.TAP_TO_TURN -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, + enabled = currentRenderMode == RenderMode.PAGINATED, + onClick = { + onToggleTapToNavigate(!tapToNavigateEnabled) + showMoreMenu = false + }, + trailingIcon = { + if (tapToNavigateEnabled) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + } + EpubOverflowMenuSection.VOLUME_SCROLL -> { + DropdownMenuItem( + text = { + Text( + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource( + R.string.menu_volume_button_scrolling + ) + else stringResource(R.string.menu_volume_button_page_turn) + ) + }, + enabled = true, + onClick = { + onToggleVolumeScroll(!volumeScrollEnabled) + showMoreMenu = false + }, + trailingIcon = { + if (volumeScrollEnabled) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + } + EpubOverflowMenuSection.PAGE_TURN_ANIM -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_realistic_page_turns)) }, + enabled = currentRenderMode == RenderMode.PAGINATED, + onClick = { + onTogglePageTurnAnimation(!isPageTurnAnimationEnabled) + showMoreMenu = false + }, + trailingIcon = { + if (isPageTurnAnimationEnabled) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + } + EpubOverflowMenuSection.KEEP_SCREEN_ON -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_keep_screen_on)) }, + onClick = { + onToggleKeepScreenOn(!isKeepScreenOn) + showMoreMenu = false + }, + trailingIcon = { + if (isKeepScreenOn) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + } + EpubOverflowMenuSection.VISUAL_OPTIONS -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_visual_options)) }, + onClick = { + showMoreMenu = false + onOpenVisualOptions() + }, + leadingIcon = { + Icon( + Icons.Default.Visibility, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }) + } + EpubOverflowMenuSection.AUTO_SCROLL -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_auto_scroll)) }, + enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL, + onClick = { + showMoreMenu = false + onStartAutoScroll() + }) + } + EpubOverflowMenuSection.TTS_SETTINGS -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_settings)) }, + onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded }, leadingIcon = { Icon( Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp) ) + }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f) + ) } ) + if (showTtsSettingsExpanded) { + if (showTtsVoiceSettings) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, + enabled = !isTtsActive, + onClick = { + showMoreMenu = false + onOpenTtsSettings() + }, + leadingIcon = { + Icon( + Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } + if (showTtsReplacements) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, + onClick = { + showMoreMenu = false + onOpenTtsReplacements() + }, + leadingIcon = { + Icon( + Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } + } } } } @@ -926,6 +1006,7 @@ fun EpubReaderBottomBar( ttsState: TtsState, isProUser: Boolean, currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode, + isSliderActive: Boolean, onOpenSlider: () -> Unit, onOpenDrawer: () -> Unit, onToggleFormat: () -> Unit, @@ -933,6 +1014,7 @@ fun EpubReaderBottomBar( onOpenAiHub: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, + onOpenBrightness: () -> Unit, onToggleTts: () -> Unit, onOpenScreenOrientation: () -> Unit, hiddenTools: Set, @@ -977,15 +1059,25 @@ fun EpubReaderBottomBar( ) { Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc)) } + ReaderTool.BRIGHTNESS -> TooltipIconButton( + text = stringResource(R.string.reader_brightness_title), + description = stringResource(R.string.reader_brightness_system_desc), + onClick = onOpenBrightness + ) { + Icon( + painter = painterResource(id = R.drawable.contrast), + contentDescription = stringResource(R.string.reader_brightness_title) + ) + } ReaderTool.SLIDER -> TooltipIconButton( text = stringResource(R.string.tooltip_slider), description = stringResource(R.string.tooltip_slider_desc), - onClick = onOpenSlider, - enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL + onClick = onOpenSlider ) { Icon( painter = painterResource(id = R.drawable.slider), - contentDescription = stringResource(R.string.content_desc_navigate_slider) + contentDescription = stringResource(R.string.content_desc_navigate_slider), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface ) } ReaderTool.TOC -> TooltipIconButton( @@ -1078,50 +1170,59 @@ fun EpubReaderPageSlider( startPageThumbnail: Bitmap?, paginator: IPaginator?, chapters: List, - onClose: () -> Unit, onScrub: (Float) -> Unit, - onJumpToPage: (Int) -> Unit + onJumpToPage: (Int) -> Unit, + modifier: Modifier = Modifier, + activeColor: Color = Color.Unspecified, + inactiveColor: Color = Color.Unspecified, + contentColor: Color = Color.Unspecified, + thumbnailSurfaceColor: Color = Color.Unspecified, + thumbnailContentColor: Color = Color.Unspecified ) { + val effectiveActiveColor = if (activeColor == Color.Unspecified) { + MaterialTheme.colorScheme.primary + } else { + activeColor + } + val effectiveInactiveColor = if (inactiveColor == Color.Unspecified) { + MaterialTheme.colorScheme.surfaceVariant + } else { + inactiveColor + } + val effectiveContentColor = if (contentColor == Color.Unspecified) { + MaterialTheme.colorScheme.onSurface + } else { + contentColor + } + val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) { + MaterialTheme.colorScheme.surfaceVariant + } else { + thumbnailSurfaceColor + } + val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + thumbnailContentColor + } + AnimatedVisibility( visible = isVisible, enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)), - exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)) + exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)), + modifier = modifier ) { - Box(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onClose() } - ) - - IconButton( - onClick = onClose, - modifier = Modifier - .align(Alignment.TopStart) - .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top + WindowInsetsSides.Start)) - .padding(8.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_desc_exit_slider) - ) - } - - // Bottom controls + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(72.dp)) Box( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}, ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp), + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = 32.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -1139,7 +1240,7 @@ fun EpubReaderPageSlider( Surface( modifier = Modifier.size(20.dp), shape = CircleShape, - color = MaterialTheme.colorScheme.primary, + color = effectiveActiveColor, tonalElevation = 0.dp, shadowElevation = 0.dp ) {} @@ -1156,7 +1257,7 @@ fun EpubReaderPageSlider( .fillMaxWidth() .height(trackHeight) .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + color = effectiveInactiveColor, shape = trackShape ) ) { @@ -1165,7 +1266,7 @@ fun EpubReaderPageSlider( .fillMaxWidth(fraction) .fillMaxHeight() .background( - color = MaterialTheme.colorScheme.primary, + color = effectiveActiveColor, shape = trackShape ) ) @@ -1194,6 +1295,7 @@ fun EpubReaderPageSlider( startPageThumbnail?.let { thumbnail -> ThumbnailWithIndicator( modifier = thumbnailModifier, + borderColor = effectiveActiveColor, onClick = { onJumpToPage(sliderStartPage) } ) { Image( @@ -1213,11 +1315,14 @@ fun EpubReaderPageSlider( } ThumbnailWithIndicator( modifier = thumbnailModifier, + borderColor = effectiveActiveColor, onClick = { onJumpToPage(sliderStartPage) } ) { PaginatedThumbnailContent( pageNumber = sliderStartPage, - chapterTitle = startPageChapterTitle + chapterTitle = startPageChapterTitle, + surfaceColor = effectiveThumbnailSurfaceColor, + contentColor = effectiveThumbnailContentColor ) } } @@ -1226,7 +1331,7 @@ fun EpubReaderPageSlider( Text( text = "${sliderCurrentPage.roundToInt()} / $totalPages", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = effectiveContentColor, fontSize = 18.sp ) } @@ -1271,8 +1376,17 @@ fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) { } @Composable -internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> Unit) { - val borderColor = MaterialTheme.colorScheme.primary +internal fun ThumbnailWithIndicator( + modifier: Modifier = Modifier, + borderColor: Color = Color.Unspecified, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + val effectiveBorderColor = if (borderColor == Color.Unspecified) { + MaterialTheme.colorScheme.primary + } else { + borderColor + } Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally @@ -1283,7 +1397,7 @@ internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () - .height(64.dp) .clickable(onClick = onClick), shape = RoundedCornerShape(4.dp), - border = BorderStroke(2.dp, borderColor) + border = BorderStroke(2.dp, effectiveBorderColor) ) { content() } @@ -1292,17 +1406,32 @@ internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () - .offset(y = (-4).dp) .size(8.dp) .rotate(45f) - .background(borderColor) + .background(effectiveBorderColor) ) } } @Composable -private fun PaginatedThumbnailContent(pageNumber: Int, chapterTitle: String?) { +private fun PaginatedThumbnailContent( + pageNumber: Int, + chapterTitle: String?, + surfaceColor: Color = Color.Unspecified, + contentColor: Color = Color.Unspecified +) { + val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) { + MaterialTheme.colorScheme.surfaceVariant + } else { + surfaceColor + } + val effectiveContentColor = if (contentColor == Color.Unspecified) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + contentColor + } Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant + color = effectiveSurfaceColor, + contentColor = effectiveContentColor ) { Column( modifier = Modifier.padding(4.dp), @@ -2058,17 +2187,24 @@ enum class ToolbarSection(@StringRes val titleRes: Int) { } @Composable -private fun ToolPreviewIcon(tool: ReaderTool) { +private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) { val title = stringResource(tool.titleRes) when (tool) { ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp)) - ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.SLIDER -> Icon( + painterResource(id = R.drawable.slider), + contentDescription = title, + modifier = Modifier.size(20.dp), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp)) ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp)) } @@ -2077,11 +2213,11 @@ private fun ToolPreviewIcon(tool: ReaderTool) { @Composable private fun HiddenEpubToolMenuItem( tool: ReaderTool, - currentRenderMode: RenderMode, - isTtsActive: Boolean, + isSliderActive: Boolean, showMoreMenu: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, + onOpenBrightness: () -> Unit, onOpenSlider: () -> Unit, onOpenDrawer: () -> Unit, onToggleFormat: () -> Unit, @@ -2090,18 +2226,14 @@ private fun HiddenEpubToolMenuItem( onToggleTts: () -> Unit, onOpenScreenOrientation: () -> Unit ) { - val enabled = when (tool) { - ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL - else -> true - } DropdownMenuItem( text = { Text(stringResource(tool.titleRes)) }, - enabled = enabled, onClick = { showMoreMenu() when (tool) { ReaderTool.DICTIONARY -> onOpenDictionarySettings() ReaderTool.THEME -> onOpenThemeSettings() + ReaderTool.BRIGHTNESS -> onOpenBrightness() ReaderTool.SLIDER -> onOpenSlider() ReaderTool.TOC -> onOpenDrawer() ReaderTool.FORMAT -> onToggleFormat() @@ -2112,7 +2244,12 @@ private fun HiddenEpubToolMenuItem( else -> Unit } }, - leadingIcon = { ToolPreviewIcon(tool) } + leadingIcon = { ToolPreviewIcon(tool, isSliderActive = isSliderActive) }, + trailingIcon = if (tool == ReaderTool.SLIDER && isSliderActive) { + { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) + } + } else null ) } @@ -2170,6 +2307,12 @@ fun TtsOverlayControls( null } } + val canSkipPreviousChunk = !ttsState.isLoading && + ttsState.currentChunkIndex > 0 && + ttsState.totalChunks > 0 + val canSkipNextChunk = !ttsState.isLoading && + ttsState.currentChunkIndex >= 0 && + ttsState.currentChunkIndex < ttsState.totalChunks - 1 val saveAndApply = { saveTtsSpeechRate(context, rate) @@ -2181,11 +2324,9 @@ fun TtsOverlayControls( } } - val backgroundAlpha = 0.6f - Surface( shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha), + color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 0.dp, shadowElevation = 0.dp, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)), @@ -2239,7 +2380,7 @@ fun TtsOverlayControls( ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(8.dp) ) { Text( @@ -2255,7 +2396,7 @@ fun TtsOverlayControls( } Surface( - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp) ) { val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { @@ -2274,7 +2415,7 @@ fun TtsOverlayControls( if (BuildConfig.FLAVOR != "oss" && activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { Surface( - color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.tertiaryContainer, shape = RoundedCornerShape(8.dp) ) { Text( @@ -2339,30 +2480,59 @@ fun TtsOverlayControls( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - // Giant Play/Pause - Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) { - FilledIconButton( - onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() }, - modifier = Modifier.size(56.dp), - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - contentColor = MaterialTheme.colorScheme.primary - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + enabled = canSkipPreviousChunk, + onClick = { ttsController.skipToPreviousChunk() }, + modifier = Modifier.size(40.dp) ) { Icon( - painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), - stringResource(R.string.content_desc_play_pause), - modifier = Modifier.size(28.dp) + imageVector = Icons.Default.SkipPrevious, + contentDescription = stringResource(R.string.content_desc_tts_previous_chunk), + modifier = Modifier.size(24.dp) + ) + } + + // Giant Play/Pause + Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) { + FilledIconButton( + onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() }, + modifier = Modifier.size(56.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), + stringResource(R.string.content_desc_play_pause), + modifier = Modifier.size(28.dp) + ) + } + if (ttsState.isLoading) CircularProgressIndicator( + modifier = Modifier.size(56.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + strokeWidth = 3.dp + ) + } + + IconButton( + enabled = canSkipNextChunk, + onClick = { ttsController.skipToNextChunk() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = stringResource(R.string.content_desc_tts_next_chunk), + modifier = Modifier.size(24.dp) ) } - if (ttsState.isLoading) CircularProgressIndicator( - modifier = Modifier.size(56.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), - strokeWidth = 3.dp - ) } - Spacer(Modifier.width(16.dp)) + Spacer(Modifier.width(12.dp)) // Unified Sliders Block Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt index de87dba..9cc33de 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt @@ -19,9 +19,11 @@ */ package com.aryan.reader.epubreader +import android.graphics.BitmapFactory import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation @@ -32,6 +34,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -55,6 +58,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog @@ -66,12 +70,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -83,7 +88,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -94,8 +101,10 @@ import com.aryan.reader.R import com.aryan.reader.RenderMode import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubTocEntry +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber @Composable @@ -207,6 +216,7 @@ fun EpubReaderDrawerSheet( chapters: List, tableOfContents: List, activeFragmentId: String?, + readerImages: List, bookmarks: Set, userHighlights: List, currentChapterIndex: Int, @@ -214,6 +224,8 @@ fun EpubReaderDrawerSheet( renderMode: RenderMode, onNavigateToChapter: (Int) -> Unit, onNavigateToTocEntry: (EpubTocEntry) -> Unit, + onNavigateToImage: (EpubReaderImageReference) -> Unit, + onDownloadImage: (EpubReaderImageReference) -> Unit, onNavigateToBookmark: (Bookmark) -> Unit, onNavigateToHighlight: (UserHighlight) -> Unit, onDeleteBookmark: (Bookmark) -> Unit, @@ -227,11 +239,15 @@ fun EpubReaderDrawerSheet( ModalDrawerSheet( modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars) ) { - val drawerPagerState = rememberPagerState(pageCount = { 3 }) + val drawerPagerState = rememberPagerState(pageCount = { 4 }) val drawerScope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxSize()) { - TabRow(selectedTabIndex = drawerPagerState.currentPage) { + ScrollableTabRow( + selectedTabIndex = drawerPagerState.currentPage, + edgePadding = 0.dp, + modifier = Modifier.fillMaxWidth() + ) { Tab( selected = drawerPagerState.currentPage == 0, onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } }, @@ -247,6 +263,11 @@ fun EpubReaderDrawerSheet( onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } }, text = { Text(stringResource(R.string.tab_annotations)) } ) + Tab( + selected = drawerPagerState.currentPage == 3, + onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(3) } }, + text = { Text(stringResource(R.string.tab_images)) } + ) } HorizontalPager( @@ -282,6 +303,11 @@ fun EpubReaderDrawerSheet( onOpenPaletteManager = onOpenPaletteManager, onHighlightColorChange = onHighlightColorChange ) + 3 -> ImagesList( + readerImages = readerImages, + onNavigateToImage = onNavigateToImage, + onDownloadImage = onDownloadImage + ) } } } @@ -710,6 +736,145 @@ private fun BookmarksList( } } +@Composable +private fun ImagesList( + readerImages: List, + onNavigateToImage: (EpubReaderImageReference) -> Unit, + onDownloadImage: (EpubReaderImageReference) -> Unit +) { + if (readerImages.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.no_images_found), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + return + } + + val listState = rememberLazyListState() + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(end = 4.dp), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items( + items = readerImages, + key = { it.id } + ) { image -> + ListItem( + leadingContent = { + EpubReaderImageThumbnail( + image = image, + modifier = Modifier.size(width = 72.dp, height = 56.dp) + ) + }, + headlineContent = { + Text( + text = image.displayTitle, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column { + Text( + text = image.chapterTitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val metadata = listOfNotNull(image.dimensionLabel, image.sourceName()).joinToString(" - ") + if (metadata.isNotBlank()) { + Text( + text = metadata, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + trailingContent = { + IconButton(onClick = { onDownloadImage(image) }) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.content_desc_download_image) + ) + } + }, + modifier = Modifier.clickable { onNavigateToImage(image) } + ) + HorizontalDivider() + } + } + + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } +} + +@Composable +private fun EpubReaderImageThumbnail( + image: EpubReaderImageReference, + modifier: Modifier = Modifier +) { + var bitmap by remember(image.sourcePath) { mutableStateOf(null) } + + LaunchedEffect(image.sourcePath) { + bitmap = withContext(Dispatchers.IO) { + if (image.sourcePath.startsWith("data:", ignoreCase = true)) { + val bytes = image.readDownloadBytes() + bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } + } else { + BitmapFactory.decodeFile(image.sourcePath) + } + } + } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + ) { + val currentBitmap = bitmap + if (currentBitmap != null) { + Image( + bitmap = currentBitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = (image.index + 1).toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable private fun HighlightsList( diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt new file mode 100644 index 0000000..cab8d29 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt @@ -0,0 +1,243 @@ +package com.aryan.reader.epubreader + +import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.EpubChapter +import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import java.io.File +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Base64 + +data class EpubReaderImageReference( + val id: String, + val index: Int, + val sourcePath: String, + val originalSource: String, + val altText: String?, + val chapterIndex: Int, + val chapterTitle: String, + val elementId: String?, + val ordinalInChapter: Int, + val chunkIndex: Int?, + val intrinsicWidth: Int?, + val intrinsicHeight: Int? +) { + val displayTitle: String + get() = altText?.trim()?.takeIf { it.isNotBlank() } + ?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() } + ?: "Image ${index + 1}" + + val dimensionLabel: String? + get() { + val width = intrinsicWidth?.takeIf { it > 0 } + val height = intrinsicHeight?.takeIf { it > 0 } + return if (width != null && height != null) "${width}x$height" else null + } + + fun sourceName(): String? { + val source = originalSource.takeIf { it.isNotBlank() } ?: sourcePath + if (source.startsWith("data:", ignoreCase = true)) return null + return source + .substringBefore('#') + .substringBefore('?') + .replace('\\', '/') + .substringAfterLast('/') + .takeIf { it.isNotBlank() } + } + + fun suggestedDownloadFileName(): String { + val extension = sourcePath.readerImageExtension() + ?: originalSource.readerImageExtension() + ?: "png" + val base = altText?.trim()?.takeIf { it.isNotBlank() } + ?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() } + ?: "image-${index + 1}" + val safeBase = base.sanitizedReaderImageFileBase().ifBlank { "image-${index + 1}" } + return "$safeBase.$extension" + } + + fun mimeType(): String { + val dataMime = readerDataUriMimeType(sourcePath) + if (dataMime != null) return dataMime + return when (sourcePath.readerImageExtension() ?: originalSource.readerImageExtension()) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "bmp" -> "image/bmp" + "svg" -> "image/svg+xml" + else -> "image/*" + } + } +} + +fun EpubBook.readerImageReferencesForDrawer(): List { + val references = mutableListOf() + chapters.forEachIndexed { chapterIndex, chapter -> + val html = chapter.readerImageHtml(extractionBasePath).takeIf { it.isNotBlank() } + ?: return@forEachIndexed + val sourceOrdinalByKey = mutableMapOf() + val document = Jsoup.parse(html, chapter.absPath) + + document.select("img, image").forEach { element -> + val originalSource = element.readerImageSource() ?: return@forEach + val sourcePath = resolveReaderImageSource(chapter, extractionBasePath, originalSource) + val sourceKey = sourcePath.readerImageLookupKey() + val ordinal = sourceOrdinalByKey.getOrDefault(sourceKey, 0) + sourceOrdinalByKey[sourceKey] = ordinal + 1 + val index = references.size + + references += EpubReaderImageReference( + id = "android-epub-image:$chapterIndex:$ordinal:${sourcePath.hashCode()}:$index", + index = index, + sourcePath = sourcePath, + originalSource = originalSource, + altText = element.attr("alt").ifBlank { element.attr("title") }.ifBlank { null }, + chapterIndex = chapterIndex, + chapterTitle = chapter.title.ifBlank { "Chapter ${chapterIndex + 1}" }, + elementId = element.id().ifBlank { null }, + ordinalInChapter = ordinal, + chunkIndex = element.readerTopLevelBodyChildIndex()?.let { it / 20 }, + intrinsicWidth = element.readerImageDimension("width"), + intrinsicHeight = element.readerImageDimension("height") + ) + } + } + return references +} + +fun EpubReaderImageReference.readDownloadBytes(): ByteArray? { + if (sourcePath.startsWith("data:", ignoreCase = true)) { + return sourcePath.readerDataUriBytes() + } + return runCatching { + File(sourcePath).takeIf { it.isFile }?.readBytes() + }.getOrNull() +} + +private fun EpubChapter.readerImageHtml(extractionBasePath: String): String { + if (htmlContent.isNotBlank()) return htmlContent + return runCatching { + File(extractionBasePath, htmlFilePath).takeIf { it.isFile }?.readText().orEmpty() + }.getOrDefault("") +} + +private fun Element.readerImageSource(): String? { + return listOf("src", "href", "xlink:href", "data-src") + .firstNotNullOfOrNull { attrName -> + attr(attrName).trim().takeIf { it.isNotBlank() } + } +} + +private fun Element.readerImageDimension(attribute: String): Int? { + val raw = attr(attribute).trim().takeIf { it.isNotBlank() } ?: return null + return Regex("""\d+""").find(raw)?.value?.toIntOrNull()?.takeIf { it > 0 } +} + +private fun Element.readerTopLevelBodyChildIndex(): Int? { + val body = ownerDocument()?.body() ?: return null + var topLevel: Element = this + while (topLevel.parent() != null && topLevel.parent() != body) { + topLevel = topLevel.parent() ?: break + } + if (topLevel.parent() != body) return null + return body.childNodes().indexOf(topLevel).takeIf { it >= 0 } +} + +private fun resolveReaderImageSource( + chapter: EpubChapter, + extractionBasePath: String, + source: String +): String { + val withoutFragment = source.substringBefore('#').substringBefore('?') + if (withoutFragment.startsWith("data:", ignoreCase = true)) return source + + val fileUriPath = withoutFragment.readerFileUriPath() + fileUriPath?.let { path -> + val file = File(path) + if (file.isFile) { + return runCatching { file.canonicalFile.absolutePath }.getOrDefault(file.absolutePath) + } + } + + val sourceForResolve = fileUriPath ?: withoutFragment + AndroidHtmlResourceResolver.resolvePath(chapter.absPath, extractionBasePath, sourceForResolve)?.let { + return it + } + + val fallbackCandidates = listOf( + File(extractionBasePath, sourceForResolve), + File(extractionBasePath, sourceForResolve.trimStart('/', '\\')) + ) + return fallbackCandidates + .firstOrNull { it.isFile } + ?.let { runCatching { it.canonicalFile.absolutePath }.getOrDefault(it.absolutePath) } + ?: source +} + +private fun String.readerFileUriPath(): String? { + if (!startsWith("file:", ignoreCase = true)) return null + return runCatching { + URI(this).path?.let { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) } + }.getOrNull() +} + +private fun String.readerImageLookupKey(): String { + return substringBefore('#') + .substringBefore('?') + .replace('\\', '/') + .lowercase() +} + +private fun String.readerImageExtension(): String? { + readerDataUriMimeType(this)?.let { mime -> + return when (mime.lowercase()) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + "image/bmp" -> "bmp" + "image/svg+xml" -> "svg" + else -> null + } + } + return substringBefore('#') + .substringBefore('?') + .substringAfterLast('.', "") + .lowercase() + .takeIf { it in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg") } +} + +private fun readerDataUriMimeType(source: String): String? { + if (!source.startsWith("data:", ignoreCase = true)) return null + return source + .drop(5) + .substringBefore(';') + .substringBefore(',') + .takeIf { it.startsWith("image/", ignoreCase = true) } +} + +private fun String.readerDataUriBytes(): ByteArray? { + val commaIndex = indexOf(',') + if (!startsWith("data:", ignoreCase = true) || commaIndex == -1) return null + val metadata = substring(0, commaIndex) + val data = substring(commaIndex + 1) + return runCatching { + if (metadata.contains(";base64", ignoreCase = true)) { + Base64.getDecoder().decode(data) + } else { + URLDecoder.decode(data, StandardCharsets.UTF_8.name()).toByteArray(StandardCharsets.UTF_8) + } + }.getOrNull() +} + +private fun String.sanitizedReaderImageFileBase(): String { + return replace(Regex("""[\\/:*?"<>|]+"""), "_") + .replace(Regex("""\s+"""), " ") + .trim() + .trim('.') + .take(80) +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 61cc316..3f8996e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -158,6 +158,9 @@ import com.aryan.reader.BuildConfig import com.aryan.reader.BuiltInThemes import com.aryan.reader.MainViewModel import com.aryan.reader.R +import com.aryan.reader.ReaderBrightnessEffect +import com.aryan.reader.ReaderFileInfoDialogs +import com.aryan.reader.ReaderBrightnessSheet import com.aryan.reader.ReaderScreenOrientationEffect import com.aryan.reader.ReaderScreenOrientationSheet import com.aryan.reader.ReaderThemePanel @@ -176,13 +179,17 @@ 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.loadReaderBrightnessSettings import com.aryan.reader.loadReaderScreenOrientationMode import com.aryan.reader.loadEpubRightToLeftPagination import com.aryan.reader.loadReaderThemeId +import com.aryan.reader.loadReaderSliderToggled import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.loadTtsReplacementPreferences +import com.aryan.reader.readerSliderBookmarkPosition +import com.aryan.reader.readerSliderChromeColors +import com.aryan.reader.readerSliderToggleState import com.aryan.reader.paginatedreader.BookPaginator -import com.aryan.reader.paginatedreader.CfiUtils import com.aryan.reader.paginatedreader.HeaderBlock import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.ListItemBlock @@ -198,10 +205,13 @@ 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.saveReaderBrightnessSettings import com.aryan.reader.saveReaderScreenOrientationMode import com.aryan.reader.saveEpubRightToLeftPagination import com.aryan.reader.saveReaderThemeId +import com.aryan.reader.saveReaderSliderToggled import com.aryan.reader.saveTtsReplacementPreferences +import com.aryan.reader.shouldRenderReaderSlider import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator import com.aryan.reader.tts.SpeakerSamplePlayer @@ -210,6 +220,7 @@ import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.withTtsReplacements import com.aryan.reader.shared.reader.ReaderJumpHistory +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -217,6 +228,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf @@ -246,7 +258,7 @@ private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools" private const val TOOL_ORDER_KEY = "reader_tool_order" private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools" private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version" -private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 1 +private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 2 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" @@ -264,6 +276,25 @@ private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String .take(maxLength) } +private fun List.withInitialChunkOverride( + startChunkIndex: Int, + initialChunk: TtsChunk? +): List { + if (initialChunk == null || startChunkIndex !in indices) return this + val existing = this[startChunkIndex] + if ( + existing.text == initialChunk.text && + existing.sourceCfi == initialChunk.sourceCfi && + existing.startOffsetInSource == initialChunk.startOffsetInSource + ) { + return this + } + + return toMutableList().also { chunks -> + chunks[startChunkIndex] = initialChunk + } +} + private fun View.bottomRoundedCornerRadiusPx(): Int { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0 @@ -313,7 +344,7 @@ private fun loadHiddenTools(context: Context): Set { val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty() val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) { - val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools() + val migratedHiddenTools = savedHiddenTools + readerHiddenToolsIntroducedAfter(defaultsVersion) prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools) putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION) @@ -323,6 +354,13 @@ private fun loadHiddenTools(context: Context): Set { return savedHiddenTools } +private fun readerHiddenToolsIntroducedAfter(defaultsVersion: Int): Set { + return buildSet { + if (defaultsVersion < 1) add(ReaderTool.SCREEN_ORIENTATION.name) + if (defaultsVersion < 2) add(ReaderTool.BRIGHTNESS.name) + } +} + private fun saveToolOrder(context: Context, toolOrder: List) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) } @@ -644,9 +682,19 @@ fun EpubReaderHost( ) { val view = LocalView.current val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() val window = (view.context as? Activity)?.window val activity = context as? Activity val scope = rememberCoroutineScope() + var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) } + var showBrightnessSheet by remember { mutableStateOf(false) } + ReaderBrightnessEffect(window, readerBrightnessSettings) + + val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings -> + readerBrightnessSettings = settings + saveReaderBrightnessSettings(context, settings) + } + fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) { viewModel.showBanner(message, isError, isPersistent) } @@ -663,8 +711,8 @@ fun EpubReaderHost( var isNavigatingToPosition by remember { mutableStateOf(false) } var isSeamlessTransitioning by remember { mutableStateOf(false) } var showInsufficientCreditsDialog by remember { mutableStateOf(false) } + var showFileInfoDialog by remember { mutableStateOf(false) } - var isPageSliderVisible by remember { mutableStateOf(false) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var isFastScrubbing by remember { mutableStateOf(false) } val scrubDebounceJob = remember { mutableStateOf(null) } @@ -717,6 +765,11 @@ fun EpubReaderHost( val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) { stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) } + val bookId = readerCacheBookId + + var isPageSliderVisible by remember(bookId) { + mutableStateOf(loadReaderSliderToggled(context, bookId)) + } val locatorConverter = remember(context, readerCacheBookId) { LocatorConverter( @@ -749,7 +802,6 @@ fun EpubReaderHost( var isAutoScrollCollapsed by remember { mutableStateOf(false) } var isTtsCollapsed by remember { mutableStateOf(false) } - val bookId = readerCacheBookId var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) } val initialSettings = remember(isAutoScrollLocal) { @@ -1017,6 +1069,13 @@ fun EpubReaderHost( val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) var showBars by remember { mutableStateOf(false) } val chapters = remember(epubBook.chapters) { epubBook.chapters } + var readerImages by remember(epubBook) { mutableStateOf>(emptyList()) } + + LaunchedEffect(epubBook) { + readerImages = withContext(Dispatchers.IO) { + epubBook.readerImageReferencesForDrawer() + } + } var currentChapterIndex by rememberSaveable(epubBook.title) { mutableIntStateOf( @@ -1048,11 +1107,14 @@ fun EpubReaderHost( var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) } var chapterChunks by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } + var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } + var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } var chapterHead by remember(currentChapterIndex) { mutableStateOf("") } var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) } var cfiToLoad by remember { mutableStateOf(initialCfi) } var fragmentToLoad by remember { mutableStateOf(null) } + var imageToLoad by remember { mutableStateOf(null) } var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) } var bookmarkPageMap by remember { mutableStateOf>(emptyMap()) } @@ -1170,13 +1232,6 @@ fun EpubReaderHost( } } - LaunchedEffect(isPageSliderVisible) { - if (!isPageSliderVisible) { - startPageThumbnail?.recycle() - startPageThumbnail = null - } - } - LaunchedEffect(ttsState.errorMessage) { ttsState.errorMessage?.let { message -> if (message == "INSUFFICIENT_CREDITS") { @@ -1189,6 +1244,12 @@ fun EpubReaderHost( } val searchState = rememberSearchState(scope = scope, searcher = epubSearcher) + val isEpubSliderReady = currentRenderMode == RenderMode.VERTICAL_SCROLL || paginatedPagerState.pageCount > 0 + val epubSliderChromeVisible = shouldRenderReaderSlider( + isToggledOn = isPageSliderVisible, + isBottomChromeVisible = showBars, + isSearchActive = searchState.isSearchActive + ) && isEpubSliderReady val speakerPlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() }) } @@ -1282,6 +1343,11 @@ fun EpubReaderHost( if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000) } else activeTheme.textColor } + val epubReaderSliderColors = readerSliderChromeColors( + pageBackground = effectiveBg, + pageText = effectiveText, + themePrimary = MaterialTheme.colorScheme.primary + ) val activeTextureId = activeTheme.textureId val activeTextureAlpha = 1f - globalTextureTransparency val activeTextureBitmap = remember(activeTextureId) { @@ -1326,7 +1392,8 @@ fun EpubReaderHost( !ttsState.currentWordSourceCfi.isNullOrBlank() || !ttsState.sourceCfi.isNullOrBlank() || !ttsState.currentText.isNullOrBlank() - val isSameBook = ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title + val isSameBook = ttsState.bookId?.let { it == bookId } + ?: (ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title) return isReaderSession && hasReaderSessionState && isSameBook } @@ -1372,6 +1439,7 @@ fun EpubReaderHost( chunkTargetOverride = null cfiToLoad = null fragmentToLoad = null + imageToLoad = null isNavigatingToPosition = false suppressNextVerticalTtsDetach = false } @@ -1570,22 +1638,31 @@ fun EpubReaderHost( val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 val pageInChapter = currentPage - chapterStartPage - val ttsChunks = bookPaginator.getTtsChunksForChapter( - chapterIndex = chapterIndex, - startingFromPageInChapter = pageInChapter - ) + val allTtsChunks = bookPaginator.getTtsChunksForChapter(chapterIndex) + val firstChunkOnPage = if (pageInChapter > 0) { + bookPaginator.getTtsChunksForChapter( + chapterIndex = chapterIndex, + startingFromPageInChapter = pageInChapter + )?.firstOrNull() + } else { + allTtsChunks?.firstOrNull() + } + val startChunkIndex = findTtsChunkStartIndex(allTtsChunks.orEmpty(), firstChunkOnPage) ?: 0 - if (!ttsChunks.isNullOrEmpty()) { + if (!allTtsChunks.isNullOrEmpty() && firstChunkOnPage != null) { val chapterTitle = chapters.getOrNull(chapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } ttsChapterIndex = chapterIndex ttsController.start( - chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), + chunks = allTtsChunks.withInitialChunkOverride(startChunkIndex, firstChunkOnPage) + .withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, + bookId = bookId, chapterIndex = chapterIndex, totalChapters = chapters.size, + startChunkIndex = startChunkIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token @@ -1597,6 +1674,33 @@ fun EpubReaderHost( ) } + var pendingImageDownload by remember { mutableStateOf(null) } + val imageSaveLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("image/*"), + onResult = { uri -> + val image = pendingImageDownload + pendingImageDownload = null + if (uri != null && image != null) { + scope.launch { + val saved = withContext(Dispatchers.IO) { + runCatching { + val bytes = image.readDownloadBytes() ?: error("Image bytes are unavailable") + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(bytes) + } ?: error("Could not open image destination") + }.isSuccess + } + val message = if (saved) { + context.getString(R.string.saved_image_message, image.suggestedDownloadFileName()) + } else { + context.getString(R.string.error_save_image) + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + } + ) + val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { _ -> @@ -1616,17 +1720,14 @@ fun EpubReaderHost( val bookPaginator = paginator as? BookPaginator val chapterIndex = currentChapterInPaginatedMode ?: return@launch val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch - - var foundIdx = -1 - for (i in chunks.indices) { - val c = chunks[i] - val cPath = CfiUtils.getPath(c.sourceCfi) - val bPath = CfiUtils.getPath(baseCfi) - if (cPath == bPath && startOffset >= c.startOffsetInSource && startOffset < c.startOffsetInSource + c.text.length) { - foundIdx = i - break - } - } + val foundIdx = findTtsChunkStartIndex( + chunks = chunks, + target = TtsChunk( + text = "", + sourceCfi = baseCfi, + startOffsetInSource = startOffset + ) + ) ?: -1 if (foundIdx != -1) { val target = chunks[foundIdx] @@ -1639,21 +1740,24 @@ fun EpubReaderHost( spokenText = slicedText, ) - val remainingChunks = mutableListOf(newChunk) - remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size)) + val sessionChunks = chunks.toMutableList().also { + it[foundIdx] = newChunk + } - if (remainingChunks.isNotEmpty()) { + if (sessionChunks.isNotEmpty()) { ttsShouldStartOnChapterLoad = false ttsChapterIndex = chapterIndex val chapterTitle = chapters.getOrNull(chapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } ttsController.start( - chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId), + chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, + bookId = bookId, chapterIndex = chapterIndex, totalChapters = chapters.size, + startChunkIndex = foundIdx, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token @@ -1770,6 +1874,63 @@ fun EpubReaderHost( } } + fun currentEpubSliderPage(): Int { + return when (currentRenderMode) { + RenderMode.VERTICAL_SCROLL -> currentPageInChapter + RenderMode.PAGINATED -> (paginatedPagerState.currentPage + 1).coerceAtLeast(1) + } + } + + fun resetEpubSliderBookmark() { + val position = readerSliderBookmarkPosition(currentEpubSliderPage()) + sliderStartPage = position.startPage + sliderCurrentPage = position.currentPage + } + + LaunchedEffect(bookId, isPageSliderVisible) { + saveReaderSliderToggled(context, bookId, isPageSliderVisible) + if (isPageSliderVisible) { + resetEpubSliderBookmark() + } + } + + fun toggleEpubPageSlider() { + if (!isPageSliderVisible && currentRenderMode == RenderMode.PAGINATED && paginatedPagerState.pageCount <= 0) { + showBanner("Book is not paginated yet.") + return + } + + val nextState = readerSliderToggleState( + isCurrentlyToggledOn = isPageSliderVisible, + currentPage = currentEpubSliderPage() + ) + sliderStartPage = nextState.bookmarkPosition.startPage + sliderCurrentPage = nextState.bookmarkPosition.currentPage + isPageSliderVisible = nextState.isToggledOn + showBars = true + if (nextState.isToggledOn) { + showFormatAdjustmentBars = false + } + } + + LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, paginatedPagerState.currentPage) { + if (isPageSliderVisible && !epubSliderChromeVisible) { + resetEpubSliderBookmark() + } + } + + LaunchedEffect(epubSliderChromeVisible, currentRenderMode, sliderStartPage, webViewRefForTts) { + if (epubSliderChromeVisible && currentRenderMode == RenderMode.VERTICAL_SCROLL) { + startPageThumbnail?.recycle() + startPageThumbnail = webViewRefForTts?.let { webView -> + captureWebViewVisibleArea(webView) + } + } else if (!epubSliderChromeVisible || currentRenderMode == RenderMode.PAGINATED) { + startPageThumbnail?.recycle() + startPageThumbnail = null + } + } + val latestChapterIndex by rememberUpdatedState(currentChapterIndex) LaunchedEffect(ttsState.bookTitle, ttsState.chapterIndex, ttsState.sourceCfi, ttsState.playbackSource) { @@ -1952,6 +2113,8 @@ fun EpubReaderHost( webViewRefForTts = null chapterHead = "" chapterChunks = emptyList() + chapterChunkElementStartIndices = emptyList() + chapterChunkElementCounts = emptyList() startPageThumbnail?.recycle() startPageThumbnail = null autoScrollResumeJob.value?.cancel() @@ -2029,6 +2192,8 @@ fun EpubReaderHost( chapterHead = result.head chapterChunks = result.chunks + chapterChunkElementStartIndices = result.chunkElementStartIndices + chapterChunkElementCounts = result.chunkElementCounts isChapterParsing = false if (initialScrollTargetForChapter == ChapterScrollPosition.END) { @@ -2505,6 +2670,42 @@ fun EpubReaderHost( } } + fun scrollCurrentVerticalChapterToImage(image: EpubReaderImageReference) { + val targetChunk = image.chunkIndex + if (targetChunk != null && targetChunk >= 0) { + injectVerticalChunksThrough(targetChunk) + } + val escapedSource = escapeJsString(image.sourcePath) + val escapedOriginalSource = escapeJsString(image.originalSource) + webViewRefForTts?.evaluateJavascript( + "javascript:window.scrollToReaderImageSource('$escapedSource', ${image.ordinalInChapter}, '$escapedOriginalSource');", + null + ) + } + + fun navigateVerticalToImage(image: EpubReaderImageReference) { + scope.launch { + recordEpubJump(chapterStartJumpLocator(image.chapterIndex)) + clearPendingTtsRelocationState("sidebar_image_vertical") + imageToLoad = image + cfiToLoad = null + fragmentToLoad = null + initialScrollTargetForChapter = null + if (image.chapterIndex != currentChapterIndex) { + chunkTargetOverride = image.chunkIndex?.coerceAtLeast(0) + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=SIDEBAR_IMAGE, from=$currentChapterIndex, to=${image.chapterIndex}, image='${image.sourceName()}'") + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = image.chapterIndex + } else { + chunkTargetOverride = null + scrollCurrentVerticalChapterToImage(image) + imageToLoad = null + } + } + } + fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) { scope.launch { val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) @@ -2766,10 +2967,7 @@ fun EpubReaderHost( } BackHandler(enabled = true) { - if (isPageSliderVisible) { - isPageSliderVisible = false - showBars = true - } else if (drawerState.isOpen) { + if (drawerState.isOpen) { scope.launch { Timber.d("Back pressed: Closing drawer") drawerState.close() @@ -2795,6 +2993,7 @@ fun EpubReaderHost( chapters = chapters, tableOfContents = epubBook.tableOfContents, activeFragmentId = activeFragmentId, + readerImages = readerImages, bookmarks = bookmarks, userHighlights = userHighlights, currentChapterIndex = currentChapterIndex, @@ -2803,6 +3002,56 @@ fun EpubReaderHost( activeHighlightPalette = currentHighlightPalette, onOpenPaletteManager = { showPaletteManager = true }, onHighlightColorChange = onHighlightColorChange, + onNavigateToImage = { image -> + scope.launch { + drawerState.close() + when (currentRenderMode) { + RenderMode.VERTICAL_SCROLL -> { + navigateVerticalToImage(image) + } + RenderMode.PAGINATED -> { + val bookPaginator = paginator as? BookPaginator + if (bookPaginator != null) { + isNavigatingByToc = true + try { + val imagePage = bookPaginator.findStablePageForImageSource( + chapterIndex = image.chapterIndex, + sourcePath = image.sourcePath, + elementId = image.elementId, + ordinalInChapter = image.ordinalInChapter + ) + if (imagePage != null) { + val (pageIndex, locator) = imagePage + paginatedJumpLocatorForPage( + pageIndex = pageIndex, + targetLocator = locator, + allowPageFallback = true + )?.let { recordEpubJump(it) } + scrollPaginatedToJumpPage(pageIndex, locator) + } else { + val fallbackPage = bookPaginator.findStableChapterStartPage(image.chapterIndex) + if (fallbackPage != null) { + recordEpubJump(chapterStartJumpLocator(image.chapterIndex).copy(pageIndex = fallbackPage)) + scrollPaginatedToJumpPage( + fallbackPage, + Locator(image.chapterIndex, 0, 0), + fallbackToChapterStart = true + ) + } + } + } finally { + isNavigatingByToc = false + } + } + } + } + if (showBars) showBars = false + } + }, + onDownloadImage = { image -> + pendingImageDownload = image + imageSaveLauncher.launch(image.suggestedDownloadFileName()) + }, onNavigateToTocEntry = { entry -> scope.launch { drawerState.close() @@ -3434,6 +3683,10 @@ fun EpubReaderHost( currentTopPadding } + val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel() + val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel() + val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null) + Box( modifier = Modifier .fillMaxSize() @@ -3545,16 +3798,26 @@ fun EpubReaderHost( } else if (chapterChunks.isNotEmpty()) { var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) } - val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) { + val initialContentToLoad = remember( + loadUpToChunkIndex, + chapterChunks, + chapterChunkElementStartIndices, + chapterChunkElementCounts + ) { val targetIdx = loadUpToChunkIndex val startIdx = 0 val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1) chapterChunks.indices.joinToString(separator = "\n") { index -> + val attributes = readerChunkContainerAttributes( + index, + chapterChunkElementStartIndices, + chapterChunkElementCounts + ) if (index in startIdx..endIdx) { - "
${chapterChunks[index]}
" + "
${chapterChunks[index]}
" } else { - "
" + "
" } } } @@ -3651,6 +3914,9 @@ fun EpubReaderHost( initialPageScrollY = currentScrollYPosition, initialCfi = cfiToLoad, initialFragmentId = fragmentToLoad.also { }, + initialImageSource = imageToLoad?.sourcePath, + initialImageOriginalSource = imageToLoad?.originalSource, + initialImageOrdinal = imageToLoad?.ordinalInChapter ?: 0, userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex }, activeHighlightPalette = currentHighlightPalette, onUpdatePalette = onUpdateHighlightPalette, @@ -3693,12 +3959,14 @@ fun EpubReaderHost( ) } else { val wasCfiScroll = cfiToLoad != null - Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") - logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll") + val wasImageScroll = imageToLoad != null + Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll") + logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll wasImageScroll=$wasImageScroll") initialScrollTargetForChapter = null cfiToLoad = null fragmentToLoad = null - Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") + imageToLoad = null + Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll") isWebViewReady = true if (wasCfiScroll) { @@ -4154,14 +4422,27 @@ fun EpubReaderHost( Uri.fromFile(File(it)).toString() } ttsChapterIndex = targetChapterIndex + val nativeChapterChunks = locatorConverter + .getTtsChunksForChapter(epubBook, targetChapterIndex, bookId) + .orEmpty() + val extractedStartChunk = ttsChunks.firstOrNull() + val nativeStartChunkIndex = findTtsChunkStartIndex(nativeChapterChunks, extractedStartChunk) + val sessionChunks = if (nativeChapterChunks.isNotEmpty() && nativeStartChunkIndex != null) { + nativeChapterChunks.withInitialChunkOverride(nativeStartChunkIndex, extractedStartChunk) + } else { + ttsChunks + } + val startChunkIndex = nativeStartChunkIndex ?: 0 ttsController.start( - chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), + chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, + bookId = bookId, chapterIndex = targetChapterIndex, totalChapters = chapters.size, + startChunkIndex = startChunkIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token @@ -5216,6 +5497,7 @@ fun EpubReaderHost( currentRenderMode = currentRenderMode, isBookmarked = isBookmarked, isTtsActive = isTtsSessionActive, + isSliderActive = isPageSliderVisible, tapToNavigateEnabled = tapToNavigateEnabled, volumeScrollEnabled = volumeScrollEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, @@ -5307,35 +5589,11 @@ fun EpubReaderHost( onOpenTtsReplacements = { showTtsReplacementsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, + onOpenBrightness = { showBrightnessSheet = true }, onOpenVisualOptions = { showVisualOptionsSheet = true }, onOpenScreenOrientation = { showScreenOrientationSheet = 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 { - showBanner("Book is not paginated yet.") - } - } - } - }, + onOpenSlider = ::toggleEpubPageSlider, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -5343,6 +5601,7 @@ fun EpubReaderHost( showFormatAdjustmentBars = !showFormatAdjustmentBars if (showFormatAdjustmentBars) { searchState.showSearchResultsPanel = false + resetEpubSliderBookmark() isPageSliderVisible = false } }, @@ -5374,6 +5633,7 @@ fun EpubReaderHost( } } }, + onOpenFileInfo = { showFileInfoDialog = true }, onToggleReflow = if (onToggleReflow != null) { { val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) { @@ -5537,8 +5797,8 @@ fun EpubReaderHost( .padding(bottom = bottomPadding + 45.dp), showStandardBars = showBars, searchStateActive = searchState.isSearchActive, - backLabel = epubJumpHistory.backLocator?.epubJumpLabel(), - forwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel(), + backLabel = epubJumpBackLabel, + forwardLabel = epubJumpForwardLabel, onBack = ::goBackInEpubJumpHistory, onForward = ::goForwardInEpubJumpHistory, onClear = { epubJumpHistory = epubJumpHistory.clear() } @@ -5555,35 +5815,12 @@ fun EpubReaderHost( toolOrder = toolOrder, bottomTools = bottomTools, currentTtsMode = currentTtsMode, + isSliderActive = isPageSliderVisible, onOpenAiHub = { showAiHubSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = 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 { - showBanner("Book is not paginated yet.") - } - } - } - }, + onOpenBrightness = { showBrightnessSheet = true }, + onOpenSlider = ::toggleEpubPageSlider, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -5592,6 +5829,7 @@ fun EpubReaderHost( showFormatAdjustmentBars = !showFormatAdjustmentBars if (showFormatAdjustmentBars) { searchState.showSearchResultsPanel = false + resetEpubSliderBookmark() isPageSliderVisible = false } }, @@ -5880,69 +6118,62 @@ fun EpubReaderHost( onDismiss = { activeFootnoteHtml = null } ) } - } - } - EpubReaderPageSlider( - isVisible = isPageSliderVisible, - currentRenderMode = currentRenderMode, - totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount, - sliderCurrentPage = sliderCurrentPage, - sliderStartPage = sliderStartPage, - startPageThumbnail = startPageThumbnail, - paginator = paginator, - chapters = chapters, - onClose = { - isPageSliderVisible = false - showBars = true - }, - onScrub = { newValue -> - sliderCurrentPage = newValue - isFastScrubbing = true - scrubDebounceJob.value?.cancel() - scrubDebounceJob.value = scope.launch { - delay(200) - if (isActive) { - val targetPage = newValue.roundToInt() - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { - val scrollY = (targetPage - 1) * currentClientHeightValue - webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) - } else { - paginatedPagerState.scrollToPage(targetPage - 1) + EpubReaderPageSlider( + isVisible = epubSliderChromeVisible, + currentRenderMode = currentRenderMode, + totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount, + sliderCurrentPage = sliderCurrentPage, + sliderStartPage = sliderStartPage, + startPageThumbnail = startPageThumbnail, + paginator = paginator, + chapters = chapters, + onScrub = { newValue -> + sliderCurrentPage = newValue + isFastScrubbing = true + scrubDebounceJob.value?.cancel() + scrubDebounceJob.value = scope.launch { + delay(200) + if (isActive) { + val targetPage = newValue.roundToInt() + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + val scrollY = (targetPage - 1) * currentClientHeightValue + webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) + } else { + paginatedPagerState.scrollToPage(targetPage - 1) + } + isFastScrubbing = false + } } - isFastScrubbing = false - } - } - }, - onJumpToPage = { page -> - scope.launch { - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { - sliderCurrentPage = page.toFloat() - val scrollY = (page - 1) * currentClientHeightValue - recordEpubJump( - SharedReaderLocator( - chapterIndex = currentChapterIndex, - cfi = "android-scroll:$scrollY" - ) - ) - webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) - } else { - sliderCurrentPage = page.toFloat() - val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1) - paginatedJumpLocatorForPage( - pageIndex = page - 1, - targetLocator = targetLocator, - allowPageFallback = true - )?.let { recordEpubJump(it) } - scrollPaginatedToJumpPage(page - 1, targetLocator) - } + }, + onJumpToPage = { page -> + scope.launch { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + sliderCurrentPage = page.toFloat() + val scrollY = (page - 1) * currentClientHeightValue + webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) + } else { + sliderCurrentPage = page.toFloat() + val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1) + scrollPaginatedToJumpPage(page - 1, targetLocator) + } + } + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp), + activeColor = epubReaderSliderColors.activeTrackColor, + inactiveColor = epubReaderSliderColors.inactiveTrackColor, + contentColor = epubReaderSliderColors.contentColor, + thumbnailSurfaceColor = epubReaderSliderColors.thumbnailSurfaceColor, + thumbnailContentColor = epubReaderSliderColors.thumbnailContentColor + ) + + if (epubSliderChromeVisible && isFastScrubbing) { + val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount + PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total) } } - ) - - if (isPageSliderVisible && isFastScrubbing) { - val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount - PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total) } if (showTtsSettingsSheet) { @@ -5974,6 +6205,15 @@ fun EpubReaderHost( onDismiss = { showTtsReplacementsSheet = false }, ) + ReaderFileInfoDialogs( + isFileInfoVisible = showFileInfoDialog, + onFileInfoVisibleChange = { showFileInfoDialog = it }, + uiState = uiState, + primaryBookId = uiState.selectedBookId ?: stableBookId, + uriString = uiState.selectedEpubUri?.toString(), + viewModel = viewModel + ) + if (showCustomizeToolsSheet) { CustomizeToolsSheet( hiddenTools = hiddenTools, @@ -5995,6 +6235,14 @@ fun EpubReaderHost( ) } + if (showBrightnessSheet) { + ReaderBrightnessSheet( + settings = readerBrightnessSettings, + onSettingsChange = updateReaderBrightness, + onDismiss = { showBrightnessSheet = false } + ) + } + if (showDictionarySettingsSheet) { DictionarySettingsDialog( isVisible = true, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt index a94deba..a8595c3 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSearch.kt @@ -45,6 +45,7 @@ import com.aryan.reader.SearchResult import com.aryan.reader.SearchResultsPanel import com.aryan.reader.SearchState import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.contentFilePath import com.aryan.reader.paginatedreader.IPaginator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -64,8 +65,7 @@ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List() epubBook.chapters.forEachIndexed { chapterIndex, chapter -> try { - val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" - val htmlFile = File(fullPath) + val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath()) if (!htmlFile.exists()) return@forEachIndexed val doc = Jsoup.parse(htmlFile, "UTF-8") @@ -249,4 +249,4 @@ fun EpubReaderSearchOverlay( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index 82c7790..ad354f6 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -109,6 +109,13 @@ import com.aryan.reader.data.CustomFontEntity import java.io.File import kotlin.math.roundToInt +typealias ReaderFont = com.aryan.reader.shared.ReaderFont +typealias ReaderTextAlign = com.aryan.reader.shared.ReaderTextAlign +typealias SystemUiMode = com.aryan.reader.shared.SystemUiMode +typealias PageInfoMode = com.aryan.reader.shared.PageInfoMode +typealias PageInfoPosition = com.aryan.reader.shared.PageInfoPosition +typealias FormatSettings = com.aryan.reader.shared.FormatSettings + const val SETTINGS_PREFS_NAME = "epub_reader_settings" private const val TEXT_ALIGN_KEY = "reader_text_align" private const val FONT_SIZE_KEY = "reader_font_size" @@ -153,50 +160,45 @@ fun loadTtsPitch(context: Context): Float { return prefs.getFloat(TTS_PITCH_KEY, 1.0f) } -enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) { - ORIGINAL("original", "Original", "Original"), - MERRIWEATHER("merriweather", "Merriweather", "Merriweather"), - LATO("lato", "Lato", "Lato"), - LORA("lora", "Lora", "Lora"), - ROBOTO_MONO("roboto_mono", "Roboto Mono", "Roboto Mono"), - LEXEND("lexend", "Lexend", "Lexend") -} +val ReaderTextAlign.iconResId: Int + get() = when (this) { + ReaderTextAlign.DEFAULT, + ReaderTextAlign.LEFT -> R.drawable.format_align_left + ReaderTextAlign.RIGHT -> R.drawable.format_align_right + ReaderTextAlign.JUSTIFY -> R.drawable.format_align_justify + } -enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) { - DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default), - LEFT("left", "left", R.drawable.format_align_left, R.string.label_left), - RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right), - JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify) -} +@get:StringRes +val ReaderTextAlign.displayNameRes: Int + get() = when (this) { + ReaderTextAlign.DEFAULT -> R.string.label_default + ReaderTextAlign.LEFT -> R.string.label_left + ReaderTextAlign.RIGHT -> R.string.label_right + ReaderTextAlign.JUSTIFY -> R.string.label_justify + } -enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) { - DEFAULT(0, R.string.label_always_show), - SYNC(1, R.string.label_sync_with_menus), - HIDDEN(2, R.string.label_always_hide) -} +@get:StringRes +val SystemUiMode.titleRes: Int + get() = when (this) { + SystemUiMode.DEFAULT -> R.string.label_always_show + SystemUiMode.SYNC -> R.string.label_sync_with_menus + SystemUiMode.HIDDEN -> R.string.label_always_hide + } -enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) { - DEFAULT(0, R.string.label_always_show), - SYNC(1, R.string.label_sync_with_menus), - HIDDEN(2, R.string.label_always_hide) -} +@get:StringRes +val PageInfoMode.titleRes: Int + get() = when (this) { + PageInfoMode.DEFAULT -> R.string.label_always_show + PageInfoMode.SYNC -> R.string.label_sync_with_menus + PageInfoMode.HIDDEN -> R.string.label_always_hide + } -enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) { - BOTTOM(0, R.string.label_bottom), - TOP(1, R.string.label_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 -) +@get:StringRes +val PageInfoPosition.titleRes: Int + get() = when (this) { + PageInfoPosition.BOTTOM -> R.string.label_bottom + PageInfoPosition.TOP -> R.string.label_top + } private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_" private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_" diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt index a1ae8cd..7781952 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt @@ -327,20 +327,27 @@ private fun handleVerticalAutoAdvance( Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Loading remaining text of current chapter natively.") val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex) - if (!nativeChunks.isNullOrEmpty() && lastReadCfi != null) { - val lastCfiPath = lastReadCfi.split(":")[0] - val resumeIdx = nativeChunks.indexOfLast { it.sourceCfi.split(":")[0] == lastCfiPath } + if (!nativeChunks.isNullOrEmpty()) { + val resumeIdx = findTtsChunkResumeIndex( + chunks = nativeChunks, + sourceCfi = lastReadCfi, + startOffsetInSource = currentState.startOffsetInSource, + currentText = currentState.currentText, + currentChunkIndexFallback = currentState.currentChunkIndex + ) - if (resumeIdx != -1 && resumeIdx + 1 < nativeChunks.size) { - val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size) + if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) { + val startChunkIndex = resumeIdx + 1 val token = getAuthToken() ttsController.start( - chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), + chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), bookTitle = epubBookTitle, chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title, coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() }, + bookId = ttsReplacementBookId, chapterIndex = currentTtsChapterIndex, totalChapters = chapters.size, + startChunkIndex = startChunkIndex, continueSession = true, ttsMode = currentTtsMode, playbackSource = "READER", @@ -371,6 +378,7 @@ private fun handleVerticalAutoAdvance( bookTitle = epubBookTitle, chapterTitle = chapters.getOrNull(nextIdx)?.title, coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() }, + bookId = ttsReplacementBookId, chapterIndex = nextIdx, totalChapters = chapters.size, continueSession = true, @@ -450,6 +458,7 @@ private fun handlePaginatedAutoAdvance( bookTitle = epubBookTitle, chapterTitle = chapterTitle, coverImageUri = coverUriString, + bookId = ttsReplacementBookId, chapterIndex = chapterToTry, totalChapters = chapters.size, continueSession = true, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt new file mode 100644 index 0000000..e02e82a --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubTtsChunkMatching.kt @@ -0,0 +1,105 @@ +package com.aryan.reader.epubreader + +import com.aryan.reader.paginatedreader.CfiUtils +import com.aryan.reader.paginatedreader.TtsChunk +import kotlin.math.abs + +private val TTS_WHITESPACE = Regex("\\s+") + +internal fun sameTtsChunkSource(first: String, second: String): Boolean { + if (first.isBlank() || second.isBlank()) return first == second + val firstPath = CfiUtils.getPath(first) + val secondPath = CfiUtils.getPath(second) + return firstPath == secondPath || cfiPathContains(firstPath, secondPath) || cfiPathContains(secondPath, firstPath) +} + +internal fun findTtsChunkStartIndex( + chunks: List, + target: TtsChunk? +): Int? { + if (target == null) return null + + val exactIndex = chunks.indexOfFirst { + sameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + it.startOffsetInSource == target.startOffsetInSource && + normalizedTtsText(it.text) == normalizedTtsText(target.text) + } + if (exactIndex >= 0) return exactIndex + + val sourceAndOffsetIndex = chunks.indexOfFirst { + sameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + target.startOffsetInSource >= it.startOffsetInSource && + target.startOffsetInSource < it.startOffsetInSource + it.text.length + } + if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex + + val sourceAndTextIndex = chunks.indexOfFirst { + sameTtsChunkSource(it.sourceCfi, target.sourceCfi) && + ttsTextMatches(it.text, target.text) + } + if (sourceAndTextIndex >= 0) return sourceAndTextIndex + + val sourceNearestOffsetIndex = chunks + .mapIndexedNotNull { index, chunk -> + if (sameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) { + index to abs(chunk.startOffsetInSource - target.startOffsetInSource) + } else { + null + } + } + .minByOrNull { it.second } + ?.first + if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex + + return findUniqueTextMatch(chunks, target.text) +} + +internal fun findTtsChunkResumeIndex( + chunks: List, + sourceCfi: String?, + startOffsetInSource: Int, + currentText: String?, + currentChunkIndexFallback: Int +): Int? { + val target = sourceCfi + ?.takeIf { it.isNotBlank() } + ?.let { + TtsChunk( + text = currentText.orEmpty(), + sourceCfi = it, + startOffsetInSource = startOffsetInSource.coerceAtLeast(0) + ) + } + + val matchedIndex = findTtsChunkStartIndex(chunks, target) + ?: currentText?.let { findUniqueTextMatch(chunks, it) } + if (matchedIndex != null) return matchedIndex + + return currentChunkIndexFallback.takeIf { it in chunks.indices } +} + +private fun cfiPathContains(parentPath: String, childPath: String): Boolean { + if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false + val parentParts = parentPath.split('/').filter { it.isNotEmpty() } + val childParts = childPath.split('/').filter { it.isNotEmpty() } + return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts +} + +private fun normalizedTtsText(text: String): String = + text.replace(TTS_WHITESPACE, " ").trim() + +private fun ttsTextMatches(first: String, second: String): Boolean { + val firstNormalized = normalizedTtsText(first) + val secondNormalized = normalizedTtsText(second) + if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false + return firstNormalized == secondNormalized || + firstNormalized.startsWith(secondNormalized) || + secondNormalized.startsWith(firstNormalized) +} + +private fun findUniqueTextMatch(chunks: List, text: String): Int? { + val matches = chunks.mapIndexedNotNull { index, chunk -> + index.takeIf { ttsTextMatches(chunk.text, text) } + } + return matches.singleOrNull() +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt index b2c353f..6438001 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt @@ -38,6 +38,20 @@ import org.json.JSONObject enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM } +internal fun readWebViewHitTestTypeOrNull(hitTestTypeProvider: () -> Int?): Int? { + return try { + hitTestTypeProvider() + } catch (e: NullPointerException) { + Timber.w(e, "WebView hit test state was unavailable for tap.") + null + } +} + +internal fun isWebViewAnchorHitTestType(type: Int?): Boolean { + return type == WebView.HitTestResult.SRC_ANCHOR_TYPE || + type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE +} + @SuppressLint("ViewConstructor") class InteractiveWebView( context: Context, @@ -376,10 +390,11 @@ class InteractiveWebView( override fun onSingleTapConfirmed(e: MotionEvent): Boolean { Timber.d("onSingleTapConfirmed") - val hitTestResult = this@InteractiveWebView.hitTestResult - val type = hitTestResult.type + val type = readWebViewHitTestTypeOrNull { + this@InteractiveWebView.hitTestResult?.type + } - if (type == HitTestResult.SRC_ANCHOR_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { + if (isWebViewAnchorHitTestType(type)) { Timber.d("Tap was on a link. Consuming tap, not toggling app bars.") return true } diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt index b4f9350..ca5807e 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt @@ -6,4 +6,5 @@ typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry +typealias OpdsDownloadState = com.aryan.reader.shared.opds.SharedOpdsDownloadState typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt index 097f95c..068869a 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import com.aryan.reader.shared.opds.SharedOpdsCatalogs +import com.aryan.reader.shared.opds.SharedOpdsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -12,7 +13,7 @@ import timber.log.Timber import java.security.MessageDigest import java.util.UUID -class OpdsRepository(context: Context) { +class OpdsRepository(context: Context) : SharedOpdsRepository { private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE) private val parser = OpdsParser() @@ -34,7 +35,7 @@ class OpdsRepository(context: Context) { private val httpClient = sharedHttpClient - fun getCatalogs(): List { + override fun loadCatalogs(): List { val jsonString = prefs.getString(KEY_CATALOGS_JSON, null) val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString) val catalogs = decodedCatalogs.ifEmpty { @@ -46,10 +47,12 @@ class OpdsRepository(context: Context) { return catalogs } - suspend fun getSearchTemplate( + fun getCatalogs(): List = loadCatalogs() + + override suspend fun getSearchTemplate( openSearchUrl: String, - username: String? = null, - password: String? = null + username: String?, + password: String? ): String? = withContext(Dispatchers.IO) { try { val request = Request.Builder().url(openSearchUrl).build() @@ -65,7 +68,7 @@ class OpdsRepository(context: Context) { fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) { saveCatalogs( SharedOpdsCatalogs.addCatalog( - catalogs = getCatalogs(), + catalogs = loadCatalogs(), title = title, url = url, username = username, @@ -76,14 +79,14 @@ class OpdsRepository(context: Context) { } fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { - saveCatalogs(SharedOpdsCatalogs.updateCatalog(getCatalogs(), id, title, url, username, password)) + saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password)) } fun removeCatalog(id: String) { - saveCatalogs(SharedOpdsCatalogs.removeCatalog(getCatalogs(), id)) + saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id)) } - private fun saveCatalogs(catalogs: List) { + override fun saveCatalogs(catalogs: List) { prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) } } @@ -166,7 +169,7 @@ class OpdsRepository(context: Context) { } - suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result = withContext(Dispatchers.IO) { + override suspend fun fetchFeed(url: String, username: String?, password: String?): Result = withContext(Dispatchers.IO) { Timber.tag("OpdsDebug").d("Starting fetch for URL: $url") try { val client = getAuthenticatedClient(username, password) diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt index c4fbb80..08c1f36 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt @@ -6,135 +6,106 @@ import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.aryan.reader.R +import com.aryan.reader.shared.opds.SharedOpdsController import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer -import com.aryan.reader.shared.opds.SharedOpdsSearch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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 okhttp3.Response import timber.log.Timber import java.io.File +import java.util.UUID class OpdsViewModel(application: Application) : AndroidViewModel(application) { private val repository = OpdsRepository(application) + private val controller = SharedOpdsController( + repository = repository, + feedLoadErrorMessage = { error -> + application.getString(R.string.opds_error_load_feed, error.message.orEmpty()) + }, + idFactory = { UUID.randomUUID().toString() } + ) - private val _uiState = MutableStateFlow(OpdsScreenState()) + private val _uiState = MutableStateFlow(controller.state) val uiState: StateFlow = _uiState.asStateFlow() - private val urlStack = mutableListOf() - - private val _downloadingEntries = MutableStateFlow>(emptySet()) - val downloadingEntries: StateFlow> = _downloadingEntries.asStateFlow() - - private fun fetchUrl(url: String, isPagination: Boolean = false) { - viewModelScope.launch { - val catalog = _uiState.value.currentCatalog - _uiState.update { it.copy(isLoading = true, errorMessage = null, isViewingCatalog = true) } - - val result = repository.fetchFeed(url, catalog?.username, catalog?.password) - result.onSuccess { newFeed -> - val template = newFeed.searchUrl ?: _uiState.value.searchUrlTemplate - if (!isPagination) { - if (urlStack.isEmpty() || urlStack.last() != url) { - urlStack.add(url) - } - _uiState.update { it.copy(isLoading = false, currentFeed = newFeed, searchUrlTemplate = template) } - } else { - _uiState.update { state -> - val currentEntries = state.currentFeed?.entries ?: emptyList() - state.copy( - isLoading = false, - currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries), - searchUrlTemplate = template - ) - } - } - }.onFailure { e -> - _uiState.update { - it.copy( - isLoading = false, - errorMessage = getApplication().getString(R.string.opds_error_load_feed, e.message.orEmpty()) - ) - } - } - } - } - fun loadNextPage() { - val nextUrl = _uiState.value.currentFeed?.nextUrl - if (nextUrl != null && !_uiState.value.isLoading) { - fetchUrl(nextUrl, isPagination = true) + viewModelScope.launch { + controller.loadNextPage(::emitState) } } - data class DownloadState(val isDownloading: Boolean, val progress: Float? = null) - - private val _downloadingState = MutableStateFlow>(emptyMap()) - val downloadingState: StateFlow> = _downloadingState.asStateFlow() - fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) { val downloadUrl = acquisition.url val catalog = _uiState.value.currentCatalog - viewModelScope.launch(Dispatchers.IO) { - _downloadingState.update { it + (entry.id to DownloadState(true, 0f)) } + viewModelScope.launch { + updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f)) try { - val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password) - val request = Request.Builder().url(downloadUrl).build() + val tempFile = withContext(Dispatchers.IO) { + val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password) + val request = Request.Builder().url(downloadUrl).build() - val response = client.newCall(request).execute() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw OpdsDownloadFailedException( + context.getString(R.string.opds_error_download_failed, response.message) + ) + } - if (response.isSuccessful) { - val body = response.body - ?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response)) - val contentLength = body.contentLength() + val body = response.body + ?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response)) + val contentLength = body.contentLength() + val ext = resolveOpdsDownloadExtension(acquisition, response) + val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50) + val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext") - val ext = resolveOpdsDownloadExtension(acquisition, response) + body.byteStream().use { input -> + tempFile.outputStream().use { output -> + val buffer = ByteArray(8 * 1024) + var totalRead = 0L + var lastProgressUpdate = System.currentTimeMillis() - val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50) - val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext") + while (true) { + val bytesRead = input.read(buffer) + if (bytesRead == -1) break + output.write(buffer, 0, bytesRead) + totalRead += bytesRead - val input = body.byteStream() - val output = tempFile.outputStream() - val buffer = ByteArray(8 * 1024) - var bytesRead: Int - var totalRead = 0L - var lastProgressUpdate = System.currentTimeMillis() - - input.use { inp -> - output.use { out -> - while (inp.read(buffer).also { bytesRead = it } != -1) { - out.write(buffer, 0, bytesRead) - totalRead += bytesRead - if (contentLength > 0) { - val now = System.currentTimeMillis() - // Throttle UI updates to 4-5 fps - if (now - lastProgressUpdate > 200) { - val progress = totalRead.toFloat() / contentLength.toFloat() - _downloadingState.update { it + (entry.id to DownloadState(true, progress)) } - lastProgressUpdate = now + if (contentLength > 0) { + val now = System.currentTimeMillis() + if (now - lastProgressUpdate > 200) { + val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f) + withContext(Dispatchers.Main) { + updateDownloadState( + entry.id, + OpdsDownloadState(isDownloading = true, progress = progress) + ) + } + lastProgressUpdate = now + } } } } } + tempFile } - - withContext(Dispatchers.Main) { - onDownloaded(Uri.fromFile(tempFile)) - } - } else { - Timber.e("Download failed: ${response.code}") - _uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_failed, response.message)) } } + + onDownloaded(Uri.fromFile(tempFile)) } catch (e: Exception) { Timber.e(e, "Download error") - _uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_error, e.message.orEmpty())) } + val message = if (e is OpdsDownloadFailedException) { + e.message.orEmpty() + } else { + context.getString(R.string.opds_error_download_error, e.message.orEmpty()) + } + emitState(controller.setErrorMessage(message)) } finally { - _downloadingState.update { it - entry.id } + updateDownloadState(entry.id, null) } } } @@ -147,69 +118,55 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) { ) } - init { - loadCatalogs() - } - - private fun loadCatalogs() { - _uiState.update { it.copy(catalogs = repository.getCatalogs()) } - } - fun addCatalog(title: String, url: String, username: String?, password: String?) { - repository.addCatalog(title, url, username, password) - loadCatalogs() + emitState(controller.addCatalog(title, url, username, password)) } fun removeCatalog(id: String) { - repository.removeCatalog(id) - loadCatalogs() + emitState(controller.removeCatalog(id)) } fun openCatalog(catalog: OpdsCatalog) { - urlStack.clear() - _uiState.update { it.copy(searchUrlTemplate = null, currentCatalog = catalog) } - fetchUrl(catalog.url) - } - - fun openFeedUrl(url: String) { - fetchUrl(url) - } - - fun navigateBack(): Boolean { - if (urlStack.size > 1) { - urlStack.removeAt(urlStack.lastIndex) - val previousUrl = urlStack.last() - urlStack.removeAt(urlStack.lastIndex) - fetchUrl(previousUrl) - return true - } else { - urlStack.clear() - _uiState.update { it.copy(isViewingCatalog = false, currentFeed = null, searchUrlTemplate = null, currentCatalog = null) } - return false + viewModelScope.launch { + controller.openCatalog(catalog, ::emitState) } } + fun openFeedUrl(url: String) { + viewModelScope.launch { + controller.openFeedUrl(url, ::emitState) + } + } + + fun navigateBack(): Boolean { + val returnsToPreviousFeed = controller.hasFeedHistory() + viewModelScope.launch { + controller.navigateBack(::emitState) + } + return returnsToPreviousFeed + } + fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { - repository.updateCatalog(id, title, url, username, password) - loadCatalogs() + emitState(controller.updateCatalog(id, title, url, username, password)) } fun search(query: String) { - val searchLink = _uiState.value.searchUrlTemplate ?: return - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } - - val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl -> - val catalog = _uiState.value.currentCatalog - repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password) - } - - openFeedUrl(finalUrl) + controller.search(query, ::emitState) } } fun clearError() { - _uiState.update { it.copy(errorMessage = null) } + emitState(controller.clearError()) } + + private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) { + emitState(controller.updateDownloadState(entryId, downloadState)) + } + + private fun emitState(state: OpdsScreenState) { + _uiState.value = state + } + + private class OpdsDownloadFailedException(message: String) : Exception(message) } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index 0a51c5c..302131b 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import com.aryan.reader.SearchResult import com.aryan.reader.epub.EpubChapter +import com.aryan.reader.epub.contentFilePath import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.BookProcessingInput import com.aryan.reader.paginatedreader.data.BookProcessingWorker @@ -103,6 +104,14 @@ private data class PaginationRequest(val chapterIndex: Int, val priority: Int) : private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F" +private fun String.normalizedImageSourceForNavigation(): String { + return substringBefore('#') + .substringBefore('?') + .replace('\\', '/') + .removePrefix("file://") + .lowercase() +} + private data class TextRangeIndex( val pageInChapter: Int, val blockIndex: Int, @@ -394,7 +403,7 @@ class BookPaginator( } private fun chapterContentVersion(chapter: EpubChapter): Int { - val backingFile = java.io.File(extractionBasePath, chapter.htmlFilePath) + val backingFile = java.io.File(extractionBasePath, chapter.contentFilePath()) return buildString { append(chapter.absPath) append('|') @@ -656,6 +665,43 @@ class BookPaginator( finalPageIndex } + suspend fun findStablePageForImageSource( + chapterIndex: Int, + sourcePath: String, + elementId: String?, + ordinalInChapter: Int + ): Pair? = withContext(Dispatchers.IO) { + val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null + val imageBlocks = getAllBlocks(getBlocksForChapter(chapter, chapterIndex)) + .filterIsInstance() + if (imageBlocks.isEmpty()) return@withContext null + + val normalizedSource = sourcePath.normalizedImageSourceForNavigation() + val normalizedFileName = normalizedSource.substringAfterLast('/') + val matchingBySource = imageBlocks.filter { block -> + val normalizedBlockPath = block.path.normalizedImageSourceForNavigation() + normalizedBlockPath == normalizedSource || + normalizedBlockPath.endsWith("/$normalizedFileName") || + normalizedSource.endsWith("/${normalizedBlockPath.substringAfterLast('/')}") + } + val targetBlock = elementId + ?.takeIf { it.isNotBlank() } + ?.let { id -> imageBlocks.firstOrNull { it.elementId == id } } + ?: matchingBySource.getOrNull(ordinalInChapter.coerceAtLeast(0)) + ?: matchingBySource.firstOrNull() + ?: return@withContext null + + val locator = Locator( + chapterIndex = chapterIndex, + blockIndex = targetBlock.blockIndex, + charOffset = 0 + ) + val targetPage = findStablePageForLocator(locator) + ?: findStableChapterStartPage(chapterIndex) + ?: return@withContext null + targetPage to locator + } + suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List? { val pages = ensureChapterPaginated(chapterIndex) if (pages.isNullOrEmpty()) { @@ -766,7 +812,7 @@ class BookPaginator( var shouldIgnoreCache = false if (isCacheEmpty && isLazyChapter) { - val file = java.io.File(extractionBasePath, chapter.htmlFilePath) + val file = java.io.File(extractionBasePath, chapter.contentFilePath()) if (file.exists() && file.length() > 0) { Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: Cache HIT but empty for lazy chapter $chapterIndex. Backing file exists (${file.length()} bytes). Ignoring cache.") shouldIgnoreCache = true @@ -789,7 +835,7 @@ class BookPaginator( var htmlToParse = chapter.htmlContent if (htmlToParse.isEmpty()) { - val file = java.io.File(extractionBasePath, chapter.htmlFilePath) + val file = java.io.File(extractionBasePath, chapter.contentFilePath()) if (file.exists()) { Timber.tag("ReflowPaginationDiag").d("getBlocksForChapter: Lazy loading content from disk for chapter $chapterIndex: ${file.name} (${file.length()} bytes)") try { @@ -1187,7 +1233,7 @@ class BookPaginator( Timber.tag("POS_DIAG").d("getPlainTextForChapter: chapterIndex=$chapterIndex, chapterTitle='${chapter.title}', hasInMemoryContent=${chapter.htmlContent.isNotEmpty()}") val htmlToParse = chapter.htmlContent.ifEmpty { try { - val file = java.io.File(extractionBasePath, chapter.htmlFilePath) + val file = java.io.File(extractionBasePath, chapter.contentFilePath()) if (file.exists()) file.readText() else "" } catch (_: Exception) { "" diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt index c2a3be6..a721b46 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.contentFilePath import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.ProcessedChapter import kotlinx.coroutines.Dispatchers @@ -67,7 +68,7 @@ class LocatorConverter( val htmlToParse = chapter.htmlContent.ifBlank { try { - val file = File(book.extractionBasePath, chapter.htmlFilePath) + val file = File(book.extractionBasePath, chapter.contentFilePath()) if (file.exists()) { val content = file.readText() content diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt index 3ce975a..11165da 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt @@ -34,6 +34,7 @@ import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.aryan.reader.epub.epubContentFilePath import com.aryan.reader.paginatedreader.CssParser import com.aryan.reader.paginatedreader.FontFaceInfo import com.aryan.reader.paginatedreader.MathMLRenderer @@ -246,7 +247,7 @@ class BookProcessingWorker( if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) { Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}") val htmlToParse = chapter.htmlContent.ifBlank { - val backingFile = File(extractionBasePath, chapter.htmlFilePath) + val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath)) if (backingFile.exists()) { backingFile.readText() } else { diff --git a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt index ba4f6e7..b5a6ac0 100644 --- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt +++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt @@ -42,6 +42,8 @@ object NativePdfiumBridge { inkPointOffsets: IntArray, inkPointCounts: IntArray, inkPoints: FloatArray, + inkNames: Array, + inkContents: Array, textPageIndices: IntArray, textBounds: FloatArray, textColors: IntArray, @@ -62,7 +64,16 @@ object NativePdfiumBridge { highlightRectOffsets: IntArray, highlightRectCounts: IntArray, highlightRects: FloatArray, - highlightContents: Array + highlightNames: Array, + highlightContents: Array, + highlightCommentOffsets: IntArray, + highlightCommentCounts: IntArray, + highlightCommentParentIndices: IntArray, + highlightCommentNames: Array, + highlightCommentAuthors: Array, + highlightCommentContents: Array, + highlightCommentCreatedDates: Array, + highlightCommentModifiedDates: Array ): Boolean const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfAnnotationModels.kt b/app/src/main/java/com/aryan/reader/pdf/PdfAnnotationModels.kt new file mode 100644 index 0000000..af6df30 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfAnnotationModels.kt @@ -0,0 +1,11 @@ +package com.aryan.reader.pdf + +enum class AnnotationType { + INK, TEXT +} + +enum class InkType { + PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT +} + +data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L) diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt b/app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt new file mode 100644 index 0000000..ce292e3 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt @@ -0,0 +1,111 @@ +package com.aryan.reader.pdf + +import android.graphics.Bitmap +import android.graphics.RectF +import androidx.core.graphics.createBitmap +import com.aryan.reader.ml.SpeechBubble +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt +import android.graphics.Color as AndroidColor + +internal data class ExpandedBubbleRender( + val bitmap: Bitmap, + val zoomFactor: Float +) + +internal fun computeDynamicBubbleZoomFactor( + bubbleBounds: RectF, + viewportWidth: Float, + viewportHeight: Float +): Float { + if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f + val targetWidth = viewportWidth * 0.6f + val targetHeight = viewportHeight * 0.32f + return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height()) + .coerceIn(1.35f, 4.25f) +} + +internal fun isTapInsideBubble( + bubble: SpeechBubble, + tapX: Float, + tapY: Float, + hitSlopPx: Float +): Boolean { + val expandedBounds = RectF(bubble.bounds) + expandedBounds.inset(-hitSlopPx, -hitSlopPx) + if (!expandedBounds.contains(tapX, tapY)) return false + + val mask = bubble.maskBitmap ?: return true + if (!bubble.bounds.contains(tapX, tapY)) return true + + val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f) + val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f) + val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1) + val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1) + return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24 +} + +internal suspend fun renderExpandedBubbleBitmap( + document: ReaderDocument, + pageIndex: Int, + bubbleBounds: RectF, + pageWidth: Int, + pageHeight: Int, + renderScale: Float +): Bitmap? = withContext(Dispatchers.IO) { + if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) { + return@withContext null + } + + document.openPage(pageIndex)?.use { page -> + val safeRenderScale = safePdfBitmapRenderScale( + contentWidth = bubbleBounds.width(), + contentHeight = bubbleBounds.height(), + requestedScale = renderScale + ) + val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1) + val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1) + val bitmap = createBitmap(cropWidth, cropHeight) + + try { + page.renderPageBitmap( + bitmap = bitmap, + startX = (-bubbleBounds.left * safeRenderScale).roundToInt(), + startY = (-bubbleBounds.top * safeRenderScale).roundToInt(), + drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth), + drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight), + renderAnnot = true + ) + bitmap + } catch (t: Throwable) { + bitmap.recycle() + Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex") + null + } + } +} + +internal fun safePdfBitmapRenderScale( + contentWidth: Float, + contentHeight: Float, + requestedScale: Float +): Float { + if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f + + val requestedWidth = contentWidth * requestedScale + val requestedHeight = contentHeight * requestedScale + val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0 + val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0)) + val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() / + max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0) + val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01) + return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat() +} + +internal const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L +internal const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096 diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt index 9eefa8c..2611dd5 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt @@ -24,7 +24,6 @@ import android.graphics.Bitmap import android.net.Uri import timber.log.Timber import androidx.core.graphics.createBitmap -import io.legere.pdfiumandroid.suspend.PdfiumCoreKt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -32,7 +31,6 @@ private const val TAG = "PdfCoverGenerator" class PdfCoverGenerator(context: Context) { private val appContext = context.applicationContext - private val pdfiumCore = PdfiumCoreKt(Dispatchers.IO) /** * Generates a Bitmap cover for the first page of a PDF. @@ -48,37 +46,40 @@ class PdfCoverGenerator(context: Context) { appContext.contentResolver.openFileDescriptor(pdfUri, "r").use { pfd -> if (pfd == null) { Timber.e("Failed to open ParcelFileDescriptor for URI: $pdfUri") - return@withContext null - } - pdfiumCore.newDocument(pfd).use { doc -> - if (doc.getPageCount() == 0) { - Timber.w("PDF has no pages, cannot generate cover: $pdfUri") - return@withContext null - } - doc.openPage(0)?.use { page -> - val originalWidth = page.getPageWidthPoint() - val originalHeight = page.getPageHeightPoint() - if (originalWidth <= 0 || originalHeight <= 0) { - Timber.e("Invalid page dimensions for cover: $pdfUri") - return@withContext null + null + } else { + PdfiumEngineProvider.withPdfium { + PdfiumCoreProvider.core.newDocument(pfd).use { doc -> + if (doc.getPageCount() == 0) { + Timber.w("PDF has no pages, cannot generate cover: $pdfUri") + return@withPdfium null + } + doc.openPage(0)?.use { page -> + val originalWidth = page.getPageWidthPoint() + val originalHeight = page.getPageHeightPoint() + if (originalWidth <= 0 || originalHeight <= 0) { + Timber.e("Invalid page dimensions for cover: $pdfUri") + return@withPdfium null + } + + val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat() + val targetWidth = (targetHeight * aspectRatio).toInt() + + if (targetWidth <= 0) { + Timber.e("Calculated invalid bitmap width for cover: $targetWidth") + return@withPdfium null + } + + val bitmap = createBitmap(targetWidth, targetHeight) + page.renderPageBitmap( + bitmap = bitmap, + startX = 0, startY = 0, + drawSizeX = targetWidth, drawSizeY = targetHeight, + renderAnnot = false + ) + bitmap + } } - - val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat() - val targetWidth = (targetHeight * aspectRatio).toInt() - - if (targetWidth <= 0) { - Timber.e("Calculated invalid bitmap width for cover: $targetWidth") - return@withContext null - } - - val bitmap = createBitmap(targetWidth, targetHeight) - page.renderPageBitmap( - bitmap = bitmap, - startX = 0, startY = 0, - drawSizeX = targetWidth, drawSizeY = targetHeight, - renderAnnot = false - ) - bitmap } } } @@ -88,4 +89,4 @@ class PdfCoverGenerator(context: Context) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt index 13026f8..e1a789d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt @@ -158,21 +158,40 @@ internal fun shouldShowPdfAnnotationExportChoice( internal fun getFastFileId(context: Context, uri: Uri): String { var result = uri.toString() + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "id.fast.start uri=$uri scheme=${uri.scheme}" + ) try { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (uri.scheme == "file") { + uri.path?.let { + val file = java.io.File(it) + result = "${file.name}_${file.length()}" + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "id.fast.file uri=$uri path=${file.absolutePath} exists=${file.exists()} " + + "name=${file.name} size=${file.length()} mtime=${file.lastModified()} result=$result" + ) + } + } else { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L - val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown" + val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L + val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown" - result = "${name}_${size}" + result = "${name}_${size}" + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "id.fast.content uri=$uri name=$name size=$size result=$result" + ) + } } } } catch (e: Exception) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "id.fast.failed uri=$uri fallback=$result") Timber.e(e, "Failed to generate fast file ID") } + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i("id.fast.done uri=$uri result=$result") return result } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt index e7265e6..ebfe680 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt @@ -48,6 +48,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -81,6 +82,7 @@ import kotlinx.coroutines.withContext import org.json.JSONArray import timber.log.Timber import androidx.core.graphics.createBitmap +import com.aryan.reader.cardTitle import com.aryan.reader.data.RecentFileItem import com.aryan.reader.pdf.data.VirtualPage @@ -316,9 +318,12 @@ private fun PdfTabsDrawerPage( activeTabBookId: String?, currentPage: Int, totalPages: Int, + isTopTabStripVisible: Boolean, onTabSelected: (String) -> Unit, onTabClosed: (String) -> Unit, - onNewTabClick: () -> Unit + onNewTabClick: () -> Unit, + onTopTabStripVisibilityChange: (Boolean) -> Unit, + usePdfFileNameAsDisplayName: Boolean ) { Column(modifier = Modifier.fillMaxSize()) { Row( @@ -357,6 +362,28 @@ private fun PdfTabsDrawerPage( HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onTopTabStripVisibilityChange(!isTopTabStripVisible) } + .padding(start = 16.dp, end = 12.dp, top = 10.dp, bottom = 10.dp) + .testTag("PdfTopTabStripVisibilityToggle"), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.pdf_tabs_show_top_app_bar_tabs), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Switch( + checked = isTopTabStripVisible, + onCheckedChange = null + ) + } + + HorizontalDivider() + if (openTabs.isEmpty()) { Box( modifier = Modifier.fillMaxSize().padding(16.dp), @@ -382,7 +409,8 @@ private fun PdfTabsDrawerPage( currentPage = currentPage, totalPages = totalPages, onTabSelected = onTabSelected, - onTabClosed = onTabClosed + onTabClosed = onTabClosed, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName ) } } @@ -397,7 +425,8 @@ private fun PdfDrawerTabItem( currentPage: Int, totalPages: Int, onTabSelected: (String) -> Unit, - onTabClosed: (String) -> Unit + onTabClosed: (String) -> Unit, + usePdfFileNameAsDisplayName: Boolean ) { val shape = RoundedCornerShape(8.dp) val containerColor by animateColorAsState( @@ -485,7 +514,7 @@ private fun PdfDrawerTabItem( Column(modifier = Modifier.weight(1f)) { Text( - text = tab.customName ?: tab.title ?: tab.displayName, + text = tab.cardTitle(usePdfFileNameAsDisplayName), style = MaterialTheme.typography.bodyLarge, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, color = contentColor, @@ -542,11 +571,14 @@ internal fun PdfNavigationDrawerContent( isTabsEnabled: Boolean = false, openTabs: List = emptyList(), activeTabBookId: String? = null, + usePdfFileNameAsDisplayName: Boolean = false, + isTopTabStripVisible: Boolean = true, customHighlightColors: Map, onPageSelected: (Int) -> Unit, onTabSelected: (String) -> Unit = {}, onTabClosed: (String) -> Unit = {}, onNewTabClick: () -> Unit = {}, + onTopTabStripVisibilityChange: (Boolean) -> Unit = {}, onRenameBookmark: (PdfBookmark, String) -> Unit, onDeleteBookmark: (PdfBookmark) -> Unit, onDeleteHighlight: (PdfUserHighlight) -> Unit, @@ -601,6 +633,7 @@ internal fun PdfNavigationDrawerContent( activeTabBookId = activeTabBookId, currentPage = currentPage, totalPages = totalPages, + isTopTabStripVisible = isTopTabStripVisible, onTabSelected = { bookId -> if (bookId == activeTabBookId) { onCloseDrawer() @@ -610,7 +643,9 @@ internal fun PdfNavigationDrawerContent( } }, onTabClosed = onTabClosed, - onNewTabClick = onNewTabClick + onNewTabClick = onNewTabClick, + onTopTabStripVisibilityChange = onTopTabStripVisibilityChange, + usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName ) PdfDrawerSection.CHAPTERS -> { // Chapters Page diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfEmbeddedAnnotations.kt b/app/src/main/java/com/aryan/reader/pdf/PdfEmbeddedAnnotations.kt new file mode 100644 index 0000000..bf62806 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfEmbeddedAnnotations.kt @@ -0,0 +1,64 @@ +package com.aryan.reader.pdf + +import android.graphics.RectF +import timber.log.Timber + +data class EmbeddedAnnotation( + val index: Int, + val subtype: Int, + val rect: RectF, + val contents: String?, + val author: String?, + val name: String?, + val inReplyTo: String?, + val replies: MutableList = mutableListOf() +) + +internal fun groupEmbeddedAnnotationsForDisplay( + annotations: List +): List { + if (annotations.isEmpty()) return emptyList() + + val annotMap = annotations + .filter { !it.name.isNullOrBlank() } + .associateBy { it.name } + val orphans = mutableListOf() + + annotations.forEach { annot -> + if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) { + Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}") + annotMap[annot.inReplyTo]?.replies?.add(annot) + } else { + orphans.add(annot) + } + } + + Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}") + + val groupedRoots = mutableListOf>() + orphans.forEach { annot -> + val match = groupedRoots.find { group -> + val root = group.first() + val inflatedRoot = RectF(root.rect).apply { inset(-10f, -10f) } + RectF.intersects(inflatedRoot, annot.rect) + } + if (match != null) { + Timber.tag("PdfCommentDebug").w( + "Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!" + ) + match.add(annot) + } else { + groupedRoots.add(mutableListOf(annot)) + } + } + + return groupedRoots.map { group -> + val root = group.first() + if (group.size > 1) { + root.replies.addAll(group.drop(1)) + } + root + }.filter { + !it.contents.isNullOrBlank() || it.replies.any { reply -> !reply.contents.isNullOrBlank() } + } +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index c68c492..e41b138 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -96,7 +96,10 @@ import com.aryan.reader.pdf.ocr.OcrElement import com.aryan.reader.pdf.ocr.OcrLine import com.aryan.reader.pdf.ocr.OcrResult import com.aryan.reader.pdf.ocr.OcrSymbol +import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment import timber.log.Timber +import java.text.DateFormat +import java.util.Date import java.util.UUID enum class OcrLanguage(@StringRes val displayNameRes: Int) { @@ -135,7 +138,8 @@ data class PdfUserHighlight( val color: PdfHighlightColor, val text: String, val range: Pair, - val note: String? = null + val note: String? = null, + val comments: List = emptyList() ) internal data class CustomPdfMenuState( @@ -699,6 +703,13 @@ fun PdfHighlightColorRow( } } +private enum class PdfAnnotationSheetSection { + NOTE, + COMMENTS +} + +private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader" + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PdfAnnotationBottomSheet( @@ -709,7 +720,8 @@ fun PdfAnnotationBottomSheet( onPaletteClick: (() -> Unit)? = null, onColorChange: (PdfHighlightColor) -> Unit, onDismiss: () -> Unit, - onSave: (String) -> Unit, + onSave: (String, List) -> Unit, + onUpdate: (String, List) -> Unit = { _, _ -> }, onDelete: () -> Unit, onCopy: () -> Unit, onDictionary: () -> Unit, @@ -718,6 +730,24 @@ fun PdfAnnotationBottomSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var noteText by remember { mutableStateOf(highlight.note ?: "") } + var comments by remember(highlight.id) { mutableStateOf(highlight.comments) } + var selectedSection by remember(highlight.id) { mutableStateOf(PdfAnnotationSheetSection.NOTE) } + var commentText by remember(highlight.id) { mutableStateOf("") } + var replyTargetId by remember(highlight.id) { mutableStateOf(null) } + var editingCommentId by remember(highlight.id) { mutableStateOf(null) } + var commentAuthor by remember(highlight.id) { + mutableStateOf( + highlight.comments + .lastOrNull { it.author.isNotBlank() } + ?.author + ?: DEFAULT_PDF_COMMENT_AUTHOR + ) + } + + fun persistComments(nextComments: List) { + comments = nextComments + onUpdate(noteText, nextComments) + } ModalBottomSheet( onDismissRequest = onDismiss, @@ -775,23 +805,98 @@ fun PdfAnnotationBottomSheet( Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = noteText, - onValueChange = { noteText = it }, - placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp), - maxLines = 5, - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = effectiveText.copy(alpha = 0.3f), - focusedTextColor = effectiveText, - unfocusedTextColor = effectiveText - ), - shape = RoundedCornerShape(12.dp) + PdfAnnotationSheetTabs( + selectedSection = selectedSection, + commentCount = comments.count { it.contents.isNotBlank() }, + effectiveText = effectiveText, + onSectionChange = { selectedSection = it } ) + Spacer(Modifier.height(12.dp)) + + if (selectedSection == PdfAnnotationSheetSection.NOTE) { + OutlinedTextField( + value = noteText, + onValueChange = { noteText = it }, + placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp), + maxLines = 5, + colors = pdfAnnotationTextFieldColors(effectiveText), + shape = RoundedCornerShape(12.dp) + ) + } else { + PdfHighlightCommentsEditor( + comments = comments, + commentText = commentText, + commentAuthor = commentAuthor, + replyTargetId = replyTargetId, + editingCommentId = editingCommentId, + effectiveText = effectiveText, + onCommentTextChange = { commentText = it }, + onCommentAuthorChange = { commentAuthor = it }, + onReply = { + editingCommentId = null + replyTargetId = it.id + commentText = "" + }, + onCancelReply = { replyTargetId = null }, + onEdit = { comment -> + editingCommentId = comment.id + replyTargetId = null + commentText = comment.contents + commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + }, + onCancelEdit = { + editingCommentId = null + commentText = "" + }, + onDelete = { comment -> + val nextComments = comments.withoutCommentThread(comment.id) + persistComments(nextComments) + if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) { + replyTargetId = null + } + if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) { + editingCommentId = null + commentText = "" + } + }, + onAddComment = { + val contents = commentText.trim() + if (contents.isNotBlank()) { + val now = System.currentTimeMillis() + val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR } + val nextComments = if (editingCommentId != null) { + comments.map { comment -> + if (comment.id == editingCommentId) { + comment.copy( + author = author, + contents = contents, + modifiedAt = now + ) + } else { + comment + } + } + } else { + comments + SharedPdfAnnotationComment( + id = UUID.randomUUID().toString(), + parentId = replyTargetId, + author = author, + contents = contents, + createdAt = now, + modifiedAt = now + ) + } + persistComments(nextComments) + commentText = "" + replyTargetId = null + editingCommentId = null + } + } + ) + } + Spacer(Modifier.height(24.dp)) Row( @@ -811,19 +916,326 @@ fun PdfAnnotationBottomSheet( Text(stringResource(R.string.action_delete)) } Button( - onClick = { onSave(noteText) }, + onClick = { onSave(noteText, comments) }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) ) { - Text(stringResource(R.string.action_save_note)) + Text(stringResource(R.string.action_done)) } } } } } +@Composable +private fun PdfAnnotationSheetTabs( + selectedSection: PdfAnnotationSheetSection, + commentCount: Int, + effectiveText: Color, + onSectionChange: (PdfAnnotationSheetSection) -> Unit +) { + Surface( + color = effectiveText.copy(alpha = 0.06f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(4.dp)) { + PdfAnnotationSheetTab( + label = stringResource(R.string.label_note), + selected = selectedSection == PdfAnnotationSheetSection.NOTE, + effectiveText = effectiveText, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(PdfAnnotationSheetSection.NOTE) } + ) + PdfAnnotationSheetTab( + label = "${stringResource(R.string.label_comments)} ($commentCount)", + selected = selectedSection == PdfAnnotationSheetSection.COMMENTS, + effectiveText = effectiveText, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(PdfAnnotationSheetSection.COMMENTS) } + ) + } + } +} + +@Composable +private fun PdfAnnotationSheetTab( + label: String, + selected: Boolean, + effectiveText: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else effectiveText, + shape = RoundedCornerShape(6.dp), + modifier = modifier + .height(40.dp) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun PdfHighlightCommentsEditor( + comments: List, + commentText: String, + commentAuthor: String, + replyTargetId: String?, + editingCommentId: String?, + effectiveText: Color, + onCommentTextChange: (String) -> Unit, + onCommentAuthorChange: (String) -> Unit, + onReply: (SharedPdfAnnotationComment) -> Unit, + onCancelReply: () -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onCancelEdit: () -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit, + onAddComment: () -> Unit +) { + val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet() + val visibleComments = comments + .filter { it.contents.isNotBlank() } + .map { comment -> + if (comment.parentId != null && comment.parentId !in commentIds) { + comment.copy(parentId = null) + } else { + comment + } + } + val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId } + val editingComment = visibleComments.firstOrNull { it.id == editingCommentId } + + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 220.dp) + .verticalScroll(rememberScrollState()) + ) { + PdfHighlightCommentThread( + comments = visibleComments, + parentId = null, + depth = 0, + visitedIds = emptySet(), + effectiveText = effectiveText, + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } + + if (editingComment != null || replyTarget != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (editingComment != null) { + stringResource(R.string.label_editing_comment) + } else { + stringResource( + R.string.label_replying_to, + replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty() + ) + }, + style = MaterialTheme.typography.labelMedium, + color = effectiveText.copy(alpha = 0.7f), + modifier = Modifier.weight(1f) + ) + TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) { + Text(stringResource(R.string.action_cancel)) + } + } + } + + OutlinedTextField( + value = commentAuthor, + onValueChange = onCommentAuthorChange, + label = { Text(stringResource(R.string.author)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = pdfAnnotationTextFieldColors(effectiveText), + shape = RoundedCornerShape(12.dp) + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = commentText, + onValueChange = onCommentTextChange, + placeholder = { + Text( + stringResource(R.string.placeholder_add_comment), + color = effectiveText.copy(alpha = 0.5f) + ) + }, + modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp), + maxLines = 4, + colors = pdfAnnotationTextFieldColors(effectiveText), + shape = RoundedCornerShape(12.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) { + Text( + stringResource( + if (editingComment != null) R.string.action_save_comment else R.string.action_add_comment + ) + ) + } + } + } +} + +@Composable +private fun PdfHighlightCommentThread( + comments: List, + parentId: String?, + depth: Int, + visitedIds: Set, + effectiveText: Color, + onReply: (SharedPdfAnnotationComment) -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit +) { + comments + .filter { it.parentId == parentId } + .sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id })) + .forEach { comment -> + if (comment.id in visitedIds) return@forEach + PdfHighlightCommentItem( + comment = comment, + depth = depth, + effectiveText = effectiveText, + onReply = { onReply(comment) }, + onEdit = { onEdit(comment) }, + onDelete = { onDelete(comment) } + ) + PdfHighlightCommentThread( + comments = comments, + parentId = comment.id, + depth = depth + 1, + visitedIds = visitedIds + comment.id, + effectiveText = effectiveText, + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun PdfHighlightCommentItem( + comment: SharedPdfAnnotationComment, + depth: Int, + effectiveText: Color, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + val indentSize = (depth * 16).dp + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentSize, top = 6.dp, bottom = 6.dp) + ) { + if (depth > 0) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + val timestamp = comment.createdAt.formatPdfCommentTimestamp() + if (timestamp.isNotBlank()) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = effectiveText.copy(alpha = 0.55f) + ) + } + } + Spacer(Modifier.height(2.dp)) + Text( + text = comment.contents, + style = MaterialTheme.typography.bodyMedium, + color = effectiveText + ) + Row { + TextButton(onClick = onReply) { + Text(stringResource(R.string.action_reply)) + } + TextButton(onClick = onEdit) { + Text(stringResource(R.string.label_edit)) + } + TextButton(onClick = onDelete) { + Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + } + } + } + } +} + +@Composable +private fun pdfAnnotationTextFieldColors(effectiveText: Color) = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = effectiveText.copy(alpha = 0.3f), + focusedTextColor = effectiveText, + unfocusedTextColor = effectiveText + ) + +private fun List.withoutCommentThread(commentId: String): List { + val childrenByParentId = groupBy { it.parentId } + val idsToRemove = mutableSetOf() + + fun collect(id: String) { + if (!idsToRemove.add(id)) return + childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) } + } + + collect(commentId) + return filterNot { it.id in idsToRemove } +} + +private fun Long.formatPdfCommentTimestamp(): String { + if (this <= 0L) return "" + return runCatching { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this)) + }.getOrDefault("") +} + @Composable private fun PdfBottomSheetToolButton( icon: Int, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt b/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt index 5dd45de..0831a0b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt @@ -7,26 +7,18 @@ import androidx.media3.common.util.UnstableApi import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.tts.TtsPlaybackManager -internal enum class SaveMode { - ORIGINAL, ANNOTATED -} +internal typealias SaveMode = com.aryan.reader.shared.SaveMode -enum class SearchHighlightMode { - FOCUSED, ALL -} +typealias SearchHighlightMode = com.aryan.reader.shared.SearchHighlightMode internal sealed interface HistoryAction { data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction data class Remove(val items: Map>) : HistoryAction } -internal enum class DockLocation { - TOP, BOTTOM, FLOATING -} +internal typealias DockLocation = com.aryan.reader.shared.DockLocation -internal enum class DisplayMode { - PAGINATION, VERTICAL_SCROLL -} +internal typealias DisplayMode = com.aryan.reader.shared.PdfDisplayMode @OptIn(UnstableApi::class) @Suppress("unused") diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt index a37225c..a42e8af 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt @@ -154,6 +154,15 @@ fun VerticalScrollbar( @Composable internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) { + PageScrubbingAnimation( + pageLabel = "Page $currentPage of $totalPages" + ) +} + +@Composable +internal fun PageScrubbingAnimation( + pageLabel: String +) { Box( modifier = Modifier .fillMaxSize() @@ -178,7 +187,7 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) { ) Spacer(Modifier.height(12.dp)) Text( - text = "Page $currentPage of $totalPages", + text = pageLabel, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface ) @@ -188,9 +197,16 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) { @Composable internal fun ThumbnailWithIndicator( - thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit + thumbnail: Bitmap, + modifier: Modifier = Modifier, + borderColor: Color = Color.Unspecified, + onClick: () -> Unit ) { - val borderColor = MaterialTheme.colorScheme.primary + val effectiveBorderColor = if (borderColor == Color.Unspecified) { + MaterialTheme.colorScheme.primary + } else { + borderColor + } Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Surface( modifier = Modifier @@ -198,7 +214,7 @@ internal fun ThumbnailWithIndicator( .height(64.dp) .clickable(onClick = onClick), shape = RoundedCornerShape(4.dp), - border = BorderStroke(2.dp, borderColor) + border = BorderStroke(2.dp, effectiveBorderColor) ) { Image( bitmap = thumbnail.asImageBitmap(), @@ -211,7 +227,7 @@ internal fun ThumbnailWithIndicator( .offset(y = (-4).dp) .size(8.dp) .rotate(45f) - .background(borderColor)) + .background(effectiveBorderColor)) } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt b/app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt new file mode 100644 index 0000000..ee5afc4 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt @@ -0,0 +1,357 @@ +package com.aryan.reader.pdf + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.platform.ViewConfiguration +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.pow + +internal const val PDF_ONE_HAND_ZOOM_TRACE_TAG = "PdfOneHandZoomTrace" +internal const val PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS = 90L +internal const val PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP = 240f + +internal enum class PdfSecondTapZoomAction { + QUICK_DOUBLE_TAP, + ONE_HAND_ZOOM, + HELD_NO_MOVEMENT +} + +internal fun classifyPdfSecondTapZoomAction( + pressDurationMillis: Long, + totalDragY: Float, + movementSlopPx: Float, + holdTimeoutMillis: Long = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS +): PdfSecondTapZoomAction { + return when { + pressDurationMillis < holdTimeoutMillis -> PdfSecondTapZoomAction.QUICK_DOUBLE_TAP + abs(totalDragY) >= movementSlopPx -> PdfSecondTapZoomAction.ONE_HAND_ZOOM + else -> PdfSecondTapZoomAction.HELD_NO_MOVEMENT + } +} + +internal fun pdfOneHandZoomScale( + startScale: Float, + totalDragY: Float, + dragDistanceForDoublePx: Float, + minScale: Float, + maxScale: Float +): Float { + val safeStart = startScale.takeIf { it.isFinite() && it > 0f } ?: minScale + val safeDistance = dragDistanceForDoublePx.takeIf { it.isFinite() && it > 0f } ?: 1f + val scaleMultiplier = 2f.pow(totalDragY / safeDistance) + return (safeStart * scaleMultiplier).coerceIn(minScale, maxScale) +} + +internal fun clampCenteredPdfCameraOffset( + scale: Float, + offset: Offset, + viewportSize: Size, + contentSize: Size +): Offset { + if (viewportSize.width <= 0f || viewportSize.height <= 0f || scale <= 1f) { + return Offset.Zero + } + val maxOffsetX = ((contentSize.width * scale) - viewportSize.width).coerceAtLeast(0f) / 2f + val maxOffsetY = ((contentSize.height * scale) - viewportSize.height).coerceAtLeast(0f) / 2f + return Offset( + x = offset.x.coerceIn(-maxOffsetX, maxOffsetX), + y = offset.y.coerceIn(-maxOffsetY, maxOffsetY) + ) +} + +internal fun centeredPdfCameraOffsetForScaleChange( + previousScale: Float, + nextScale: Float, + previousOffset: Offset, + pivot: Offset, + viewportSize: Size, + contentSize: Size +): Offset { + val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f + val ratio = nextScale / safePreviousScale + val viewportCenter = Offset(viewportSize.width / 2f, viewportSize.height / 2f) + val targetOffset = previousOffset * ratio + (pivot - viewportCenter) * (1f - ratio) + return clampCenteredPdfCameraOffset( + scale = nextScale, + offset = targetOffset, + viewportSize = viewportSize, + contentSize = contentSize + ) +} + +internal fun topLeftPdfPanForScaleChange( + previousScale: Float, + nextScale: Float, + previousPan: Offset, + pivot: Offset +): Offset { + val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f + val contentPivot = (pivot - previousPan) / safePreviousScale + return pivot - (contentPivot * nextScale) +} + +private fun Offset.traceString(): String = "(${x.toInt()},${y.toInt()})" + +private fun PointerInputChange.traceString(): String { + return "id=$id pos=${position.traceString()} prev=${previousPosition.traceString()} pressed=$pressed consumed=$isConsumed" +} + +private fun traceOneHandZoom(message: String) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(message) +} + +internal suspend fun PointerInputScope.detectPdfTapAndOneHandZoomGestures( + viewConfiguration: ViewConfiguration, + canStartOneHandZoom: () -> Boolean, + canHandleQuickDoubleTap: () -> Boolean = { true }, + consumeSingleTap: Boolean = true, + onTap: (Offset) -> Unit, + onQuickDoubleTap: (Offset) -> Unit, + onOneHandZoomHoldStart: (Offset) -> Unit, + onOneHandZoom: (pivot: Offset, totalDragY: Float) -> Unit, + onOneHandZoomEnd: (started: Boolean) -> Unit +) { + awaitEachGesture { + val firstDown = awaitFirstDown(requireUnconsumed = false) + traceOneHandZoom( + "detector.firstDown ${firstDown.traceString()} consumeSingleTap=$consumeSingleTap " + + "doubleTapTimeout=${viewConfiguration.doubleTapTimeoutMillis} holdTimeout=$PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS" + ) + val firstUp = waitForUpOrCancellation() + if (firstUp == null) { + traceOneHandZoom("detector.firstUpCanceled first=${firstDown.traceString()}") + return@awaitEachGesture + } + traceOneHandZoom("detector.firstUp ${firstUp.traceString()}") + + val secondDown = awaitPdfSecondDown( + firstPointerId = firstDown.id, + timeoutMillis = viewConfiguration.doubleTapTimeoutMillis + ) + + if (secondDown == null) { + traceOneHandZoom( + "detector.singleTap noSecondDown consume=$consumeSingleTap firstUpConsumedBefore=${firstUp.isConsumed} " + + "tap=${firstDown.position.traceString()}" + ) + if (consumeSingleTap) firstUp.consume() + onTap(firstDown.position) + return@awaitEachGesture + } + + val pivot = secondDown.position + var latestPosition = pivot + var quickDoubleTapUp: PointerInputChange? = null + var canceled = false + val oneHandAllowed = canStartOneHandZoom() + val movementSlopPx = max(2f, viewConfiguration.touchSlop * 0.35f) + var shouldStartOneHandZoom = false + traceOneHandZoom( + "detector.secondDown ${secondDown.traceString()} oneHandAllowed=$oneHandAllowed " + + "quickAllowed=${canHandleQuickDoubleTap()} movementSlop=$movementSlopPx touchSlop=${viewConfiguration.touchSlop}" + ) + + try { + withTimeout(PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS) { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == secondDown.id } + if (change == null) { + canceled = true + traceOneHandZoom( + "detector.preHoldCancel missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}" + ) + return@withTimeout + } + + latestPosition = change.position + + if (change.changedToUp()) { + quickDoubleTapUp = change + traceOneHandZoom("detector.preHoldQuickUp ${change.traceString()}") + return@withTimeout + } + + if (change.isConsumed) { + canceled = true + traceOneHandZoom( + "detector.preHoldCancel consumedByOther ${change.traceString()} " + + "allChanges=${event.changes.joinToString { it.traceString() }}" + ) + return@withTimeout + } + + if (oneHandAllowed) { + val delta = change.position - pivot + val isVerticalZoomDrag = abs(delta.y) >= movementSlopPx && + abs(delta.y) >= abs(delta.x) * 1.1f + if (isVerticalZoomDrag) { + shouldStartOneHandZoom = true + traceOneHandZoom( + "detector.preHoldStart verticalDrag delta=${delta.traceString()} " + + "slop=$movementSlopPx change=${change.traceString()}" + ) + change.consume() + return@withTimeout + } else if (delta.getDistance() >= viewConfiguration.touchSlop) { + canceled = true + traceOneHandZoom( + "detector.preHoldCancel nonZoomMove delta=${delta.traceString()} " + + "distance=${delta.getDistance()} touchSlop=${viewConfiguration.touchSlop}" + ) + return@withTimeout + } + } + } + } + } catch (_: PointerEventTimeoutCancellationException) { + // The second tap is being held, so the quick double-tap action is suppressed. + shouldStartOneHandZoom = true + traceOneHandZoom( + "detector.holdTimeout pivot=${pivot.traceString()} latest=${latestPosition.traceString()} " + + "oneHandAllowed=$oneHandAllowed" + ) + } + + if (canceled) { + traceOneHandZoom("detector.end canceledBeforeAction latest=${latestPosition.traceString()}") + return@awaitEachGesture + } + + if (quickDoubleTapUp != null) { + val quickAllowed = canHandleQuickDoubleTap() + traceOneHandZoom( + "detector.quickDoubleTap fire quickAllowed=$quickAllowed upConsumedBefore=${quickDoubleTapUp?.isConsumed} " + + "pivot=${pivot.traceString()}" + ) + if (quickAllowed) { + quickDoubleTapUp?.consume() + } + onQuickDoubleTap(pivot) + return@awaitEachGesture + } + + if (!oneHandAllowed) { + traceOneHandZoom("detector.oneHandBlocked waitingForUp pivot=${pivot.traceString()}") + waitForUpOrCancellation() + return@awaitEachGesture + } + + if (!shouldStartOneHandZoom) { + traceOneHandZoom("detector.end noAction shouldStart=false latest=${latestPosition.traceString()}") + return@awaitEachGesture + } + + var zoomStarted = false + + fun updateZoom(position: Offset) { + val totalDragY = position.y - pivot.y + val action = classifyPdfSecondTapZoomAction( + pressDurationMillis = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS, + totalDragY = totalDragY, + movementSlopPx = movementSlopPx + ) + if (action == PdfSecondTapZoomAction.ONE_HAND_ZOOM) { + if (!zoomStarted) { + traceOneHandZoom( + "detector.oneHandZoomStart dragY=$totalDragY pivot=${pivot.traceString()} " + + "position=${position.traceString()} slop=$movementSlopPx" + ) + } + zoomStarted = true + } + if (zoomStarted) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v( + "detector.oneHandZoomUpdate dragY=$totalDragY pivot=${pivot.traceString()} position=${position.traceString()}" + ) + onOneHandZoom(pivot, totalDragY) + } + } + + traceOneHandZoom( + "detector.oneHandHoldStart pivot=${pivot.traceString()} latest=${latestPosition.traceString()}" + ) + onOneHandZoomHoldStart(pivot) + try { + updateZoom(latestPosition) + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == secondDown.id } + if (change == null) { + traceOneHandZoom( + "detector.postHoldEnd missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}" + ) + break + } + + latestPosition = change.position + if (change.isConsumed) { + traceOneHandZoom( + "detector.postHoldEnd consumedByOther ${change.traceString()} zoomStarted=$zoomStarted" + ) + break + } + + val isPositionChanged = change.positionChanged() + val isUp = change.changedToUp() + updateZoom(latestPosition) + + if (isPositionChanged || zoomStarted || isUp) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v( + "detector.consumePostHold changed=$isPositionChanged zoomStarted=$zoomStarted up=$isUp " + + change.traceString() + ) + change.consume() + } + + if (isUp) { + traceOneHandZoom( + "detector.oneHandPointerUp zoomStarted=$zoomStarted latest=${latestPosition.traceString()}" + ) + break + } + } + } finally { + traceOneHandZoom( + "detector.oneHandEnd zoomStarted=$zoomStarted latest=${latestPosition.traceString()}" + ) + onOneHandZoomEnd(zoomStarted) + } + } +} + +private suspend fun androidx.compose.ui.input.pointer.AwaitPointerEventScope.awaitPdfSecondDown( + firstPointerId: PointerId, + timeoutMillis: Long +): PointerInputChange? { + return try { + withTimeout(timeoutMillis) { + while (true) { + val event = awaitPointerEvent() + val secondDown = event.changes.firstOrNull { + it.id != firstPointerId && it.changedToDown() + } ?: event.changes.firstOrNull { + it.id == firstPointerId && it.changedToDown() + } + if (secondDown != null) return@withTimeout secondDown + } + null + } + } catch (_: PointerEventTimeoutCancellationException) { + null + } +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageAnnotationRemapping.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageAnnotationRemapping.kt new file mode 100644 index 0000000..50ae6e1 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageAnnotationRemapping.kt @@ -0,0 +1,196 @@ +package com.aryan.reader.pdf + +import com.aryan.reader.pdf.data.PdfAnnotation +import com.aryan.reader.pdf.data.PdfTextBox +import com.aryan.reader.pdf.data.VirtualPage +import org.json.JSONArray +import org.json.JSONObject + +internal fun remapPdfAnnotationsForLayoutChange( + currentLayout: List, + updatedLayout: List, + annotations: Map> +): Map> { + if (annotations.isEmpty()) return emptyMap() + + val mapping = buildPdfPageIndexMapping( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + sourcePageIndices = annotations.keys + ) + val remapped = linkedMapOf>() + + annotations.toSortedMap().forEach { (sourcePageIndex, pageAnnotations) -> + val targetPageIndex = mapping[sourcePageIndex] ?: return@forEach + pageAnnotations.forEach { annotation -> + remapped.getOrPut(targetPageIndex) { mutableListOf() } + .add(annotation.copy(pageIndex = targetPageIndex)) + } + } + + return remapped.mapValues { (_, pageAnnotations) -> pageAnnotations.toList() } +} + +internal fun remapPdfTextBoxesForLayoutChange( + currentLayout: List, + updatedLayout: List, + textBoxes: List +): List { + if (textBoxes.isEmpty()) return emptyList() + + val mapping = buildPdfPageIndexMapping( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + sourcePageIndices = textBoxes.map { it.pageIndex } + ) + + return textBoxes.mapNotNull { box -> + mapping[box.pageIndex]?.let { targetPageIndex -> + box.copy(pageIndex = targetPageIndex) + } + } +} + +internal fun remapPdfUserHighlightsForLayoutChange( + currentLayout: List, + updatedLayout: List, + highlights: List +): List { + if (highlights.isEmpty()) return emptyList() + + val mapping = buildPdfPageIndexMapping( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + sourcePageIndices = highlights.map { it.pageIndex } + ) + + return highlights.mapNotNull { highlight -> + mapping[highlight.pageIndex]?.let { targetPageIndex -> + highlight.copy(pageIndex = targetPageIndex) + } + } +} + +internal fun remapPdfHistoryActionsForLayoutChange( + currentLayout: List, + updatedLayout: List, + actions: List +): List { + if (actions.isEmpty()) return emptyList() + + return actions.mapNotNull { action -> + when (action) { + is HistoryAction.Add -> { + val mapping = buildPdfPageIndexMapping( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + sourcePageIndices = listOf(action.pageIndex) + ) + val targetPageIndex = mapping[action.pageIndex] ?: return@mapNotNull null + action.copy( + pageIndex = targetPageIndex, + annotation = action.annotation.copy(pageIndex = targetPageIndex) + ) + } + is HistoryAction.Remove -> { + val remappedItems = remapPdfAnnotationsForLayoutChange( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + annotations = action.items + ) + remappedItems.takeIf { it.isNotEmpty() }?.let(HistoryAction::Remove) + } + } + } +} + +internal fun remapPdfBookmarksJsonForLayoutChange( + currentLayout: List, + updatedLayout: List, + currentBookmarksJson: String +): String { + if (currentBookmarksJson.isBlank()) return "[]" + + val jsonArray = JSONArray(currentBookmarksJson) + val sourcePageIndices = buildList { + for (i in 0 until jsonArray.length()) { + val pageIndex = jsonArray.optJSONObject(i)?.optInt("pageIndex", Int.MIN_VALUE) + if (pageIndex != null && pageIndex != Int.MIN_VALUE) add(pageIndex) + } + } + val mapping = buildPdfPageIndexMapping( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + sourcePageIndices = sourcePageIndices + ) + val newArray = JSONArray() + + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + val sourcePageIndex = obj.optInt("pageIndex", Int.MIN_VALUE) + val targetPageIndex = mapping[sourcePageIndex] ?: continue + val newObj = JSONObject(obj.toString()) + newObj.put("pageIndex", targetPageIndex) + newObj.put("totalPages", updatedLayout.size) + newArray.put(newObj) + } + + return newArray.toString() +} + +internal fun buildPdfPageIndexMapping( + currentLayout: List, + updatedLayout: List, + sourcePageIndices: Iterable +): Map { + val distinctSourcePageIndices = sourcePageIndices.toSet() + if (distinctSourcePageIndices.isEmpty()) return emptyMap() + + val minimumCurrentPageCount = maxOf( + currentLayout.size, + distinctSourcePageIndices.maxOrNull()?.plus(1) ?: 0 + ) + val effectiveCurrentLayout = currentLayout.withDefaultPdfPagesUntil(minimumCurrentPageCount) + val currentTokens = effectiveCurrentLayout.toOccurrenceTokens() + val updatedTokenIndices = updatedLayout.toOccurrenceTokens() + .mapIndexed { index, token -> token to index } + .toMap() + + return distinctSourcePageIndices.mapNotNull { sourcePageIndex -> + val token = currentTokens.getOrNull(sourcePageIndex) ?: return@mapNotNull null + val targetPageIndex = updatedTokenIndices[token] ?: return@mapNotNull null + sourcePageIndex to targetPageIndex + }.toMap() +} + +private fun List.withDefaultPdfPagesUntil(pageCount: Int): List { + if (size >= pageCount) return this + return this + (size until pageCount).map { VirtualPage.PdfPage(it) } +} + +private fun List.toOccurrenceTokens(): List { + val seen = mutableMapOf() + return map { page -> + val key = page.toVirtualPageKey() + val occurrence = seen.getOrDefault(key, 0) + seen[key] = occurrence + 1 + VirtualPageOccurrenceToken(key, occurrence) + } +} + +private fun VirtualPage.toVirtualPageKey(): VirtualPageKey { + return when (this) { + is VirtualPage.PdfPage -> VirtualPageKey.Pdf(pdfIndex) + is VirtualPage.BlankPage -> VirtualPageKey.Blank(id) + } +} + +private data class VirtualPageOccurrenceToken( + val key: VirtualPageKey, + val occurrence: Int +) + +private sealed interface VirtualPageKey { + data class Pdf(val pdfIndex: Int) : VirtualPageKey + data class Blank(val id: String) : VirtualPageKey +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 7bd4f57..de8d13e 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -11,7 +11,6 @@ import android.graphics.PorterDuffColorFilter import android.graphics.Rect import android.graphics.RectF import android.graphics.Shader -import android.util.LruCache import androidx.activity.compose.BackHandler import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.animation.core.Animatable @@ -71,9 +70,7 @@ 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 @@ -120,7 +117,6 @@ import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.zIndex import androidx.core.graphics.createBitmap import androidx.core.graphics.scale -import androidx.core.graphics.set import com.aryan.reader.R import com.aryan.reader.SearchResult import com.aryan.reader.isCanvasSafeBitmap @@ -145,101 +141,23 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import timber.log.Timber -import java.util.concurrent.ConcurrentLinkedQueue import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.PI import kotlin.math.abs -import kotlin.math.atan2 -import kotlin.math.cos import kotlin.math.max import kotlin.math.min import kotlin.math.pow import kotlin.math.roundToInt -import kotlin.math.sin -import kotlin.math.sqrt -import android.graphics.Color as AndroidColor import android.graphics.Paint as NativePaint enum class Handle { START, END } -enum class AnnotationType { - INK, TEXT -} - -enum class InkType { - PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT -} - -internal fun shouldReportPdfPageCamera( - isZoomEnabled: Boolean, - isVerticalScroll: Boolean, - isScrollLocked: Boolean, - lockedState: Triple?, - hasAppliedLockedState: Boolean -): Boolean { - return !isZoomEnabled || - isVerticalScroll || - !isScrollLocked || - lockedState == null || - hasAppliedLockedState -} - -internal fun initialPdfPageCamera( - isZoomEnabled: Boolean, - isVerticalScroll: Boolean, - isScrollLocked: Boolean, - lockedState: Triple? -): Pair { - return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) { - lockedState.first to Offset(lockedState.second, lockedState.third) - } else { - 1f to Offset.Zero - } -} - -internal fun shouldResetPdfZoomAfterBubbleZoomCleanup( - isBubbleZoomModeActive: Boolean, - scale: Float, - isVerticalScroll: Boolean, - isZoomEnabled: Boolean, - isScrollLocked: Boolean -): Boolean { - return !isBubbleZoomModeActive && - scale > 1f && - !isVerticalScroll && - isZoomEnabled && - !isScrollLocked -} - -data class EmbeddedAnnotation( - val index: Int, - val subtype: Int, - val rect: android.graphics.RectF, - val contents: String?, - val author: String?, - val name: String?, - val inReplyTo: String?, - val replies: MutableList = mutableListOf() -) - -data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L) - -data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f) - -internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String { - val sourcePageId = when (virtualPage) { - is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}" - is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}" - null -> "PDF_$pageIndex" - } - return "$documentKey:$sourcePageId" -} - private fun Throwable.readablePdfErrorDetail(): String { return localizedMessage?.takeIf { it.isNotBlank() } ?: javaClass.simpleName.takeIf { it.isNotBlank() } @@ -248,262 +166,12 @@ private fun Throwable.readablePdfErrorDetail(): String { private const val PDF_TILE_SIZE_DP = 256 private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072 -private const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L -private const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096 private const val PDF_TILE_SCALE_TOLERANCE = 0.06f -private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L +private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L +private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L 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 -} - -data class PageLink( - val highlightBounds: Rect, - val tapBounds: Rect, - val url: String?, - val destPageIdx: Int?, - val source: LinkSource -) - -private data class ExpandedBubbleRender( - val bitmap: Bitmap, - val zoomFactor: Float -) - -private fun computeDynamicBubbleZoomFactor( - bubbleBounds: RectF, - viewportWidth: Float, - viewportHeight: Float -): Float { - if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f - val targetWidth = viewportWidth * 0.6f - val targetHeight = viewportHeight * 0.32f - return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height()) - .coerceIn(1.35f, 4.25f) -} - -private fun isTapInsideBubble( - bubble: SpeechBubble, - tapX: Float, - tapY: Float, - hitSlopPx: Float -): Boolean { - val expandedBounds = RectF(bubble.bounds) - expandedBounds.inset(-hitSlopPx, -hitSlopPx) - if (!expandedBounds.contains(tapX, tapY)) return false - - val mask = bubble.maskBitmap ?: return true - if (!bubble.bounds.contains(tapX, tapY)) return true - - val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f) - val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f) - val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1) - val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1) - return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24 -} - -private suspend fun renderExpandedBubbleBitmap( - document: ReaderDocument, - pageIndex: Int, - bubbleBounds: RectF, - pageWidth: Int, - pageHeight: Int, - renderScale: Float -): Bitmap? = withContext(Dispatchers.IO) { - if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) { - return@withContext null - } - - document.openPage(pageIndex)?.use { page -> - val safeRenderScale = safePdfBitmapRenderScale( - contentWidth = bubbleBounds.width(), - contentHeight = bubbleBounds.height(), - requestedScale = renderScale - ) - val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1) - val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1) - val bitmap = createBitmap(cropWidth, cropHeight) - - try { - page.renderPageBitmap( - bitmap = bitmap, - startX = (-bubbleBounds.left * safeRenderScale).roundToInt(), - startY = (-bubbleBounds.top * safeRenderScale).roundToInt(), - drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth), - drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight), - renderAnnot = true - ) - bitmap - } catch (t: Throwable) { - bitmap.recycle() - Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex") - null - } - } -} - -private fun safePdfBitmapRenderScale( - contentWidth: Float, - contentHeight: Float, - requestedScale: Float -): Float { - if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f - - val requestedWidth = contentWidth * requestedScale - val requestedHeight = contentHeight * requestedScale - val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0 - val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0)) - val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() / - max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0) - val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01) - return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat() -} - -object PdfInkGeometry { - fun calculateFountainPenPoints( - points: List, baseWidth: Float, pageWidth: Float, pageHeight: Float - ): Pair, List> { - if (points.size < 2) return Pair(emptyList(), emptyList()) - - if (points.size % 50 == 0) { - Timber.tag("FountainPenDebug").d( - "Calculate Points: PWidth=$pageWidth, PHeight=$pageHeight, BaseW=$baseWidth, Pts=${points.size}" - ) - } - - val leftSide = mutableListOf() - val rightSide = mutableListOf() - - val computedWidths = FloatArray(points.size) - computedWidths[0] = baseWidth - - val velocityFactor = 300f - - for (i in 1 until points.size) { - val p1 = points[i - 1] - val p2 = points[i] - - val dxNorm = p2.x - p1.x - val dyNorm = p2.y - p1.y - val aspect = if (pageWidth > 0 && pageHeight > 0) pageHeight / pageWidth else 1f - val distNorm = sqrt(dxNorm * dxNorm + (dyNorm * aspect) * (dyNorm * aspect)) - - val timeDelta = (p2.timestamp - p1.timestamp).coerceAtLeast(1) - val velocityNorm = distNorm / timeDelta - - val targetWidth = (baseWidth * (1f / (1f + velocityNorm * velocityFactor))).coerceIn( - baseWidth * 0.2f, baseWidth * 1.4f - ) - - computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f - - if (i < 5) { - Timber.tag("FountainPenDebug").v( - "Pt[$i]: dt=$timeDelta, velNorm=$velocityNorm, width=${computedWidths[i]} (base=$baseWidth)" - ) - } - } - - for (i in 0 until points.size - 1) { - val pCurrent = points[i] - val pNext = points[i + 1] - - val curX = pCurrent.x * pageWidth - val curY = pCurrent.y * pageHeight - val nextX = pNext.x * pageWidth - val nextY = pNext.y * pageHeight - - val angle = atan2(nextY - curY, nextX - curX) - val normalAngle = angle - (PI / 2f).toFloat() - - val w = computedWidths[i] / 2f - - leftSide.add(Offset((curX + cos(normalAngle) * w), (curY + sin(normalAngle) * w))) - rightSide.add(Offset((curX - cos(normalAngle) * w), (curY - sin(normalAngle) * w))) - } - - val lastIdx = points.lastIndex - val lastP = points[lastIdx] - val prevP = points[lastIdx - 1] - - val lastX = lastP.x * pageWidth - val lastY = lastP.y * pageHeight - val prevX = prevP.x * pageWidth - val prevY = prevP.y * pageHeight - - val lastAngle = atan2(lastY - prevY, lastX - prevX) - val lastNormal = lastAngle - (PI / 2f).toFloat() - val lastW = computedWidths[lastIdx] / 2f - - leftSide.add(Offset((lastX + cos(lastNormal) * lastW), (lastY + sin(lastNormal) * lastW))) - rightSide.add(Offset((lastX - cos(lastNormal) * lastW), (lastY - sin(lastNormal) * lastW))) - - return Pair(leftSide, rightSide) - } -} - -internal object PdfBitmapPool { - private val pool = ConcurrentLinkedQueue() - private const val MAX_POOL_SIZE = 4 - - fun get(width: Int, height: Int): Bitmap { - val iterator = pool.iterator() - while (iterator.hasNext()) { - val b = iterator.next() - if (b.width == width && b.height == height && !b.isRecycled) { - iterator.remove() - b.eraseColor(AndroidColor.TRANSPARENT) - return b - } - } - return createBitmap(width, height) - } - - fun get(size: Int): Bitmap = get(size, size) - - fun recycle(bitmap: Bitmap) { - // Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps. - if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) { - pool.offer(bitmap) - } - } - - fun clear() { - while (!pool.isEmpty()) { - pool.poll() - } - } -} - -internal object PdfThumbnailCache { - private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() - private val cacheSize = maxMemory / 8 - - private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int) - - private val memoryCache = object : LruCache(cacheSize) { - override fun sizeOf(key: String, entry: CacheEntry): Int { - return entry.sizeKb - } - } - - fun get(pageId: String): Bitmap? { - return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled } - } - - fun put(pageId: String, bitmap: Bitmap) { - if (get(pageId) == null) { - val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1) - memoryCache.put(pageId, CacheEntry(bitmap, sizeKb)) - } - } - - fun clear() { - memoryCache.evictAll() - } -} +private val pdfHighResTileRenderMutex = Mutex() @Stable data class StableHolder(val item: T) @@ -512,6 +180,7 @@ data class StableHolder(val item: T) data class PageStaticData( val bitmap: StableHolder, val tiles: StableHolder>, + val shouldDrawHighResTiles: Boolean, val effectiveScale: Float, val centeringOffsetX: Float, val centeringOffsetY: Float, @@ -601,6 +270,9 @@ internal fun PdfPageComposable( activeTextureAlpha: Float = 0.55f, excludeImages: Boolean = false, onDoubleTap: ((Offset) -> Unit)? = null, + onDoubleTapDragZoomStart: ((Offset) -> Unit)? = null, + onDoubleTapDragZoom: ((Offset, Float) -> Unit)? = null, + onDoubleTapDragZoomEnd: (() -> Unit)? = null, isEditMode: Boolean = false, drawingState: PdfDrawingState? = null, pageAnnotations: () -> List = { emptyList() }, @@ -661,6 +333,8 @@ internal fun PdfPageComposable( var ocrRipplePosition by remember { mutableStateOf(null) } var isTransforming by remember { mutableStateOf(false) } + var isPaginationPageGestureActive by remember { mutableStateOf(false) } + var isPageTileRenderIdleCooldownActive by remember { mutableStateOf(false) } val initialCamera = initialPdfPageCamera( isZoomEnabled = isZoomEnabled, isVerticalScroll = isVerticalScroll, @@ -670,6 +344,17 @@ internal fun PdfPageComposable( var scale by remember(targetPageId) { mutableFloatStateOf(initialCamera.first) } var offset by remember(targetPageId) { mutableStateOf(initialCamera.second) } var paginationPanFlingJob by remember { mutableStateOf(null) } + val shouldPauseHighResTileRendering = + isScrolling || + isTransforming || + isPaginationPageGestureActive || + paginationPanFlingJob != null || + isPageTileRenderIdleCooldownActive + val pageMotionActive = + isScrolling || + isTransforming || + isPaginationPageGestureActive || + paginationPanFlingJob != null var hasAppliedLockedPaginationState by remember(targetPageId) { mutableStateOf(initialCamera.second != Offset.Zero || initialCamera.first != 1f) } @@ -690,9 +375,14 @@ internal fun PdfPageComposable( val currentOnSingleTap by rememberUpdatedState(onSingleTap) val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap) val currentOnDoubleTap by rememberUpdatedState(onDoubleTap) + val currentOnDoubleTapDragZoomStart by rememberUpdatedState(onDoubleTapDragZoomStart) + val currentOnDoubleTapDragZoom by rememberUpdatedState(onDoubleTapDragZoom) + val currentOnDoubleTapDragZoomEnd by rememberUpdatedState(onDoubleTapDragZoomEnd) val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale val effectiveOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero + val latestScale by rememberUpdatedState(scale) + val latestOffset by rememberUpdatedState(offset) var eraserPosition by remember { mutableStateOf(null) } @@ -712,6 +402,25 @@ internal fun PdfPageComposable( val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() } val latestEffectiveScale by rememberUpdatedState(effectiveScale) val latestEffectiveOffset by rememberUpdatedState(effectiveOffset) + val latestIsScrolling by rememberUpdatedState(isScrolling) + val latestIsAutoScrollPlaying by rememberUpdatedState(isAutoScrollPlaying) + val latestShouldPauseHighResTileRendering by rememberUpdatedState(shouldPauseHighResTileRendering) + + LaunchedEffect(isVerticalScroll, pageMotionActive) { + if (isVerticalScroll) { + if (isPageTileRenderIdleCooldownActive) { + isPageTileRenderIdleCooldownActive = false + } + return@LaunchedEffect + } + + if (pageMotionActive) { + isPageTileRenderIdleCooldownActive = true + } else if (isPageTileRenderIdleCooldownActive) { + delay(PDF_TILE_RENDER_IDLE_COOLDOWN_MS) + isPageTileRenderIdleCooldownActive = false + } + } SideEffect { Timber.tag("PdfDrawPerf") @@ -838,6 +547,7 @@ internal fun PdfPageComposable( val inputScale = if (isZoomEnabled && !isVerticalScroll) scale else 1f val inputOffset = if (isZoomEnabled && !isVerticalScroll) offset else Offset.Zero + val latestInputScale by rememberUpdatedState(inputScale) val screenToContentCoordinates: (Offset) -> Offset = { screenOffset -> val screenCenter = Offset(canvasWidthPx.floatValue / 2f, canvasHeightPx.floatValue / 2f) @@ -852,6 +562,9 @@ internal fun PdfPageComposable( val screenOffset = (pCanvas - screenCenter) * inputScale + screenCenter + inputOffset screenOffset } + val latestScreenToContentCoordinates by rememberUpdatedState(screenToContentCoordinates) + var isOneHandZooming by remember(targetPageId) { mutableStateOf(false) } + val latestIsOneHandZooming by rememberUpdatedState(isOneHandZooming) var detectedBubbles by remember(targetPageId) { mutableStateOf>(emptyList()) } var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) } @@ -1281,127 +994,23 @@ internal fun PdfPageComposable( Timber.e(e, "Error fetching web links") } - // --- Extract Image Bounds --- - try { - val pagePtr = pageWrapper.getNativePointer() - if (pagePtr != 0L) { - val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr) - val imgRects = mutableListOf() - val outRect = FloatArray(4) + val nativeExtraction = (pageWrapper as? PdfPageWrapper)?.extractNativePageOverlays( + bitmapWidthPx = actualBitmapWidthPx, + bitmapHeightPx = actualBitmapHeightPx, + pageRotation = currentPageRotation, + pageIndex = pageIndex, + linkAnnotationSubtype = annotLink + ) - for (i in 0 until objCount) { - 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]), - max(outRect[0], outRect[2]), - min(outRect[1], outRect[3]) - ) - val deviceRect = pageWrapper.mapRectToDevice( - 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, - currentPageRotation, pdfRectF - ) - if (deviceRect.width() > 0 && deviceRect.height() > 0) { - imgRects.add(android.graphics.Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom)) - } - } - } - } - mappedImageRects = imgRects - } - } catch (e: Exception) { - Timber.tag("PdfImageDebug").e(e, "Error extracting image rects") - } - - // 3. Extract Embedded Annotations - try { - val pagePtr = pageWrapper.getNativePointer() - - if (pagePtr != 0L) { - val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr) - Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") - if (count > 0) { - val allAnnots = (0 until count).mapNotNull { i -> - val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i) - if (subtype == annotLink) return@mapNotNull null - - var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents") - if (contents.isNullOrBlank()) { - contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC") - } - - val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM") - val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT") - val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T") - - val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i) - val pdfRectF = if (pdfRectArray != null) { - android.graphics.RectF( - min(pdfRectArray[0], pdfRectArray[2]), - max(pdfRectArray[1], pdfRectArray[3]), - max(pdfRectArray[0], pdfRectArray[2]), - min(pdfRectArray[1], pdfRectArray[3]) - ) - } else android.graphics.RectF() - - EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt) - } - - val annotMap = allAnnots.associateBy { it.name } - val orphans = mutableListOf() - - allAnnots.forEach { annot -> - if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) { - Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}") - annotMap[annot.inReplyTo]?.replies?.add(annot) - } else { - orphans.add(annot) - } - } - - Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}") - - val groupedRoots = mutableListOf>() - orphans.forEach { annot -> - val match = groupedRoots.find { group -> - val root = group.first() - val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) } - android.graphics.RectF.intersects(inflatedRoot, annot.rect) - } - if (match != null) { - Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!") - match.add(annot) - } else { - groupedRoots.add(mutableListOf(annot)) - } - } - - val rootsWithReplies = groupedRoots.map { group -> - val root = group.first() - if (group.size > 1) { - root.replies.addAll(group.drop(1)) - } - root - } - - finalDisplayList = rootsWithReplies.filter { - !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() } - } - - mappedAnnots = finalDisplayList.map { annot -> - val screenRect = pageWrapper.mapRectToDevice( - 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, - currentPageRotation, annot.rect - ) - annot to screenRect - } - } - } else { + if (nativeExtraction == null) { + Timber.tag("PdfCommentDebug").w("Page $pageIndex: Native overlay extraction unavailable for ${pageWrapper::class.java.simpleName}.") + } else { + if (!nativeExtraction.resolvedNativePointer) { Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.") } - } catch (e: Exception) { - Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations") + mappedImageRects = nativeExtraction.imageScreenRects + finalDisplayList = nativeExtraction.embeddedAnnotations + mappedAnnots = nativeExtraction.annotationScreenRects } } } catch (e: Exception) { @@ -1452,13 +1061,17 @@ internal fun PdfPageComposable( canvasWidthPx.floatValue, canvasHeightPx.floatValue, isVerticalScroll, - isScrolling, - isAutoScrollPlaying, virtualPage, isActivePage ) { + var lastTileDiagLogMs = 0L if (!needsTilingNow) { if (tiles.isNotEmpty()) { + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "tile-clear page=$pageIndex reason=tiling-disabled count=${tiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } val oldTiles = tiles tiles = emptyList() withContext(Dispatchers.IO) { @@ -1471,12 +1084,24 @@ internal fun PdfPageComposable( val screenWidth = canvasWidthPx.floatValue val screenHeight = canvasHeightPx.floatValue - if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || screenWidth == 0f || screenHeight == 0f) return@LaunchedEffect + if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || screenWidth == 0f || screenHeight == 0f) { + if (isVerticalScroll) { + PdfVerticalPerfLog.w( + "tile-skip page=$pageIndex reason=empty-dimensions bitmap=${actualBitmapWidthPx}x$actualBitmapHeightPx screen=${PdfVerticalPerfLog.xy(screenWidth, screenHeight)}" + ) + } + return@LaunchedEffect + } var page: ReaderPage? = null if (!isPdfPage) { if (tiles.isNotEmpty()) { + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "tile-clear page=$pageIndex reason=virtual-page count=${tiles.size}" + ) + } val oldTiles = tiles tiles = emptyList() withContext(Dispatchers.IO) { @@ -1488,16 +1113,23 @@ internal fun PdfPageComposable( try { page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) } + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "tile-loop-open page=$pageIndex pdfPage=$pdfPageIndex bitmap=${actualBitmapWidthPx}x$actualBitmapHeightPx " + + "screen=${PdfVerticalPerfLog.xy(screenWidth, screenHeight)} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } snapshotFlow { val rect = visibleScreenRect() val observedScale = latestEffectiveScale + val pauseMarker = if (latestShouldPauseHighResTileRendering) 1 else 0 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, (observedScale * 10f).roundToInt()) + listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt(), pauseMarker) } else if (!isVerticalScroll) { val observedOffset = latestEffectiveOffset val pivotX = screenWidth / 2f @@ -1511,7 +1143,7 @@ internal fun PdfPageComposable( 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()) + listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt(), pauseMarker) } else { null } @@ -1583,6 +1215,18 @@ internal fun PdfPageComposable( val tilesToRecycleIds = currentTileIds - requiredTileIds val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f + val nowMs = System.currentTimeMillis() + val shouldLogHighResTile = isVerticalScroll || (!isVerticalScroll && renderScale > 1f) + val shouldLogTileSample = shouldLogHighResTile && nowMs - lastTileDiagLogMs >= PdfVerticalPerfLog.SAMPLE_INTERVAL_MS + val tileLogMode = if (isVerticalScroll) "vertical" else "pagination" + if (shouldLogTileSample) { + lastTileDiagLogMs = nowMs + PdfVerticalPerfLog.d( + "tile-scan mode=$tileLogMode page=$pageIndex scale=${PdfVerticalPerfLog.f(renderScale)} scrolling=$latestIsScrolling pause=$latestShouldPauseHighResTileRendering auto=$latestIsAutoScrollPlaying " + + "visible=$visibleBitmapRect required=${requiredTileIds.size} render=${tilesToRenderIds.size} recycle=${tilesToRecycleIds.size} " + + "cached=${tiles.size} calcMs=${PdfVerticalPerfLog.f(duration)}" + ) + } if (duration > 2f) { Timber.tag("PdfPerformance").d( "Page $pageIndex | Tile Calc took ${duration}ms | Tiles Needed: ${requiredTileIds.size}" @@ -1597,16 +1241,43 @@ internal fun PdfPageComposable( } } - if (isScrolling && renderScale > 1f) { + if (latestShouldPauseHighResTileRendering && renderScale > 1f) { + if (shouldLogTileSample) { + PdfVerticalPerfLog.d( + "tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " + + "missing=${tilesToRenderIds.size} current=${tiles.size}" + ) + } return@collectLatest } if (requiredTileIds != validCurrentTileIds) { if (tilesToRenderIds.isNotEmpty()) { + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-queued mode=$tileLogMode page=$pageIndex missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(renderScale)} delay=${PDF_TILE_IDLE_RENDER_DELAY_MS}ms" + ) + } delay(PDF_TILE_IDLE_RENDER_DELAY_MS) if (!isActive) return@collectLatest - if (isScrolling && latestEffectiveScale > 1f) return@collectLatest + if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } + return@collectLatest + } + if (abs(latestEffectiveScale - renderScale) > PDF_TILE_SCALE_TOLERANCE) { + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=scale-changed-before-native missing=${tilesToRenderIds.size} queuedScale=${PdfVerticalPerfLog.f(renderScale)} latestScale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } + return@collectLatest + } + val renderStartNanos = PdfVerticalPerfLog.nowNanos() val renderedTiles = withContext(Dispatchers.IO) { val newTiles = mutableListOf() tilesToRenderIds.forEach { tileId -> @@ -1635,19 +1306,73 @@ internal fun PdfPageComposable( val tileRenderX = (col * tileSizePx * tileRenderScale).toInt() val tileRenderY = (row * tileSizePx * tileRenderScale).toInt() - page?.renderPageBitmap( - bitmap = tileBitmap, - startX = -tileRenderX, - startY = -tileRenderY, - drawSizeX = fullPageRenderWidth, - drawSizeY = fullPageRenderHeight, - renderAnnot = true - ) + val tilePage = page + if (tilePage == null) { + PdfBitmapPool.recycle(tileBitmap) + return@forEach + } + + var didRenderTile = false + val tileRenderWaitStartNanos = PdfVerticalPerfLog.nowNanos() + var singleTileStartNanos = tileRenderWaitStartNanos + pdfHighResTileRenderMutex.withLock { + val tileRenderWaitMs = PdfVerticalPerfLog.elapsedMs(tileRenderWaitStartNanos) + if (shouldLogHighResTile && tileRenderWaitMs >= 16L) { + PdfVerticalPerfLog.d( + "tile-render-wait mode=$tileLogMode page=$pageIndex tile=$tileId duration=${tileRenderWaitMs}ms" + ) + } + if (!isActive) return@withLock + if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } + return@withLock + } + if (abs(latestEffectiveScale - renderScale) > PDF_TILE_SCALE_TOLERANCE) { + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-canceled mode=$tileLogMode page=$pageIndex reason=scale-changed-at-native tile=$tileId queuedScale=${PdfVerticalPerfLog.f(renderScale)} latestScale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } + return@withLock + } + singleTileStartNanos = PdfVerticalPerfLog.nowNanos() + tilePage.renderPageBitmap( + bitmap = tileBitmap, + startX = -tileRenderX, + startY = -tileRenderY, + drawSizeX = fullPageRenderWidth, + drawSizeY = fullPageRenderHeight, + renderAnnot = true + ) + didRenderTile = true + } + if (!didRenderTile) { + PdfBitmapPool.recycle(tileBitmap) + return@forEach + } + val singleTileMs = PdfVerticalPerfLog.elapsedMs(singleTileStartNanos) + if (shouldLogHighResTile && singleTileMs >= 16L) { + PdfVerticalPerfLog.d( + "tile-render-slow mode=$tileLogMode page=$pageIndex tile=$tileId duration=${singleTileMs}ms " + + "tileBitmap=${tileBitmap.width}x${tileBitmap.height} full=${fullPageRenderWidth}x$fullPageRenderHeight scale=${PdfVerticalPerfLog.f(tileRenderScale)}" + ) + } newTiles += PdfTile(tileBitmap, tileRect, tileId, renderScale) } newTiles } + val renderMs = PdfVerticalPerfLog.elapsedMs(renderStartNanos) + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-finished mode=$tileLogMode page=$pageIndex requested=${tilesToRenderIds.size} rendered=${renderedTiles.size} " + + "duration=${renderMs}ms scale=${PdfVerticalPerfLog.f(renderScale)} stillScrolling=$latestIsScrolling paused=$latestShouldPauseHighResTileRendering" + ) + } if (!isActive) { withContext(Dispatchers.IO) { @@ -1655,6 +1380,28 @@ internal fun PdfPageComposable( } return@collectLatest } + if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) { + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } + withContext(Dispatchers.IO) { + renderedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) } + } + return@collectLatest + } + if (renderedTiles.isNotEmpty() && abs(latestEffectiveScale - renderScale) > PDF_TILE_SCALE_TOLERANCE) { + if (shouldLogHighResTile) { + PdfVerticalPerfLog.d( + "tile-render-discarded mode=$tileLogMode page=$pageIndex reason=scale-changed-before-commit rendered=${renderedTiles.size} queuedScale=${PdfVerticalPerfLog.f(renderScale)} latestScale=${PdfVerticalPerfLog.f(latestEffectiveScale)}" + ) + } + withContext(Dispatchers.IO) { + renderedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) } + } + return@collectLatest + } if (renderedTiles.isNotEmpty()) { val renderedIds = renderedTiles.map { it.tileId }.toSet() @@ -2585,6 +2332,10 @@ internal fun PdfPageComposable( waitForUpOrCancellation() } } catch (_: PointerEventTimeoutCancellationException) { + if (latestIsOneHandZooming) { + waitForUpOrCancellation()?.consume() + return@awaitEachGesture + } down.consume() Timber.d( "PointerInput: Long press detected at screen position ${down.position}" @@ -2866,8 +2617,6 @@ internal fun PdfPageComposable( .pointerInput( actualBitmapWidthPx, actualBitmapHeightPx, - scale, - offset, customMenuState, selectionCharRange.value, pageLinks, @@ -2886,14 +2635,52 @@ internal fun PdfPageComposable( selectedTool == InkType.TEXT || isStylusOnlyMode - if (!isTapDetectionAllowed) return@pointerInput + if (!isTapDetectionAllowed) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.detector.disabled page=$pageIndex vertical=$isVerticalScroll edit=$isEditMode " + + "tool=$selectedTool stylusOnly=$isStylusOnlyMode" + ) + return@pointerInput + } - detectTapGestures(onTap = { tapOffset -> + val oneHandZoomDistancePx = with(density) { + PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP.dp.toPx() + } + var oneHandZoomStartScale = 1f + var oneHandZoomStartOffset = Offset.Zero + + fun canZoomByDoubleTap(): Boolean { + return (isZoomEnabled && !isVerticalScroll && !isScrollLocked && actualBitmapWidthPx > 0) || + (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) + } + + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.detector.enabled page=$pageIndex vertical=$isVerticalScroll zoomEnabled=$isZoomEnabled " + + "scrollLocked=$isScrollLocked bitmap=${actualBitmapWidthPx}x$actualBitmapHeightPx " + + "scale=$latestScale offset=$latestOffset hasDoubleTap=${currentOnDoubleTap != null} " + + "hasDragZoom=${currentOnDoubleTapDragZoom != null}" + ) + + detectPdfTapAndOneHandZoomGestures( + viewConfiguration = viewConfiguration, + canStartOneHandZoom = { + (isZoomEnabled && !isVerticalScroll && !isScrollLocked && actualBitmapWidthPx > 0) || + (isVerticalScroll && !isScrollLocked && currentOnDoubleTapDragZoom != null) + }, + canHandleQuickDoubleTap = { canZoomByDoubleTap() }, + consumeSingleTap = true, + onTap = tapDetector@{ tapOffset -> + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap page=$pageIndex vertical=$isVerticalScroll offset=$tapOffset" + ) if (currentOnPreSingleTap?.invoke(tapOffset) == true) { - return@detectTapGestures + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.preSingleTapConsumed page=$pageIndex vertical=$isVerticalScroll" + ) + return@tapDetector } - val tapInContentCoords = screenToContentCoordinates(tapOffset) + val tapInContentCoords = latestScreenToContentCoordinates(tapOffset) val tapXInBitmap = tapInContentCoords.x val tapYInBitmap = tapInContentCoords.y val isWithinContentBounds = @@ -2901,8 +2688,12 @@ internal fun PdfPageComposable( tapYInBitmap in 0f..actualBitmapHeightPx.toFloat() if (!isWithinContentBounds) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.outsideContent page=$pageIndex vertical=$isVerticalScroll " + + "bitmapTap=(${tapXInBitmap.toInt()},${tapYInBitmap.toInt()})" + ) currentOnSingleTap(tapOffset) - return@detectTapGestures + return@tapDetector } Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)") @@ -2925,10 +2716,10 @@ internal fun PdfPageComposable( } else { tappedBubbleIndex } - return@detectTapGestures + return@tapDetector } else if (currentExpandedBubbleIndex != -1) { expandedBubbleIndex = -1 - return@detectTapGestures + return@tapDetector } } @@ -2936,44 +2727,28 @@ internal fun PdfPageComposable( val nativeResult = withContext(Dispatchers.IO) { try { pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> - val pagePtr = page.getNativePointer() + val nativeTap = (page as? PdfPageWrapper)?.resolveNativeTap( + documentWrapper = pdfDocumentItem as? PdfDocumentWrapper, + bitmapWidthPx = actualBitmapWidthPx, + bitmapHeightPx = actualBitmapHeightPx, + pageRotation = currentPageRotation, + deviceX = tapInContentCoords.x.toInt(), + deviceY = tapInContentCoords.y.toInt() + ) ?: return@withContext 0 - if (pagePtr == 0L) { + if (!nativeTap.resolvedNativePointer) { Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex") return@withContext 0 } - val pdfCoords = page.mapDeviceCoordsToPage( - 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, - currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt() - ) - - val docPtr = try { - val pdfDocKt = (pdfDocumentItem as? PdfDocumentWrapper)?.pdfDocument - if (pdfDocKt != null) { - val documentField = pdfDocKt.javaClass.getDeclaredField("document").apply { isAccessible = true } - val docUInstance = documentField.get(pdfDocKt) - if (docUInstance != null) { - val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true } - ptrField.get(docUInstance) as Long - } else 0L - } else 0L - } catch (e: Exception) { 0L } - - Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr") - - val linkInfo = PdfiumEngineProvider.bridge.getLinkInfoAtPoint( - docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble() - ) - - if (linkInfo != null) { - Timber.tag("PdfLinkDiagnostic").i(">>> Native Link Info Extracted: $linkInfo") - if (linkInfo.startsWith("URI:")) { - val url = linkInfo.substringAfter("URI:") + if (nativeTap.linkInfo != null) { + Timber.tag("PdfLinkDiagnostic").i(">>> Native Link Info Extracted: ${nativeTap.linkInfo}") + if (nativeTap.linkInfo.startsWith("URI:")) { + val url = nativeTap.linkInfo.substringAfter("URI:") withContext(Dispatchers.Main) { onLinkClicked(url) } return@withContext 1 - } else if (linkInfo.startsWith("PAGE:")) { - val targetPage = linkInfo.substringAfter("PAGE:").toIntOrNull() + } else if (nativeTap.linkInfo.startsWith("PAGE:")) { + val targetPage = nativeTap.linkInfo.substringAfter("PAGE:").toIntOrNull() if (targetPage != null && targetPage >= 0) { withContext(Dispatchers.Main) { onInternalLinkClicked(targetPage) } return@withContext 1 @@ -2981,8 +2756,7 @@ internal fun PdfPageComposable( } } - val clickHandled = PdfiumEngineProvider.bridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()) - if (clickHandled) { + if (nativeTap.clickHandled) { return@withContext 2 } return@withContext 0 @@ -2994,6 +2768,9 @@ internal fun PdfPageComposable( } if (nativeResult == 2) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.nativeAction page=$pageIndex vertical=$isVerticalScroll" + ) Timber.tag("PdfInteraction").i("Action detected. Refreshing page.") tiles = emptyList() bitmapState = null @@ -3001,11 +2778,14 @@ internal fun PdfPageComposable( currentRenderedPageId = "ACTION_${System.currentTimeMillis()}" return@launch } else if (nativeResult == 1) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.nativeLink page=$pageIndex vertical=$isVerticalScroll" + ) return@launch } - val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale - val hitTolerance = with(density) { 16.dp.toPx() } / inputScale + val annotHitTolerance = with(density) { 24.dp.toPx() } / latestInputScale + val hitTolerance = with(density) { 16.dp.toPx() } / latestInputScale Timber.d("detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})") @@ -3044,6 +2824,9 @@ internal fun PdfPageComposable( } if (standardHit != null) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.annotationHit page=$pageIndex vertical=$isVerticalScroll" + ) val (annot, screenRect) = standardHit customMenuState = CustomPdfMenuState( selectedText = annot.contents ?: "No comment", @@ -3057,6 +2840,9 @@ internal fun PdfPageComposable( } if (hitHighlightPair != null && tappedRect != null) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.highlightHit page=$pageIndex vertical=$isVerticalScroll" + ) val hitHighlight = hitHighlightPair.first onNoteRequested(hitHighlight.id) return@launch @@ -3067,6 +2853,10 @@ internal fun PdfPageComposable( } if (clickedLink != null) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.fallbackLink page=$pageIndex vertical=$isVerticalScroll " + + "dest=${clickedLink.destPageIdx} url=${clickedLink.url != null}" + ) Timber.d("PdfPageComposable: Fallback pageLinks intercepted click.") if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) { onInternalLinkClicked(clickedLink.destPageIdx) @@ -3080,6 +2870,10 @@ internal fun PdfPageComposable( val wasSelectionVisible = selectionCharRange.value != null || ocrSelectionSymbolIndices != null if (wasMenuVisible || wasSelectionVisible) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.clearSelectionOrMenu page=$pageIndex vertical=$isVerticalScroll " + + "menu=$wasMenuVisible selection=$wasSelectionVisible" + ) customMenuState = null selectionCharRange.value = null ocrSelectionSymbolIndices = null @@ -3092,64 +2886,147 @@ internal fun PdfPageComposable( currentPageRotation, ) } else { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.onTap.singleTap page=$pageIndex vertical=$isVerticalScroll" + ) currentOnSingleTap(tapOffset) } } - }, onDoubleTap = { tapOffset -> + }, + onQuickDoubleTap = quickDoubleTap@{ tapOffset -> + if (!canZoomByDoubleTap()) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.quickDoubleTap.blocked page=$pageIndex vertical=$isVerticalScroll " + + "zoomEnabled=$isZoomEnabled scrollLocked=$isScrollLocked bitmapWidth=$actualBitmapWidthPx" + ) + return@quickDoubleTap + } + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.quickDoubleTap page=$pageIndex vertical=$isVerticalScroll offset=$tapOffset " + + "scale=$latestScale" + ) if (isZoomEnabled && !isVerticalScroll && !isScrollLocked) { - if (actualBitmapWidthPx == 0) return@detectTapGestures + if (actualBitmapWidthPx == 0) return@quickDoubleTap coroutineScope.launch { - val startScale = scale + val startScale = latestScale val targetScale = if (startScale > 1.1f) 1f else 2.5f - val startOffset = offset - val targetOffsetUnbounded = if (targetScale <= 1.1f) { + val startOffset = latestOffset + val viewportSize = Size(size.width.toFloat(), size.height.toFloat()) + val contentSize = Size( + actualBitmapWidthPx.toFloat(), + actualBitmapHeightPx.toFloat() + ) + val targetOffset = if (targetScale <= 1.1f) { Offset.Zero } else { - val ratio = targetScale / startScale - val screenCenter = Offset( - size.width / 2f, size.height / 2f + centeredPdfCameraOffsetForScaleChange( + previousScale = startScale, + nextScale = targetScale, + previousOffset = startOffset, + pivot = tapOffset, + viewportSize = viewportSize, + contentSize = contentSize ) - startOffset * ratio + (tapOffset - screenCenter) * (1 - ratio) } - val contentWidth = actualBitmapWidthPx * targetScale - val contentHeight = actualBitmapHeightPx * targetScale - val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f - val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f - - val targetOffset = Offset( - x = targetOffsetUnbounded.x.coerceIn( - -maxOffsetX, maxOffsetX - ), y = targetOffsetUnbounded.y.coerceIn( - -maxOffsetY, maxOffsetY - ) - ) - - Animatable(0f).animateTo( - 1f, animationSpec = tween( - durationMillis = 300 - ) - ) { - val progress = value - scale = androidx.compose.ui.util.lerp( - startScale, targetScale, progress - ) - offset = androidx.compose.ui.geometry.lerp( - startOffset, targetOffset, progress - ) - onScaleChanged(scale) - } - if (scale <= 1.05f) { - scale = 1f - offset = Offset.Zero - onScaleChanged(scale) + try { + isTransforming = true + Animatable(0f).animateTo( + 1f, animationSpec = tween( + durationMillis = 300 + ) + ) { + val progress = value + scale = androidx.compose.ui.util.lerp( + startScale, targetScale, progress + ) + offset = androidx.compose.ui.geometry.lerp( + startOffset, targetOffset, progress + ) + onScaleChanged(scale) + } + if (scale <= 1.05f) { + scale = 1f + offset = Offset.Zero + onScaleChanged(scale) + } + } finally { + isTransforming = false } } } else if (isVerticalScroll && !isScrollLocked && currentOnDoubleTap != null) { currentOnDoubleTap!!(tapOffset) } - }) + }, + onOneHandZoomHoldStart = { _ -> + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.oneHandHoldStart page=$pageIndex vertical=$isVerticalScroll " + + "scale=$latestScale offset=$latestOffset scrollLocked=$isScrollLocked" + ) + isOneHandZooming = true + if (isZoomEnabled && !isVerticalScroll && !isScrollLocked && actualBitmapWidthPx > 0) { + paginationPanFlingJob?.cancel() + paginationPanFlingJob = null + oneHandZoomStartScale = latestScale + oneHandZoomStartOffset = latestOffset + isPaginationPageGestureActive = true + } else if (isVerticalScroll && !isScrollLocked) { + currentOnDoubleTapDragZoomStart?.invoke(Offset(size.width / 2f, size.height / 2f)) + } + }, + onOneHandZoom = { _, totalDragY -> + if (isZoomEnabled && !isVerticalScroll && !isScrollLocked && actualBitmapWidthPx > 0) { + val pivot = Offset(size.width / 2f, size.height / 2f) + val newScale = pdfOneHandZoomScale( + startScale = oneHandZoomStartScale, + totalDragY = totalDragY, + dragDistanceForDoublePx = oneHandZoomDistancePx, + minScale = 1f, + maxScale = 4f + ) + val viewportSize = Size(size.width.toFloat(), size.height.toFloat()) + val contentSize = Size( + actualBitmapWidthPx.toFloat(), + actualBitmapHeightPx.toFloat() + ) + scale = newScale + offset = centeredPdfCameraOffsetForScaleChange( + previousScale = oneHandZoomStartScale, + nextScale = newScale, + previousOffset = oneHandZoomStartOffset, + pivot = pivot, + viewportSize = viewportSize, + contentSize = contentSize + ) + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v( + "page.oneHandUpdate page=$pageIndex dragY=$totalDragY scale=$newScale offset=$offset" + ) + onScaleChanged(scale) + } else if (isVerticalScroll && !isScrollLocked) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v( + "page.oneHandUpdate.verticalForward page=$pageIndex dragY=$totalDragY" + ) + currentOnDoubleTapDragZoom?.invoke(Offset(size.width / 2f, size.height / 2f), totalDragY) + } + }, + onOneHandZoomEnd = { _ -> + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.oneHandEnd page=$pageIndex vertical=$isVerticalScroll scale=$scale offset=$offset" + ) + if (isZoomEnabled && !isVerticalScroll && !isScrollLocked && actualBitmapWidthPx > 0) { + if (scale > 1f && scale < 1.05f) { + scale = 1f + offset = Offset.Zero + onScaleChanged(scale) + } + isPaginationPageGestureActive = false + } else if (isVerticalScroll && !isScrollLocked) { + currentOnDoubleTapDragZoomEnd?.invoke() + } + isOneHandZooming = false + } + ) } .pointerInput( actualBitmapWidthPx, @@ -3159,9 +3036,10 @@ internal fun PdfPageComposable( isVerticalScroll, isEditMode, onTwoFingerSwipe, - isScrollLocked + isScrollLocked, + isOneHandZooming ) { - if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null) return@pointerInput + if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null || isOneHandZooming) return@pointerInput val decay = splineBasedDecay(this) val velocityTracker = VelocityTracker() @@ -3170,6 +3048,12 @@ internal fun PdfPageComposable( awaitEachGesture { @Suppress("UnusedVariable", "Unused") val down = awaitFirstDown(requireUnconsumed = false) + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.panDetector.down page=$pageIndex consumed=${down.isConsumed} " + + "scale=$scale offset=$offset scrollLocked=$isScrollLocked" + ) + isPaginationPageGestureActive = true + try { paginationPanFlingJob?.cancel() paginationPanFlingJob = null velocityTracker.resetTracking() @@ -3185,6 +3069,15 @@ internal fun PdfPageComposable( val canceled = event.changes.any { it.isConsumed } val pointerCount = event.changes.size + if (canceled) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.panDetector.canceledByConsumed page=$pageIndex mode=$mode scale=$scale " + + "pointerCount=$pointerCount changes=${event.changes.joinToString { change -> + "pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}" + }}" + ) + } + if (!canceled) { val rawPanChange = event.calculatePan() val panChange = if (isScrollLocked && pointerCount == 1) { @@ -3200,6 +3093,9 @@ internal fun PdfPageComposable( accumulatedPan += panChange if (accumulatedPan.getDistance() > touchSlop) { mode = 1 + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.panDetector.modePanSingle page=$pageIndex accumulatedPan=$accumulatedPan scale=$scale" + ) Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Single Pointer)") } } else if (pointerCount > 1) { @@ -3211,9 +3107,15 @@ internal fun PdfPageComposable( if (zoomDiff > 0.05f) { mode = 2 + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.panDetector.modeZoomMulti page=$pageIndex zoomDiff=$zoomDiff panDist=$panDist scale=$scale" + ) Timber.tag("PdfZoomDebug").d("Mode Change: ZOOM (Multi Pointer)") } else if (panDist > touchSlop) { mode = 1 + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "page.panDetector.modePanMulti page=$pageIndex zoomDiff=$zoomDiff panDist=$panDist scale=$scale" + ) Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Multi Pointer)") } } @@ -3347,12 +3249,17 @@ internal fun PdfPageComposable( if (scale > 1f && scale < 1.05f) { coroutineScope.launch { - val startScale = scale - val startOffset = offset - Animatable(0f).animateTo(1f) { - scale = lerp(startScale, 1f, value) - offset = lerp(startOffset, Offset.Zero, value) - onScaleChanged(scale) + try { + isTransforming = true + val startScale = scale + val startOffset = offset + Animatable(0f).animateTo(1f) { + scale = lerp(startScale, 1f, value) + offset = lerp(startOffset, Offset.Zero, value) + onScaleChanged(scale) + } + } finally { + isTransforming = false } } } else if (mode == 1 && scale > 1f) { @@ -3408,6 +3315,9 @@ internal fun PdfPageComposable( } } } + } finally { + isPaginationPageGestureActive = false + } } } .pointerInput( @@ -3832,6 +3742,7 @@ internal fun PdfPageComposable( currentRenderedPageId ) { if (!isVisible && !isVerticalScroll) return@LaunchedEffect + val baseRenderEffectStartNanos = PdfVerticalPerfLog.nowNanos() pageErrorMessage = null val viewContainerWidthPx = with(density) { currentContainerMaxWidth.toPx().toInt() } @@ -3870,6 +3781,11 @@ internal fun PdfPageComposable( bitmapState != null && currentRenderedPageId == targetPageId ) { + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "base-render-skip page=$pageIndex reason=current virtual=true logical=${scaledWidth}x$scaledHeight" + ) + } isLoadingPage = false return@LaunchedEffect } @@ -3904,6 +3820,12 @@ internal fun PdfPageComposable( currentRenderedPageId = targetPageId isLoadingPage = false + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "base-render-finished page=$pageIndex virtual=true logical=${scaledWidth}x$scaledHeight bitmap=${baseW}x$baseH " + + "duration=${PdfVerticalPerfLog.elapsedMs(baseRenderEffectStartNanos)}ms" + ) + } return@LaunchedEffect } @@ -3949,6 +3871,11 @@ internal fun PdfPageComposable( bitmapState != null && currentRenderedPageId == targetPageId ) { + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "base-render-skip page=$pageIndex pdfPage=$pdfPageIndex reason=current logical=${scaledWidth}x$scaledHeight" + ) + } return@withContext null } @@ -3968,14 +3895,27 @@ internal fun PdfPageComposable( Timber.d( "Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})" ) + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "base-render-start page=$pageIndex pdfPage=$pdfPageIndex logical=${scaledWidth}x$scaledHeight " + + "bitmap=${baseW}x$baseH rotation=$rotation externalScale=${PdfVerticalPerfLog.f(externalScale)}" + ) + } val newBitmap = createBitmap(baseW, baseH) localBitmap = newBitmap + val nativeRenderStartNanos = PdfVerticalPerfLog.nowNanos() page.renderPageBitmap( newBitmap, 0, 0, baseW, baseH, true ) + val nativeRenderMs = PdfVerticalPerfLog.elapsedMs(nativeRenderStartNanos) + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "base-render-native page=$pageIndex pdfPage=$pdfPageIndex duration=${nativeRenderMs}ms bitmap=${baseW}x$baseH" + ) + } Triple(newBitmap, rotation, Pair(scaledWidth, scaledHeight)) } finally { @@ -4012,9 +3952,20 @@ internal fun PdfPageComposable( ) } } + if (isVerticalScroll) { + PdfVerticalPerfLog.d( + "base-render-finished page=$pageIndex pdfPage=$pdfPageIndex logical=${dims.first}x${dims.second} " + + "bitmap=${newBitmap.width}x${newBitmap.height} rotation=$rotation duration=${PdfVerticalPerfLog.elapsedMs(baseRenderEffectStartNanos)}ms" + ) + } } } catch (e: Exception) { if (e is CancellationException) throw e + if (isVerticalScroll) { + PdfVerticalPerfLog.w( + "base-render-error page=$pageIndex pdfPage=$pdfPageIndex error=${e.readablePdfErrorDetail()}" + ) + } pageErrorMessage = context.getString( R.string.error_processing_page, e.readablePdfErrorDetail() @@ -4089,10 +4040,21 @@ internal fun PdfPageComposable( val stableTiles = remember(tiles) { StableHolder(tiles) } val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) } val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) } + val shouldDrawHighResTiles = !shouldPauseHighResTileRendering + LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) { + if (stableTiles.item.isNotEmpty() && effectiveScale > 1f) { + PdfVerticalPerfLog.d( + "tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " + + "visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " + + "scale=${PdfVerticalPerfLog.f(effectiveScale)}" + ) + } + } val staticData = remember( stableBitmapState, stableTiles, + shouldDrawHighResTiles, effectiveScale, centeringOffsetX, centeringOffsetY, @@ -4114,6 +4076,7 @@ internal fun PdfPageComposable( PageStaticData( bitmap = stableBitmapState, tiles = stableTiles, + shouldDrawHighResTiles = shouldDrawHighResTiles, effectiveScale = effectiveScale, centeringOffsetX = centeringOffsetX, centeringOffsetY = centeringOffsetY, @@ -4483,6 +4446,7 @@ private fun OcrProcessingIndicator(position: Offset) { private fun PdfBitmapLayer( bitmapState: Bitmap?, tiles: List, + shouldDrawHighResTiles: Boolean, effectiveScale: Float, centeringOffsetX: Float, centeringOffsetY: Float, @@ -4550,7 +4514,7 @@ private fun PdfBitmapLayer( } val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 - if (needsTiling) { + if (needsTiling && shouldDrawHighResTiles) { tiles.forEach { tile -> if ( tile.bitmap.isCanvasSafeBitmap( @@ -4800,212 +4764,6 @@ private fun PdfHighlightsLayer( } } -internal object PdfTextureGenerator { - private var noiseBitmap: Bitmap? = null - - fun getNoiseTexture(): Bitmap { - if (noiseBitmap == null) { - val size = 256 - val bitmap = createBitmap(size, size, Bitmap.Config.ARGB_8888) - for (x in 0 until size) { - for (y in 0 until size) { - val isGrain = Math.random() > 0.4 - if (isGrain) { - val alpha = (Math.random() * 100 + 100).toInt() - bitmap[x, y] = AndroidColor.argb(alpha, 0, 0, 0) - } else { - bitmap[x, y] = AndroidColor.TRANSPARENT - } - } - } - noiseBitmap = bitmap - } - return noiseBitmap!! - } -} - -internal sealed interface AnnotationRenderData { - data class Standard( - val path: Path, - val color: Color, - val strokeWidth: Float, - val cap: StrokeCap, - val blendMode: BlendMode - ) : AnnotationRenderData - - data class Fountain(val path: Path, val color: Color) : AnnotationRenderData - - data class Pencil( - val path: android.graphics.Path, - val color: Color, - val strokeWidth: Float, - val velocityAlpha: Float - ) : AnnotationRenderData -} - -internal object PdfAnnotationRenderHelper { - fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? { - val startTime = System.nanoTime() - if (annot.points.isEmpty()) return null - - if (annot.points.size == 1) { - val p = annot.points[0] - val x = p.x * widthPx - val y = p.y * heightPx - - val path = if (annot.inkType == InkType.PENCIL) android.graphics.Path() else Path() - - if (path is android.graphics.Path) { - path.moveTo(x, y) - path.lineTo(x, y) - return AnnotationRenderData.Pencil( - path = path, - color = annot.color, - strokeWidth = annot.strokeWidth * widthPx, - velocityAlpha = 1.0f - ) - } else if (path is Path) { - if (annot.inkType == InkType.FOUNTAIN_PEN) { - val radius = (annot.strokeWidth * widthPx) / 2f - path.addOval( - androidx.compose.ui.geometry.Rect( - center = Offset(x, y), radius = radius - ) - ) - return AnnotationRenderData.Fountain(path = path, color = annot.color) - } - - path.moveTo(x, y) - path.lineTo(x, y) - - val cap = when (annot.inkType) { - InkType.HIGHLIGHTER -> StrokeCap.Butt - InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round - else -> StrokeCap.Round - } - - val blendMode = BlendMode.SrcOver - - return AnnotationRenderData.Standard( - path = path, - color = annot.color, - strokeWidth = annot.strokeWidth * widthPx, - cap = cap, - blendMode = blendMode - ) - } - } - - val result = when (annot.inkType) { - InkType.PENCIL -> { - val path = android.graphics.Path() - val first = annot.points[0] - path.moveTo(first.x * widthPx, first.y * heightPx) - var totalDist = 0f - for (i in 1 until annot.points.size) { - val p0 = annot.points[i - 1] - val p1 = annot.points[i] - val p0x = p0.x * widthPx - val p0y = p0.y * heightPx - val p1x = p1.x * widthPx - val p1y = p1.y * heightPx - val midX = (p0x + p1x) / 2f - val midY = (p0y + p1y) / 2f - val dx = p1x - p0x - val dy = p1y - p0y - totalDist += sqrt(dx * dx + dy * dy) - - if (i == 1) path.lineTo(midX, midY) - else path.quadTo(p0x, p0y, midX, midY) - } - val last = annot.points.last() - path.lineTo(last.x * widthPx, last.y * heightPx) - - val duration = - (annot.points.last().timestamp - annot.points.first().timestamp).coerceAtLeast(1) - val velocity = totalDist / duration - val velocityAlphaFactor = (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1.0f) - - AnnotationRenderData.Pencil( - path = path, - color = annot.color, - strokeWidth = annot.strokeWidth * widthPx, - velocityAlpha = velocityAlphaFactor - ) - } - - InkType.FOUNTAIN_PEN -> { - val baseStrokeWidth = annot.strokeWidth * widthPx - val path = Path() - - val (leftSide, rightSide) = PdfInkGeometry.calculateFountainPenPoints( - annot.points, baseStrokeWidth, widthPx.toFloat(), heightPx.toFloat() - ) - - if (leftSide.isNotEmpty()) { - path.moveTo(leftSide[0].x, leftSide[0].y) - - for (i in 1 until leftSide.size) { - path.lineTo(leftSide[i].x, leftSide[i].y) - } - - for (i in rightSide.size - 1 downTo 0) { - path.lineTo(rightSide[i].x, rightSide[i].y) - } - - path.close() - } - - AnnotationRenderData.Fountain(path = path, color = annot.color) - } - - else -> { - val path = Path() - val first = annot.points[0] - path.moveTo(first.x * widthPx, first.y * heightPx) - for (i in 1 until annot.points.size) { - val p0 = annot.points[i - 1] - val p1 = annot.points[i] - val p0x = p0.x * widthPx - val p0y = p0.y * heightPx - val p1x = p1.x * widthPx - val p1y = p1.y * heightPx - val midX = (p0x + p1x) / 2f - val midY = (p0y + p1y) / 2f - if (i == 1) path.lineTo(midX, midY) - else path.quadraticTo(p0x, p0y, midX, midY) - } - val last = annot.points.last() - path.lineTo(last.x * widthPx, last.y * heightPx) - - val blendMode = when (annot.inkType) { - InkType.HIGHLIGHTER, InkType.HIGHLIGHTER_ROUND -> BlendMode.Multiply - else -> BlendMode.SrcOver - } - - val cap = when (annot.inkType) { - InkType.HIGHLIGHTER -> StrokeCap.Butt - InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round - else -> StrokeCap.Round - } - - AnnotationRenderData.Standard( - path = path, - color = annot.color, - strokeWidth = annot.strokeWidth * widthPx, - cap = cap, - blendMode = blendMode - ) - } - } - val duration = (System.nanoTime() - startTime) / 1_000_000f - if (duration > 1f) { - Timber.tag("PdfPerf").v("Path Gen: Type=${annot.inkType}, Pts=${annot.points.size}, Time=${duration}ms") - } - return result - } -} - @Suppress("SameParameterValue") @Composable private fun PdfAnnotationLayer( @@ -5127,6 +4885,7 @@ private fun PdfPageStaticLayer(data: PageStaticData) { PdfBitmapLayer( bitmapState = data.bitmap.item, tiles = data.tiles.item, + shouldDrawHighResTiles = data.shouldDrawHighResTiles, effectiveScale = data.effectiveScale, centeringOffsetX = data.centeringOffsetX, centeringOffsetY = data.centeringOffsetY, @@ -5876,53 +5635,6 @@ private fun PdfPageRenderer( } } -@Stable -class PdfDrawingState { - var currentAnnotation by mutableStateOf(null) - private set - private val currentPoints = mutableListOf() - - fun onDrawStart(pageIndex: Int, point: PdfPoint, type: InkType, color: Color, width: Float) { - currentPoints.clear() - currentPoints.add(point) - currentAnnotation = PdfAnnotation( - type = AnnotationType.INK, - inkType = type, - pageIndex = pageIndex, - points = currentPoints.toList(), - color = color, - strokeWidth = width - ) - } - - fun onDraw(point: PdfPoint) { - currentPoints.add(point) - currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList()) - } - - fun onDrawCancel() { - currentAnnotation = null - currentPoints.clear() - } - - fun onDrawEnd(): PdfAnnotation? { - val finalAnnot = currentAnnotation - currentAnnotation = null - currentPoints.clear() - return finalAnnot - } - - fun updateDrag(point: PdfPoint) { - if (currentPoints.isNotEmpty()) { - val start = currentPoints.first() - currentPoints.clear() - currentPoints.add(start) - currentPoints.add(point) - currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList()) - } - } -} - @Composable fun PdfRichTextLayer( pageIndex: Int, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt new file mode 100644 index 0000000..fd22157 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt @@ -0,0 +1,22 @@ +package com.aryan.reader.pdf + +import com.aryan.reader.pdf.data.VirtualPage + +internal const val PDF_BLANK_PAGE_PERSISTENCE_TAG = "PdfBlankPagePersist" + +internal fun List.pdfLayoutDebugSummary(maxPages: Int = 16): String { + val blankPages = filterIsInstance() + val sample = take(maxPages).mapIndexed { displayIndex, page -> + when (page) { + is VirtualPage.PdfPage -> "$displayIndex:P${page.pdfIndex}" + is VirtualPage.BlankPage -> + "$displayIndex:B(${page.id.take(8)},${page.width}x${page.height},manual=${page.wasManuallyAdded})" + } + }.let { pages -> + if (size > maxPages) pages + "...(+${size - maxPages})" else pages + } + + return "size=$size pdf=${count { it is VirtualPage.PdfPage }} " + + "blank=${blankPages.size} manualBlank=${blankPages.count { it.wasManuallyAdded }} " + + "pages=${sample.joinToString(prefix = "[", postfix = "]")}" +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt new file mode 100644 index 0000000..72e0f04 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt @@ -0,0 +1,25 @@ +package com.aryan.reader.pdf + +import android.graphics.Rect +import com.aryan.reader.pdf.data.VirtualPage + +enum class LinkSource { + ANNOTATION, TEXT_CONTENT +} + +data class PageLink( + val highlightBounds: Rect, + val tapBounds: Rect, + val url: String?, + val destPageIdx: Int?, + val source: LinkSource +) + +internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String { + val sourcePageId = when (virtualPage) { + is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}" + is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}" + null -> "PDF_$pageIndex" + } + return "$documentKey:$sourcePageId" +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt new file mode 100644 index 0000000..4b406db --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt @@ -0,0 +1,422 @@ +package com.aryan.reader.pdf + +import android.graphics.Bitmap +import android.graphics.Rect +import android.util.LruCache +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import com.aryan.reader.pdf.data.PdfAnnotation +import timber.log.Timber +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import android.graphics.Color as AndroidColor + +data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f) + +object PdfInkGeometry { + fun calculateFountainPenPoints( + points: List, baseWidth: Float, pageWidth: Float, pageHeight: Float + ): Pair, List> { + if (points.size < 2) return Pair(emptyList(), emptyList()) + + if (points.size % 50 == 0) { + Timber.tag("FountainPenDebug").d( + "Calculate Points: PWidth=$pageWidth, PHeight=$pageHeight, BaseW=$baseWidth, Pts=${points.size}" + ) + } + + val leftSide = mutableListOf() + val rightSide = mutableListOf() + + val computedWidths = FloatArray(points.size) + computedWidths[0] = baseWidth + + val velocityFactor = 300f + + for (i in 1 until points.size) { + val p1 = points[i - 1] + val p2 = points[i] + + val dxNorm = p2.x - p1.x + val dyNorm = p2.y - p1.y + val aspect = if (pageWidth > 0 && pageHeight > 0) pageHeight / pageWidth else 1f + val distNorm = sqrt(dxNorm * dxNorm + (dyNorm * aspect) * (dyNorm * aspect)) + + val timeDelta = (p2.timestamp - p1.timestamp).coerceAtLeast(1) + val velocityNorm = distNorm / timeDelta + + val targetWidth = (baseWidth * (1f / (1f + velocityNorm * velocityFactor))).coerceIn( + baseWidth * 0.2f, baseWidth * 1.4f + ) + + computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f + + if (i < 5) { + Timber.tag("FountainPenDebug").v( + "Pt[$i]: dt=$timeDelta, velNorm=$velocityNorm, width=${computedWidths[i]} (base=$baseWidth)" + ) + } + } + + for (i in 0 until points.size - 1) { + val pCurrent = points[i] + val pNext = points[i + 1] + + val curX = pCurrent.x * pageWidth + val curY = pCurrent.y * pageHeight + val nextX = pNext.x * pageWidth + val nextY = pNext.y * pageHeight + + val angle = atan2(nextY - curY, nextX - curX) + val normalAngle = angle - (PI / 2f).toFloat() + + val w = computedWidths[i] / 2f + + leftSide.add(Offset((curX + cos(normalAngle) * w), (curY + sin(normalAngle) * w))) + rightSide.add(Offset((curX - cos(normalAngle) * w), (curY - sin(normalAngle) * w))) + } + + val lastIdx = points.lastIndex + val lastP = points[lastIdx] + val prevP = points[lastIdx - 1] + + val lastX = lastP.x * pageWidth + val lastY = lastP.y * pageHeight + val prevX = prevP.x * pageWidth + val prevY = prevP.y * pageHeight + + val lastAngle = atan2(lastY - prevY, lastX - prevX) + val lastNormal = lastAngle - (PI / 2f).toFloat() + val lastW = computedWidths[lastIdx] / 2f + + leftSide.add(Offset((lastX + cos(lastNormal) * lastW), (lastY + sin(lastNormal) * lastW))) + rightSide.add(Offset((lastX - cos(lastNormal) * lastW), (lastY - sin(lastNormal) * lastW))) + + return Pair(leftSide, rightSide) + } +} + +internal object PdfBitmapPool { + private val pool = ConcurrentLinkedQueue() + private const val MAX_POOL_SIZE = 4 + + fun get(width: Int, height: Int): Bitmap { + val iterator = pool.iterator() + while (iterator.hasNext()) { + val bitmap = iterator.next() + if (bitmap.width == width && bitmap.height == height && !bitmap.isRecycled) { + iterator.remove() + bitmap.eraseColor(AndroidColor.TRANSPARENT) + return bitmap + } + } + return createBitmap(width, height) + } + + fun get(size: Int): Bitmap = get(size, size) + + fun recycle(bitmap: Bitmap) { + // Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps. + if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) { + pool.offer(bitmap) + } + } + + fun clear() { + while (!pool.isEmpty()) { + pool.poll() + } + } +} + +internal object PdfThumbnailCache { + private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() + private val cacheSize = maxMemory / 8 + + private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int) + + private val memoryCache = object : LruCache(cacheSize) { + override fun sizeOf(key: String, entry: CacheEntry): Int { + return entry.sizeKb + } + } + + fun get(pageId: String): Bitmap? { + return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled } + } + + fun put(pageId: String, bitmap: Bitmap) { + if (get(pageId) == null) { + val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1) + memoryCache.put(pageId, CacheEntry(bitmap, sizeKb)) + } + } + + fun clear() { + memoryCache.evictAll() + } +} + +internal object PdfTextureGenerator { + private var noiseBitmap: Bitmap? = null + + fun getNoiseTexture(): Bitmap { + if (noiseBitmap == null) { + val size = 256 + val bitmap = createBitmap(size, size, Bitmap.Config.ARGB_8888) + for (x in 0 until size) { + for (y in 0 until size) { + val isGrain = Math.random() > 0.4 + if (isGrain) { + val alpha = (Math.random() * 100 + 100).toInt() + bitmap[x, y] = AndroidColor.argb(alpha, 0, 0, 0) + } else { + bitmap[x, y] = AndroidColor.TRANSPARENT + } + } + } + noiseBitmap = bitmap + } + return noiseBitmap!! + } +} + +internal sealed interface AnnotationRenderData { + data class Standard( + val path: Path, + val color: Color, + val strokeWidth: Float, + val cap: StrokeCap, + val blendMode: BlendMode + ) : AnnotationRenderData + + data class Fountain(val path: Path, val color: Color) : AnnotationRenderData + + data class Pencil( + val path: android.graphics.Path, + val color: Color, + val strokeWidth: Float, + val velocityAlpha: Float + ) : AnnotationRenderData +} + +internal object PdfAnnotationRenderHelper { + fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? { + val startTime = System.nanoTime() + if (annot.points.isEmpty()) return null + + if (annot.points.size == 1) { + val point = annot.points[0] + val x = point.x * widthPx + val y = point.y * heightPx + + val path = if (annot.inkType == InkType.PENCIL) android.graphics.Path() else Path() + + if (path is android.graphics.Path) { + path.moveTo(x, y) + path.lineTo(x, y) + return AnnotationRenderData.Pencil( + path = path, + color = annot.color, + strokeWidth = annot.strokeWidth * widthPx, + velocityAlpha = 1.0f + ) + } else if (path is Path) { + if (annot.inkType == InkType.FOUNTAIN_PEN) { + val radius = (annot.strokeWidth * widthPx) / 2f + path.addOval( + androidx.compose.ui.geometry.Rect( + center = Offset(x, y), radius = radius + ) + ) + return AnnotationRenderData.Fountain(path = path, color = annot.color) + } + + path.moveTo(x, y) + path.lineTo(x, y) + + val cap = when (annot.inkType) { + InkType.HIGHLIGHTER -> StrokeCap.Butt + InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round + else -> StrokeCap.Round + } + + return AnnotationRenderData.Standard( + path = path, + color = annot.color, + strokeWidth = annot.strokeWidth * widthPx, + cap = cap, + blendMode = BlendMode.SrcOver + ) + } + } + + val result = when (annot.inkType) { + InkType.PENCIL -> { + val path = android.graphics.Path() + val first = annot.points[0] + path.moveTo(first.x * widthPx, first.y * heightPx) + var totalDist = 0f + for (i in 1 until annot.points.size) { + val p0 = annot.points[i - 1] + val p1 = annot.points[i] + val p0x = p0.x * widthPx + val p0y = p0.y * heightPx + val p1x = p1.x * widthPx + val p1y = p1.y * heightPx + val midX = (p0x + p1x) / 2f + val midY = (p0y + p1y) / 2f + val dx = p1x - p0x + val dy = p1y - p0y + totalDist += sqrt(dx * dx + dy * dy) + + if (i == 1) path.lineTo(midX, midY) + else path.quadTo(p0x, p0y, midX, midY) + } + val last = annot.points.last() + path.lineTo(last.x * widthPx, last.y * heightPx) + + val duration = + (annot.points.last().timestamp - annot.points.first().timestamp).coerceAtLeast(1) + val velocity = totalDist / duration + val velocityAlphaFactor = (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1.0f) + + AnnotationRenderData.Pencil( + path = path, + color = annot.color, + strokeWidth = annot.strokeWidth * widthPx, + velocityAlpha = velocityAlphaFactor + ) + } + + InkType.FOUNTAIN_PEN -> { + val baseStrokeWidth = annot.strokeWidth * widthPx + val path = Path() + + val (leftSide, rightSide) = PdfInkGeometry.calculateFountainPenPoints( + annot.points, baseStrokeWidth, widthPx.toFloat(), heightPx.toFloat() + ) + + if (leftSide.isNotEmpty()) { + path.moveTo(leftSide[0].x, leftSide[0].y) + + for (i in 1 until leftSide.size) { + path.lineTo(leftSide[i].x, leftSide[i].y) + } + + for (i in rightSide.size - 1 downTo 0) { + path.lineTo(rightSide[i].x, rightSide[i].y) + } + + path.close() + } + + AnnotationRenderData.Fountain(path = path, color = annot.color) + } + + else -> { + val path = Path() + val first = annot.points[0] + path.moveTo(first.x * widthPx, first.y * heightPx) + for (i in 1 until annot.points.size) { + val p0 = annot.points[i - 1] + val p1 = annot.points[i] + val p0x = p0.x * widthPx + val p0y = p0.y * heightPx + val p1x = p1.x * widthPx + val p1y = p1.y * heightPx + val midX = (p0x + p1x) / 2f + val midY = (p0y + p1y) / 2f + if (i == 1) path.lineTo(midX, midY) + else path.quadraticTo(p0x, p0y, midX, midY) + } + val last = annot.points.last() + path.lineTo(last.x * widthPx, last.y * heightPx) + + val blendMode = when (annot.inkType) { + InkType.HIGHLIGHTER, InkType.HIGHLIGHTER_ROUND -> BlendMode.Multiply + else -> BlendMode.SrcOver + } + + val cap = when (annot.inkType) { + InkType.HIGHLIGHTER -> StrokeCap.Butt + InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round + else -> StrokeCap.Round + } + + AnnotationRenderData.Standard( + path = path, + color = annot.color, + strokeWidth = annot.strokeWidth * widthPx, + cap = cap, + blendMode = blendMode + ) + } + } + val duration = (System.nanoTime() - startTime) / 1_000_000f + if (duration > 1f) { + Timber.tag("PdfPerf").v("Path Gen: Type=${annot.inkType}, Pts=${annot.points.size}, Time=${duration}ms") + } + return result + } +} + +@Stable +class PdfDrawingState { + var currentAnnotation by mutableStateOf(null) + private set + private val currentPoints = mutableListOf() + + fun onDrawStart(pageIndex: Int, point: PdfPoint, type: InkType, color: Color, width: Float) { + currentPoints.clear() + currentPoints.add(point) + currentAnnotation = PdfAnnotation( + type = AnnotationType.INK, + inkType = type, + pageIndex = pageIndex, + points = currentPoints.toList(), + color = color, + strokeWidth = width + ) + } + + fun onDraw(point: PdfPoint) { + currentPoints.add(point) + currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList()) + } + + fun onDrawCancel() { + currentAnnotation = null + currentPoints.clear() + } + + fun onDrawEnd(): PdfAnnotation? { + val finalAnnot = currentAnnotation + currentAnnotation = null + currentPoints.clear() + return finalAnnot + } + + fun updateDrag(point: PdfPoint) { + if (currentPoints.isNotEmpty()) { + val start = currentPoints.first() + currentPoints.clear() + currentPoints.add(start) + currentPoints.add(point) + currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList()) + } + } +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt index b1094fd..30952c6 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt @@ -8,9 +8,9 @@ import androidx.compose.ui.graphics.toArgb import androidx.core.content.edit import com.aryan.reader.BuildConfig import com.aryan.reader.R -import com.aryan.reader.ReaderTheme -import com.aryan.reader.ReaderTexture import com.aryan.reader.epubreader.SystemUiMode +import com.aryan.reader.shared.BuiltInPdfReaderThemes +import com.aryan.reader.shared.reader.ReaderPageSpreadMode internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll" internal const val SETTINGS_PREFS_NAME = "epub_reader_settings" @@ -33,7 +33,6 @@ private const val PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX = "pdf_as_local_speed_" private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_" private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_" private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_" -internal const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_" private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled" private const val PREF_USE_ONLINE_DICT = "use_online_dictionary" private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package" @@ -47,17 +46,21 @@ internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools" internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode" internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible" internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible" +internal const val PDF_TOP_TAB_STRIP_VISIBLE_KEY = "pdf_top_tab_strip_visible" +internal const val PDF_PAGE_SPREAD_MODE_KEY = "pdf_page_spread_mode" +internal const val PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY = "pdf_first_page_standalone_in_spread" internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug" private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version" -private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2 +private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 3 enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) { DICTIONARY(R.string.tool_external_apps, "Top Bar"), THEME(R.string.tooltip_theme_desc, "Top Bar"), + BRIGHTNESS(R.string.tool_brightness, "Top Bar"), LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"), + FILE_INFO(R.string.file_information, "Overflow Menu"), VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"), TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"), - FULL_SCREEN(R.string.tooltip_fullscreen, "Top Bar"), SLIDER(R.string.tool_navigation_slider, "Bottom Bar"), TOC(R.string.tool_sidebar, "Bottom Bar"), SEARCH(R.string.action_search, "Bottom Bar"), @@ -83,38 +86,41 @@ enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) { internal fun defaultPdfHiddenTools(): Set { return setOf( PdfReaderTool.SCREEN_ORIENTATION.name, - PdfReaderTool.HIGHLIGHT_ALL.name + PdfReaderTool.HIGHLIGHT_ALL.name, + PdfReaderTool.BRIGHTNESS.name ) } -internal fun defaultPdfToolOrder(): List = PdfReaderTool.entries.toList() - -internal fun defaultPdfBottomTools(): Set { - return PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet() +internal fun isPdfReaderToolAvailable(tool: PdfReaderTool): Boolean { + return BuildConfig.IS_PRO || tool != PdfReaderTool.OCR_LANGUAGE } -val PdfBuiltInThemes = listOf( - ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), - ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true), - ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), - 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("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 defaultPdfToolOrder(): List = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable) + +internal fun defaultPdfBottomTools(): Set { + return defaultPdfToolOrder().filter { it.category == "Bottom Bar" }.map { it.name }.toSet() +} + +val PdfBuiltInThemes = BuiltInPdfReaderThemes + +private fun sanitizePdfToolNameSet( + toolNames: Set, + includeTool: (PdfReaderTool) -> Boolean = { true } +): Set { + return toolNames.mapNotNull { toolName -> + PdfReaderTool.entries + .firstOrNull { it.name == toolName } + ?.takeIf { isPdfReaderToolAvailable(it) && includeTool(it) } + ?.name + }.toSet() +} internal fun loadPdfHiddenTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty() + val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()) val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) { - val migratedHiddenTools = savedHiddenTools + defaultPdfHiddenTools() + val migratedHiddenTools = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion)) prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) @@ -124,10 +130,20 @@ internal fun loadPdfHiddenTools(context: Context): Set { return savedHiddenTools } +private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set { + return buildSet { + if (defaultsVersion < 2) { + add(PdfReaderTool.SCREEN_ORIENTATION.name) + add(PdfReaderTool.HIGHLIGHT_ALL.name) + } + if (defaultsVersion < 3) add(PdfReaderTool.BRIGHTNESS.name) + } +} + internal fun savePdfHiddenTools(context: Context, hiddenTools: Set) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { - putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) + putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools)) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) } } @@ -138,24 +154,41 @@ internal fun loadPdfToolOrder(context: Context): List { ?.split(',') ?.filter { it.isNotBlank() } ?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } } + ?.filter(::isPdfReaderToolAvailable) .orEmpty() return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct() } internal fun savePdfToolOrder(context: Context, toolOrder: List) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) } + prefs.edit { + putString( + PDF_TOOL_ORDER_KEY, + toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name } + ) + } } internal fun loadPdfBottomTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val defaultBottomTools = defaultPdfBottomTools() - return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools + return sanitizePdfToolNameSet( + toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools, + includeTool = { it.category == "Bottom Bar" } + ) } internal fun savePdfBottomTools(context: Context, bottomTools: Set) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) } + prefs.edit { + putStringSet( + PDF_BOTTOM_TOOLS_KEY, + sanitizePdfToolNameSet( + toolNames = bottomTools, + includeTool = { it.category == "Bottom Bar" } + ) + ) + } } internal fun loadCustomHighlightColors(context: Context): Map { @@ -217,6 +250,38 @@ internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean { return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true) } +internal fun savePdfPageSpreadMode(context: Context, mode: ReaderPageSpreadMode) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putString(PDF_PAGE_SPREAD_MODE_KEY, mode.name) } +} + +internal fun loadPdfPageSpreadMode(context: Context): ReaderPageSpreadMode { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + val modeName = prefs.getString(PDF_PAGE_SPREAD_MODE_KEY, ReaderPageSpreadMode.SINGLE.name) + return runCatching { ReaderPageSpreadMode.valueOf(modeName ?: ReaderPageSpreadMode.SINGLE.name) } + .getOrDefault(ReaderPageSpreadMode.SINGLE) +} + +internal fun savePdfFirstPageStandaloneInSpread(context: Context, isEnabled: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, isEnabled) } +} + +internal fun loadPdfFirstPageStandaloneInSpread(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, false) +} + +internal fun savePdfTopTabStripVisible(context: Context, isVisible: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, isVisible) } +} + +internal fun loadPdfTopTabStripVisible(context: Context): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, true) +} + internal fun savePdfThemeId(context: Context, themeId: String) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putString(PDF_THEME_KEY, themeId) } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt index 490c57b..e0fa480 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt @@ -33,6 +33,7 @@ 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.Info import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert @@ -72,6 +73,8 @@ import androidx.compose.ui.window.DialogProperties import com.aryan.reader.R import com.aryan.reader.epubreader.OptionSegmentedControl import com.aryan.reader.epubreader.SystemUiMode +import com.aryan.reader.epubreader.titleRes +import com.aryan.reader.shared.reader.ReaderPageSpreadMode enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL } @@ -114,7 +117,7 @@ fun sanitizePdfPlaceholders(list: List): List } private val pdfReorderableToolbarTools = setOf( - PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING, + PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING, PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS, @@ -126,11 +129,12 @@ internal fun buildPdfToolbarItems( toolOrder: List, bottomTools: Set ): List { - val toolbarTools = toolOrder.filter { it in pdfReorderableToolbarTools } + val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable) + val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools } 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 pdfReorderableToolbarTools } + val moreTools = availableToolOrder.filter { it !in pdfReorderableToolbarTools } val list = mutableListOf() @@ -541,7 +545,9 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) { when (tool) { PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp)) @@ -556,9 +562,14 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) { @Composable fun PdfVisualOptionsSheet( + displayMode: DisplayMode, systemUiMode: SystemUiMode, + pageSpreadMode: ReaderPageSpreadMode, + firstPageStandaloneInSpread: Boolean, showVerticalPageGap: Boolean, showPageNumberOverlay: Boolean, + onPageSpreadModeChange: (ReaderPageSpreadMode) -> Unit, + onFirstPageStandaloneInSpreadChange: (Boolean) -> Unit, onSystemUiModeChange: (SystemUiMode) -> Unit, onShowVerticalPageGapChange: (Boolean) -> Unit, onShowPageNumberOverlayChange: (Boolean) -> Unit, @@ -606,6 +617,35 @@ fun PdfVisualOptionsSheet( Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(4.dp)) + if (displayMode == DisplayMode.PAGINATION) { + Text( + stringResource(R.string.visual_options_pdf_page_spread), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + OptionSegmentedControl( + options = ReaderPageSpreadMode.entries, + selectedOption = pageSpreadMode, + onOptionSelected = onPageSpreadModeChange, + getLabel = { + when (it) { + ReaderPageSpreadMode.SINGLE -> stringResource(R.string.visual_options_pdf_spread_single) + ReaderPageSpreadMode.TWO_PAGE -> stringResource(R.string.visual_options_pdf_spread_two) + } + } + ) + if (pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE) { + Spacer(modifier = Modifier.height(8.dp)) + PdfVisualOptionSwitchRow( + title = stringResource(R.string.visual_options_pdf_first_page_alone), + description = stringResource(R.string.visual_options_pdf_first_page_alone_desc), + checked = firstPageStandaloneInSpread, + onCheckedChange = onFirstPageStandaloneInSpreadChange + ) + } + Spacer(modifier = Modifier.height(12.dp)) + } PdfVisualOptionSwitchRow( title = stringResource(R.string.visual_options_remove_page_gap), description = stringResource(R.string.visual_options_remove_page_gap_desc), diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt b/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt index a629661..4686c32 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,6 +56,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput @@ -85,6 +87,63 @@ enum class HandlePosition { TOP, BOTTOM, AUTO } +private const val TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP = 48f +private const val TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP = 24f +private const val TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP = 72f +private const val TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP = 48f +private const val TEXT_BOX_DRAG_PILL_GAP_DP = 8f + +internal data class TextBoxChromeLayout( + val containerWidthPx: Float, + val containerHeightPx: Float, + val contentWidthPx: Float, + val contentHeightPx: Float, + val contentOffsetX: Float, + val contentOffsetY: Float, + val outerTranslationX: Float, + val outerTranslationY: Float, + val dragPillLeftPx: Float, + val dragPillTopPx: Float +) + +internal fun calculateTextBoxChromeLayout( + textBoundsPx: Rect, + isSelected: Boolean, + isHandleAtTop: Boolean, + handleSizePx: Float, + dragPillWidthPx: Float, + dragPillHeightPx: Float, + dragPillGapPx: Float +): TextBoxChromeLayout { + val halfHandlePx = handleSizePx / 2f + val contentWidthPx = textBoundsPx.width + handleSizePx + val contentHeightPx = textBoundsPx.height + handleSizePx + val dragPillTrackHeightPx = if (isSelected) dragPillHeightPx + dragPillGapPx else 0f + val containerWidthPx = maxOf(contentWidthPx, if (isSelected) dragPillWidthPx else contentWidthPx) + val containerHeightPx = contentHeightPx + dragPillTrackHeightPx + val contentOffsetX = (containerWidthPx - contentWidthPx) / 2f + val contentOffsetY = if (isSelected && isHandleAtTop) dragPillTrackHeightPx else 0f + val dragPillLeftPx = (containerWidthPx - dragPillWidthPx) / 2f + val dragPillTopPx = if (isSelected && isHandleAtTop) { + 0f + } else { + containerHeightPx - dragPillHeightPx + } + + return TextBoxChromeLayout( + containerWidthPx = containerWidthPx, + containerHeightPx = containerHeightPx, + contentWidthPx = contentWidthPx, + contentHeightPx = contentHeightPx, + contentOffsetX = contentOffsetX, + contentOffsetY = contentOffsetY, + outerTranslationX = textBoundsPx.left - halfHandlePx - contentOffsetX, + outerTranslationY = textBoundsPx.top - halfHandlePx - contentOffsetY, + dragPillLeftPx = dragPillLeftPx, + dragPillTopPx = dragPillTopPx + ) +} + // Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it suspend fun PointerInputScope.detectEagerDragGestures( onDragStart: (Offset) -> Unit, @@ -95,14 +154,14 @@ suspend fun PointerInputScope.detectEagerDragGestures( awaitEachGesture { var dragStarted = false try { - val down = awaitFirstDown(requireUnconsumed = false) + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) down.consume() // Consume immediately onDragStart(down.position) dragStarted = true val pointerId = down.id var canceled = false while (true) { - val event = awaitPointerEvent() + val event = awaitPointerEvent(PointerEventPass.Initial) val change = event.changes.firstOrNull { it.id == pointerId } if (change == null) { canceled = true @@ -151,6 +210,13 @@ fun ResizableTextBox( val density = LocalDensity.current val focusRequester = remember { FocusRequester() } + val currentOnBoundsChanged by rememberUpdatedState(onBoundsChanged) + val currentOnTextChanged by rememberUpdatedState(onTextChanged) + val currentOnSelect by rememberUpdatedState(onSelect) + val currentOnDragStart by rememberUpdatedState(onDragStart) + val currentOnDrag by rememberUpdatedState(onDrag) + val currentOnDragEnd by rememberUpdatedState(onDragEnd) + val currentOnDragCancel by rememberUpdatedState(onDragCancel) // Counter-scale fixed sizes so they render proportionally regardless of the zoom level val handleSize = (10f / scale).dp @@ -236,170 +302,207 @@ fun ResizableTextBox( } } + val dragPillTouchWidth = (TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP / scale).dp + val dragPillTouchHeight = (TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP / scale).dp + val dragPillWidthPx = with(density) { dragPillTouchWidth.toPx() } + val dragPillHeightPx = with(density) { dragPillTouchHeight.toPx() } + val dragPillGapPx = with(density) { (TEXT_BOX_DRAG_PILL_GAP_DP / scale).dp.toPx() } + val chromeLayout = calculateTextBoxChromeLayout( + textBoundsPx = currentRectPx, + isSelected = isSelected, + isHandleAtTop = isHandleAtTop, + handleSizePx = handleSizePx, + dragPillWidthPx = dragPillWidthPx, + dragPillHeightPx = dragPillHeightPx, + dragPillGapPx = dragPillGapPx + ) + Box( modifier = modifier .zIndex(if (isSelected) 10f else 0f) .graphicsLayer { - translationX = currentRectPx.left - halfHandlePx - translationY = currentRectPx.top - halfHandlePx + translationX = chromeLayout.outerTranslationX + translationY = chromeLayout.outerTranslationY } .size( - width = with(density) { (currentRectPx.width + handleSizePx).toDp() }, - height = with(density) { (currentRectPx.height + handleSizePx).toDp() } + width = with(density) { chromeLayout.containerWidthPx.toDp() }, + height = with(density) { chromeLayout.containerHeightPx.toDp() } ) ) { - // --- 1. Content Body --- Box( modifier = Modifier - .fillMaxSize() - .padding(handleSize / 2) - .pointerInput(Unit) { - detectTapGestures { - Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]") - onSelect() - } + .offset { + IntOffset( + chromeLayout.contentOffsetX.roundToInt(), + chromeLayout.contentOffsetY.roundToInt() + ) } - .then( - if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier + .size( + width = with(density) { chromeLayout.contentWidthPx.toDp() }, + height = with(density) { chromeLayout.contentHeightPx.toDp() } ) + .zIndex(1f) ) { - BasicTextField( - value = box.text, - onValueChange = onTextChanged, + // --- 1. Content Body --- + Box( modifier = Modifier .fillMaxSize() - .padding(8.dp) - .verticalScroll(rememberScrollState()) - .focusRequester(focusRequester), - textStyle = TextStyle( - color = box.color, - background = box.backgroundColor, - fontFamily = fontFamily, - fontSize = with(LocalDensity.current) { - (box.fontSize * pageHeightPx).toSp() - }, - fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal, - fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal, - textDecoration = run { - val decs = mutableListOf() - if (box.isUnderline) decs.add(TextDecoration.Underline) - if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough) - if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs) + .padding(handleSize / 2) + .pointerInput(Unit) { + detectTapGestures { + Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]") + currentOnSelect() + } } - ), - cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary), - enabled = isEditMode && isSelected, - readOnly = !isEditMode - ) + .then( + if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier + ) + ) { + BasicTextField( + value = box.text, + onValueChange = currentOnTextChanged, + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .verticalScroll(rememberScrollState()) + .focusRequester(focusRequester), + textStyle = TextStyle( + color = box.color, + background = box.backgroundColor, + fontFamily = fontFamily, + fontSize = with(LocalDensity.current) { + (box.fontSize * pageHeightPx).toSp() + }, + fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal, + textDecoration = run { + val decs = mutableListOf() + if (box.isUnderline) decs.add(TextDecoration.Underline) + if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough) + if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs) + } + ), + cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary), + enabled = isEditMode && isSelected, + readOnly = !isEditMode + ) + } + + if (isSelected) { + val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE } + + fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset { + return when (handle) { + ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx) + ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx) + ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx) + ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2) + ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h) + ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h) + ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h) + ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2) + else -> Offset.Zero + } + } + + handles.forEach { handle -> + val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height) + + Box( + modifier = Modifier + .offset { + IntOffset( + (center.x - handleTouchSizePx / 2).roundToInt(), + (center.y - handleTouchSizePx / 2).roundToInt() + ) + } + .size(handleTouchSize) + .pointerInput(box.id, handle, pageWidthPx, pageHeightPx) { + detectEagerDragGestures( + onDragStart = { + Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle") + isDraggingOrResizing = true + }, + onDragEnd = { + isDraggingOrResizing = false + val normalized = Rect( + left = currentRectPx.left / pageWidthPx, + top = currentRectPx.top / pageHeightPx, + right = currentRectPx.right / pageWidthPx, + bottom = currentRectPx.bottom / pageHeightPx + ) + Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized") + currentOnBoundsChanged(normalized) + }, + onDragCancel = { isDraggingOrResizing = false } + ) { change, dragAmount -> + Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount") + + var l = currentRectPx.left + var t = currentRectPx.top + var r = currentRectPx.right + var b = currentRectPx.bottom + val dx = dragAmount.x + val dy = dragAmount.y + val minSize = 50f / scale + + when (handle) { + ResizeHandle.TOP_LEFT -> { + l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) + t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) + } + ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) + ResizeHandle.TOP_RIGHT -> { + r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) + t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) + } + ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) + ResizeHandle.BOTTOM_RIGHT -> { + r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) + b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) + } + ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) + ResizeHandle.BOTTOM_LEFT -> { + l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) + b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) + } + ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) + else -> {} + } + currentRectPx = Rect(l, t, r, b) + } + } + ) { + Box( + modifier = Modifier + .size(handleSize) + .background(handleColor, CircleShape) + .align(Alignment.Center) + ) + } + } + } } if (isSelected) { - val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE } - - fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset { - return when (handle) { - ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx) - ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx) - ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx) - ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2) - ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h) - ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h) - ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h) - ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2) - else -> Offset.Zero - } - } - - handles.forEach { handle -> - val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height) - - Box( - modifier = Modifier - .offset { - IntOffset( - (center.x - handleTouchSizePx / 2).roundToInt(), - (center.y - handleTouchSizePx / 2).roundToInt() - ) - } - .size(handleTouchSize) - .pointerInput(onBoundsChanged) { - detectEagerDragGestures( - onDragStart = { - Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle") - isDraggingOrResizing = true - }, - onDragEnd = { - isDraggingOrResizing = false - val normalized = Rect( - left = currentRectPx.left / pageWidthPx, - top = currentRectPx.top / pageHeightPx, - right = currentRectPx.right / pageWidthPx, - bottom = currentRectPx.bottom / pageHeightPx - ) - Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized") - onBoundsChanged(normalized) - }, - onDragCancel = { isDraggingOrResizing = false } - ) { change, dragAmount -> - Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount") - - var l = currentRectPx.left - var t = currentRectPx.top - var r = currentRectPx.right - var b = currentRectPx.bottom - val dx = dragAmount.x - val dy = dragAmount.y - val minSize = 50f / scale - - when (handle) { - ResizeHandle.TOP_LEFT -> { - l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) - t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) - } - ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) - ResizeHandle.TOP_RIGHT -> { - r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) - t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) - } - ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) - ResizeHandle.BOTTOM_RIGHT -> { - r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) - b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) - } - ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) - ResizeHandle.BOTTOM_LEFT -> { - l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) - b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) - } - ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) - else -> {} - } - currentRectPx = Rect(l, t, r, b) - } - } - ) { - Box( - modifier = Modifier - .size(handleSize) - .background(handleColor, CircleShape) - .align(Alignment.Center) - ) - } - } - DragPill( isDarkMode = isDarkMode, scale = scale, modifier = Modifier - .align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter) - .offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp) + .offset { + IntOffset( + chromeLayout.dragPillLeftPx.roundToInt(), + chromeLayout.dragPillTopPx.roundToInt() + ) + } + .size(width = dragPillTouchWidth, height = dragPillTouchHeight) .zIndex(20f) - .pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) { + .pointerInput(box.id, pageWidthPx, pageHeightPx) { detectEagerDragGestures( onDragStart = { offset -> Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset") isDraggingOrResizing = true - onDragStart(offset) + currentOnDragStart(offset) }, onDragEnd = { isDraggingOrResizing = false @@ -410,12 +513,12 @@ fun ResizableTextBox( bottom = currentRectPx.bottom / pageHeightPx ) Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized") - onBoundsChanged(normalized) - onDragEnd() + currentOnBoundsChanged(normalized) + currentOnDragEnd() }, onDragCancel = { isDraggingOrResizing = false - onDragCancel() + currentOnDragCancel() } ) { change, dragAmount -> val w = currentRectPx.width @@ -426,7 +529,7 @@ fun ResizableTextBox( val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h)) val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h) currentRectPx = newRect - onDrag(dragAmount, newRect) + currentOnDrag(dragAmount, newRect) } } ) @@ -440,20 +543,28 @@ private fun DragPill( isDarkMode: Boolean, scale: Float = 1f ) { - Surface( - modifier = modifier - .size(width = (48f / scale).dp, height = (24f / scale).dp), - shape = CircleShape, - color = if (isDarkMode) Color.White else Color.Black, - contentColor = if (isDarkMode) Color.Black else Color.White, - shadowElevation = (4f / scale).dp + Box( + modifier = modifier, + contentAlignment = Alignment.Center ) { - Box(contentAlignment = Alignment.Center) { - Icon( - painter = painterResource(id = R.drawable.drag_handle), - contentDescription = stringResource(R.string.content_desc_drag_text_box), - modifier = Modifier.size((20f / scale).dp) - ) + Surface( + modifier = Modifier + .size( + width = (TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP / scale).dp, + height = (TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP / scale).dp + ), + shape = CircleShape, + color = if (isDarkMode) Color.White else Color.Black, + contentColor = if (isDarkMode) Color.Black else Color.White, + shadowElevation = (4f / scale).dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = R.drawable.drag_handle), + contentDescription = stringResource(R.string.content_desc_drag_text_box), + modifier = Modifier.size((20f / scale).dp) + ) + } } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt index c9a0616..f613fd5 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt @@ -133,22 +133,23 @@ object PdfToHtmlGenerator { headerFooterStrings: Set ): String { return try { - doc.openPage(pageIdx)?.use { page -> - page.openTextPage().use { textPage -> - val charCount = textPage.textPageCountChars() + PdfiumEngineProvider.withPdfium { + doc.openPage(pageIdx)?.use { page -> + page.openTextPage().use { textPage -> + val charCount = textPage.textPageCountChars() val pagePtr = getNativePointer(page) val textPagePtr = getNativePointer(textPage) val imageElements = mutableListOf() - val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr) + val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr) for (i in 0 until objCount) { - if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { + if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { val bbox = FloatArray(4) - if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) { + if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) { val topY = bbox[3] val dimens = IntArray(2) - val pixels = PdfiumEngineProvider.bridge.extractImagePixels(pagePtr, i, dimens) + val pixels = NativePdfiumBridge.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) @@ -179,12 +180,10 @@ object PdfToHtmlGenerator { val flags: IntArray? val charBoxes: FloatArray? - 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) - } + sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount) + weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount) + flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount) + charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount) if (sizes == null || weights == null || flags == null) { return@use buildFallbackPageSection(pageNumber, rawText) @@ -302,8 +301,9 @@ object PdfToHtmlGenerator { } buildPageHtml(pageNumber, finalElements, headerFooterStrings) - } - } ?: buildEmptyPageSection(pageNumber) + } + } ?: buildEmptyPageSection(pageNumber) + } } catch (e: Exception) { Timber.tag(TAG).w(e, "Error extracting page $pageIdx") buildEmptyPageSection(pageNumber) @@ -506,17 +506,19 @@ object PdfToHtmlGenerator { for (pageIdx in samplePages) { try { - doc.openPage(pageIdx)?.use { page -> - page.openTextPage().use { textPage -> - val charCount = textPage.textPageCountChars() - if (charCount <= 0) return@use - val rawText = textPage.textPageGetText(0, charCount) ?: return@use + PdfiumEngineProvider.withPdfium { + doc.openPage(pageIdx)?.use { page -> + page.openTextPage().use { textPage -> + val charCount = textPage.textPageCountChars() + if (charCount <= 0) return@use + val rawText = textPage.textPageGetText(0, charCount) ?: return@use - val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 } - if (lines.isNotEmpty()) { - val edgeLines = lines.take(2) + lines.takeLast(2) - for (line in edgeLines) { - frequency[line] = (frequency[line] ?: 0) + 1 + val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 } + if (lines.isNotEmpty()) { + val edgeLines = lines.take(2) + lines.takeLast(2) + for (line in edgeLines) { + frequency[line] = (frequency[line] ?: 0) + 1 + } } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt index cc8597d..fc6ad15 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -44,6 +44,7 @@ import com.aryan.reader.SearchState import com.aryan.reader.SearchTopBar import com.aryan.reader.TooltipIconButton import com.aryan.reader.areReaderAiFeaturesEnabled +import com.aryan.reader.cardTitle import com.aryan.reader.epubreader.SystemUiMode import kotlin.collections.isNotEmpty @@ -52,6 +53,7 @@ internal val PdfTabStripHeight = 44.dp private val pdfToolbarTools = setOf( PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, + PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING, PdfReaderTool.SLIDER, PdfReaderTool.TOC, @@ -63,6 +65,61 @@ private val pdfToolbarTools = setOf( PdfReaderTool.SCREEN_ORIENTATION ) +internal enum class PdfOverflowMenuSection { + CUSTOMIZE_TOOLBAR, + HIDDEN_TOOLS, + OCR_LANGUAGE, + VISUAL_OPTIONS, + READING_MODE, + TAP_TO_TURN, + KEEP_SCREEN_ON, + AUTO_SCROLL, + TTS_SETTINGS, + BOOKMARK, + PAGE_MANAGEMENT, + REFLOW, + FILE_ACTIONS, + FILE_INFO +} + +internal fun pdfOverflowMenuSections( + hiddenTools: Set, + hasHiddenToolbarTools: Boolean, + isPro: Boolean, + effectiveFileType: FileType, + hasFileInfo: Boolean = true +): List = buildList { + add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR) + if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS) + if (isPro && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) { + add(PdfOverflowMenuSection.OCR_LANGUAGE) + } + if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) add(PdfOverflowMenuSection.VISUAL_OPTIONS) + if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) add(PdfOverflowMenuSection.READING_MODE) + if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) add(PdfOverflowMenuSection.TAP_TO_TURN) + if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) add(PdfOverflowMenuSection.KEEP_SCREEN_ON) + if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) add(PdfOverflowMenuSection.AUTO_SCROLL) + if ( + !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) || + !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) + ) { + add(PdfOverflowMenuSection.TTS_SETTINGS) + } + if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) add(PdfOverflowMenuSection.BOOKMARK) + if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) add(PdfOverflowMenuSection.PAGE_MANAGEMENT) + if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) add(PdfOverflowMenuSection.REFLOW) + if ( + !hiddenTools.contains(PdfReaderTool.SHARE.name) || + (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) || + (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) + ) { + add(PdfOverflowMenuSection.FILE_ACTIONS) + } + if (hasFileInfo && !hiddenTools.contains(PdfReaderTool.FILE_INFO.name)) { + add(PdfOverflowMenuSection.FILE_INFO) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun PdfTopBar( @@ -76,6 +133,7 @@ internal fun PdfTopBar( isLoadingDocument: Boolean, errorMessage: String?, currentPageForDisplay: Int, + currentPageLabel: String? = null, totalPages: Int, pagerStatePageCount: Int, hiddenTools: Set, @@ -87,6 +145,7 @@ internal fun PdfTopBar( isRightToLeftPagination: Boolean, isKeepScreenOn: Boolean, isTtsSessionActive: Boolean, + isSliderActive: Boolean, isBookmarked: Boolean, canDeletePage: Boolean, isReflowingThisBook: Boolean, @@ -95,9 +154,11 @@ internal fun PdfTopBar( isTabsEnabled: Boolean, openTabs: List, activeTabBookId: String?, + usePdfFileNameAsDisplayName: Boolean, effectiveFileType: FileType, onNavigateBack: () -> Unit, onShowThemePanel: () -> Unit, + onShowBrightnessControl: () -> Unit, onToggleScrollLock: () -> Unit, onShowDictionarySettings: () -> Unit, onShowPenPlayground: () -> Unit, @@ -125,6 +186,7 @@ internal fun PdfTopBar( onShowTtsSettings: () -> Unit, onShowTtsReplacements: () -> Unit, onToggleBookmark: () -> Unit, + onShowFileInfo: () -> Unit, onInsertPage: () -> Unit, onDeletePage: () -> Unit, onReflowAction: () -> Unit, @@ -176,7 +238,8 @@ internal fun PdfTopBar( val titleText = when { isLoadingDocument -> stringResource(R.string.loading_pdf) errorMessage != null -> stringResource(R.string.error_loading_pdf) - totalPages > 0 && pagerStatePageCount > 0 -> stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages) + totalPages > 0 && pagerStatePageCount > 0 -> currentPageLabel + ?: stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages) totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page) else -> stringResource(R.string.pdf_viewer) } @@ -199,6 +262,13 @@ internal fun PdfTopBar( ) { Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) } + PdfReaderTool.BRIGHTNESS -> TooltipIconButton( + text = stringResource(R.string.reader_brightness_title), + description = stringResource(R.string.reader_brightness_system_desc), + onClick = onShowBrightnessControl + ) { + Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), 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), @@ -219,7 +289,11 @@ internal fun PdfTopBar( onClick = onShowSlider, enabled = !isTtsPlayingOrLoading ) { - Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider)) + Icon( + painterResource(id = R.drawable.slider), + contentDescription = stringResource(R.string.content_desc_navigate_slider), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) } PdfReaderTool.TOC -> TooltipIconButton( text = stringResource(R.string.tooltip_toc), @@ -321,267 +395,264 @@ internal fun PdfTopBar( } ) { val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) } - DropdownMenuItem( - text = { Text(stringResource(R.string.title_customize_toolbar)) }, - onClick = { showMoreMenu = false; onShowCustomizeTools() }, - leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) } - ) - HorizontalDivider() - - if (hiddenToolbarTools.isNotEmpty()) { - DropdownMenuItem( - text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) }, - 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, - onShowScreenOrientation = onShowScreenOrientation - ) - } - } - HorizontalDivider() - } - - if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_ocr_language)) }, - onClick = { showMoreMenu = false; onShowOcrLanguage() } - ) - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_visual_options)) }, - onClick = { showMoreMenu = false; onShowVisualOptions() }, - leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) } - ) - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_change_reading_mode)) }, - onClick = { showReadingModeExpanded = !showReadingModeExpanded }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f) - ) - } - ) - if (showReadingModeExpanded) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, - enabled = !isTtsSessionActive, - onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false }, - trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, - enabled = !isTtsSessionActive, - onClick = { - onSetRightToLeftPagination(false) - onChangeDisplayMode(DisplayMode.PAGINATION) - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) { - Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) - } - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_right_to_left_pagination)) }, - enabled = !isTtsSessionActive, - onClick = { - onSetRightToLeftPagination(true) - onChangeDisplayMode(DisplayMode.PAGINATION) - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) { - Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) - } - } - ) - } - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, - enabled = displayMode == DisplayMode.PAGINATION, - onClick = { - onToggleTapToNavigate() - showMoreMenu = false - }, - trailingIcon = { - if (tapToNavigateEnabled) { - Icon( - Icons.Filled.Check, - contentDescription = stringResource(R.string.content_desc_enabled) - ) - } - } - ) - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_keep_screen_on)) }, - onClick = { onToggleKeepScreenOn(); showMoreMenu = false }, - trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } - ) - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_auto_scroll)) }, - enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, - onClick = { showMoreMenu = false; onStartAutoScroll() } - ) - HorizontalDivider() - } - val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) - if (showTtsVoiceSettings || showTtsReplacements) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_settings)) }, - onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded }, - leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f) - ) - } - ) - if (showTtsSettingsExpanded) { - if (showTtsVoiceSettings) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, - enabled = !isTtsSessionActive, - onClick = { showMoreMenu = false; onShowTtsSettings() }, - leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } - ) - } - if (showTtsReplacements) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, - onClick = { showMoreMenu = false; onShowTtsReplacements() }, - leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } - ) - } - } - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) { - DropdownMenuItem( - text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) }, - onClick = { showMoreMenu = false; onToggleBookmark() } - ) - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_insert_blank_page)) }, - onClick = { showMoreMenu = false; onInsertPage() } - ) - if (canDeletePage) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_delete_page)) }, - onClick = { showMoreMenu = false; onDeletePage() }, - colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error) - ) - } - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) { - DropdownMenuItem( - text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) }, - enabled = isPdfDocumentLoaded && !isReflowingThisBook, - onClick = { showMoreMenu = false; onReflowAction() }, - leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) } - ) - HorizontalDivider() - } - val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name) val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name) val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name) - if (showShareAction || showSaveCopyAction || showPrintAction) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_share_save_print)) }, - onClick = { showFileActionsExpanded = !showFileActionsExpanded }, - leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f) + pdfOverflowMenuSections( + hiddenTools = hiddenTools, + hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(), + isPro = BuildConfig.IS_PRO, + effectiveFileType = effectiveFileType + ).forEachIndexed { index, section -> + if (index > 0) HorizontalDivider() + when (section) { + PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.title_customize_toolbar)) }, + onClick = { showMoreMenu = false; onShowCustomizeTools() }, + leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) } ) } - ) - if (showFileActionsExpanded) { - if (showShareAction) { + PdfOverflowMenuSection.HIDDEN_TOOLS -> { DropdownMenuItem( - text = { Text(stringResource(R.string.action_share)) }, - onClick = { showMoreMenu = false; onShare() }, - leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) } + text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) }, + 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, + isSliderActive = isSliderActive, + closeMenu = { + showHiddenToolsExpanded = false + showMoreMenu = false + }, + onShowThemePanel = onShowThemePanel, + onShowBrightnessControl = onShowBrightnessControl, + onToggleScrollLock = onToggleScrollLock, + onShowDictionarySettings = onShowDictionarySettings, + onShowSlider = onShowSlider, + onShowToc = onShowToc, + onSearchClick = onSearchClick, + onToggleHighlights = onToggleHighlights, + onShowAiHub = onShowAiHub, + onToggleEditMode = onToggleEditMode, + onToggleTts = onToggleTts, + onShowScreenOrientation = onShowScreenOrientation + ) + } + } + } + PdfOverflowMenuSection.FILE_INFO -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.file_information)) }, + onClick = { showMoreMenu = false; onShowFileInfo() }, + leadingIcon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp)) } ) } - if (showSaveCopyAction) { + PdfOverflowMenuSection.OCR_LANGUAGE -> { DropdownMenuItem( - text = { Text(stringResource(R.string.action_save_copy_to_device)) }, - onClick = { showMoreMenu = false; onSaveCopy() }, - leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) } + text = { Text(stringResource(R.string.menu_ocr_language)) }, + onClick = { showMoreMenu = false; onShowOcrLanguage() } ) } - if (showPrintAction) { + PdfOverflowMenuSection.VISUAL_OPTIONS -> { DropdownMenuItem( - text = { Text(stringResource(R.string.action_print)) }, - onClick = { showMoreMenu = false; onPrint() }, - leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) } + text = { Text(stringResource(R.string.menu_visual_options)) }, + onClick = { showMoreMenu = false; onShowVisualOptions() }, + leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) } ) } + PdfOverflowMenuSection.READING_MODE -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_change_reading_mode)) }, + onClick = { showReadingModeExpanded = !showReadingModeExpanded }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f) + ) + } + ) + if (showReadingModeExpanded) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, + enabled = !isTtsSessionActive, + onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false }, + trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, + enabled = !isTtsSessionActive, + onClick = { + onSetRightToLeftPagination(false) + onChangeDisplayMode(DisplayMode.PAGINATION) + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) { + Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_right_to_left_pagination)) }, + enabled = !isTtsSessionActive, + onClick = { + onSetRightToLeftPagination(true) + onChangeDisplayMode(DisplayMode.PAGINATION) + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) { + Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) + } + } + ) + } + } + PdfOverflowMenuSection.TAP_TO_TURN -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, + enabled = displayMode == DisplayMode.PAGINATION, + onClick = { + onToggleTapToNavigate() + showMoreMenu = false + }, + trailingIcon = { + if (tapToNavigateEnabled) { + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + } + } + ) + } + PdfOverflowMenuSection.KEEP_SCREEN_ON -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_keep_screen_on)) }, + onClick = { onToggleKeepScreenOn(); showMoreMenu = false }, + trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) } + ) + } + PdfOverflowMenuSection.AUTO_SCROLL -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_auto_scroll)) }, + enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, + onClick = { showMoreMenu = false; onStartAutoScroll() } + ) + } + PdfOverflowMenuSection.TTS_SETTINGS -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_settings)) }, + onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded }, + leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f) + ) + } + ) + if (showTtsSettingsExpanded) { + if (showTtsVoiceSettings) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, + enabled = !isTtsSessionActive, + onClick = { showMoreMenu = false; onShowTtsSettings() }, + leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + if (showTtsReplacements) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, + onClick = { showMoreMenu = false; onShowTtsReplacements() }, + leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + } + } + PdfOverflowMenuSection.BOOKMARK -> { + DropdownMenuItem( + text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) }, + onClick = { showMoreMenu = false; onToggleBookmark() } + ) + } + PdfOverflowMenuSection.PAGE_MANAGEMENT -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_insert_blank_page)) }, + onClick = { showMoreMenu = false; onInsertPage() } + ) + if (canDeletePage) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_delete_page)) }, + onClick = { showMoreMenu = false; onDeletePage() }, + colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error) + ) + } + } + PdfOverflowMenuSection.REFLOW -> { + DropdownMenuItem( + text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) }, + enabled = isPdfDocumentLoaded && !isReflowingThisBook, + onClick = { showMoreMenu = false; onReflowAction() }, + leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + PdfOverflowMenuSection.FILE_ACTIONS -> { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_share_save_print)) }, + onClick = { showFileActionsExpanded = !showFileActionsExpanded }, + leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f) + ) + } + ) + if (showFileActionsExpanded) { + if (showShareAction) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_share)) }, + onClick = { showMoreMenu = false; onShare() }, + leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) } + ) + } + if (showSaveCopyAction) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_save_copy_to_device)) }, + onClick = { showMoreMenu = false; onSaveCopy() }, + leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) } + ) + } + if (showPrintAction) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_print)) }, + onClick = { showMoreMenu = false; onPrint() }, + leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + } + } } } } @@ -608,7 +679,7 @@ internal fun PdfTopBar( verticalAlignment = Alignment.CenterVertically ) { Text( - text = tab.customName ?: tab.title ?: tab.displayName, + text = tab.cardTitle(usePdfFileNameAsDisplayName), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 140.dp), @@ -645,8 +716,10 @@ private fun HiddenPdfToolMenuItem( isHighlightingLoading: Boolean, isEditMode: Boolean, isTtsSessionActive: Boolean, + isSliderActive: Boolean, closeMenu: () -> Unit, onShowThemePanel: () -> Unit, + onShowBrightnessControl: () -> Unit, onToggleScrollLock: () -> Unit, onShowDictionarySettings: () -> Unit, onShowSlider: () -> Unit, @@ -671,6 +744,7 @@ private fun HiddenPdfToolMenuItem( closeMenu() when (tool) { PdfReaderTool.THEME -> onShowThemePanel() + PdfReaderTool.BRIGHTNESS -> onShowBrightnessControl() PdfReaderTool.LOCK_PANNING -> onToggleScrollLock() PdfReaderTool.DICTIONARY -> onShowDictionarySettings() PdfReaderTool.SLIDER -> onShowSlider() @@ -688,8 +762,14 @@ private fun HiddenPdfToolMenuItem( 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.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), 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.SLIDER -> Icon( + painterResource(id = R.drawable.slider), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) 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 -> { @@ -702,7 +782,12 @@ private fun HiddenPdfToolMenuItem( PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp)) } - } + }, + trailingIcon = if (tool == PdfReaderTool.SLIDER && isSliderActive) { + { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) + } + } else null ) } @@ -849,8 +934,10 @@ fun PdfBottomBar( isHighlightingLoading: Boolean, isEditMode: Boolean, isTtsSessionActive: Boolean, + isSliderActive: Boolean, ttsErrorMessage: String?, onShowThemePanel: () -> Unit, + onShowBrightnessControl: () -> Unit, onToggleScrollLock: () -> Unit, onShowDictionarySettings: () -> Unit, onShowSlider: () -> Unit, @@ -893,6 +980,13 @@ fun PdfBottomBar( ) { Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) } + PdfReaderTool.BRIGHTNESS -> TooltipIconButton( + text = stringResource(R.string.reader_brightness_title), + description = stringResource(R.string.reader_brightness_system_desc), + onClick = onShowBrightnessControl + ) { + Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } PdfReaderTool.LOCK_PANNING -> TooltipIconButton( text = stringResource(R.string.tooltip_lock_pan), description = stringResource(R.string.tooltip_lock_pan_desc), @@ -913,7 +1007,11 @@ fun PdfBottomBar( onClick = onShowSlider, enabled = !isTtsPlayingOrLoading ) { - Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider)) + Icon( + painterResource(id = R.drawable.slider), + contentDescription = stringResource(R.string.content_desc_navigate_slider), + tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) } PdfReaderTool.TOC -> TooltipIconButton( text = stringResource(R.string.tooltip_toc), diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt new file mode 100644 index 0000000..7eee3f1 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt @@ -0,0 +1,35 @@ +package com.aryan.reader.pdf + +import timber.log.Timber +import kotlin.math.roundToInt + +internal object PdfVerticalPerfLog { + const val TAG = "PdfVerticalPerf" + const val SAMPLE_INTERVAL_MS = 250L + + 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) + } + + fun f(value: Float): String { + if (value.isNaN() || value.isInfinite()) return value.toString() + return (value * 10f).roundToInt().let { rounded -> + if (rounded % 10 == 0) (rounded / 10).toString() + else (rounded / 10f).toString() + } + } + + fun xy(x: Float, y: Float): String = "(${f(x)},${f(y)})" +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 0d42531..63cd25f 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -42,7 +42,6 @@ import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.foundation.gestures.calculateCentroidSize import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculateZoom -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -88,6 +87,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.isPrimaryPressed import androidx.compose.ui.input.pointer.isSecondaryPressed @@ -116,6 +116,7 @@ import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -128,6 +129,18 @@ import kotlin.math.min import kotlin.math.roundToInt private const val SCROLL_BOUNDS_TAG = "PdfScrollBounds" +private const val VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS = 220L + +internal fun resolvePdfVerticalPageBackgroundColor( + activeTheme: com.aryan.reader.ReaderTheme +): Color { + val resolved = when (activeTheme.id) { + "no_theme", "system" -> Color.White + "reverse" -> Color.Black + else -> activeTheme.backgroundColor + } + return if (resolved.isSpecified) resolved else Color.White +} @Stable class VerticalPdfReaderState { @@ -333,11 +346,7 @@ internal fun PdfVerticalReader( var isStylusEraserOverride by remember { mutableStateOf(false) } val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse" val verticalPageBackgroundColor = remember(activeTheme) { - when (activeTheme.id) { - "no_theme", "system" -> Color.White - "reverse" -> Color.Black - else -> activeTheme.backgroundColor - } + resolvePdfVerticalPageBackgroundColor(activeTheme) } BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { val imeInsets = WindowInsets.ime @@ -374,6 +383,22 @@ internal fun PdfVerticalReader( var isFastFlinging by remember { mutableStateOf(false) } var isInteracting by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) } + var isTileRenderIdleCooldownActive by remember { mutableStateOf(false) } + + LaunchedEffect(isInteracting, isFlinging) { + if (isInteracting || isFlinging) { + if (!isTileRenderIdleCooldownActive) { + PdfVerticalPerfLog.d("tile-render-cooldown active=true reason=busy") + } + isTileRenderIdleCooldownActive = true + } else if (isTileRenderIdleCooldownActive) { + delay(VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS) + PdfVerticalPerfLog.d( + "tile-render-cooldown active=false idleFor=${VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS}ms" + ) + isTileRenderIdleCooldownActive = false + } + } val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) { data class LayoutResult(val pages: List, val totalHeight: Float) @@ -417,9 +442,48 @@ internal fun PdfVerticalReader( } } + LaunchedEffect(layoutInfo, totalDocHeight, screenWidth, screenHeight, fitZoom, headerHeightPx, footerHeightPx) { + PdfVerticalPerfLog.i( + "layout-ready pages=${layoutInfo.size} totalH=${PdfVerticalPerfLog.f(totalDocHeight)} " + + "screen=${PdfVerticalPerfLog.xy(screenWidth, screenHeight)} chrome=${PdfVerticalPerfLog.xy(headerHeightPx, footerHeightPx)} " + + "fitZoom=${PdfVerticalPerfLog.f(fitZoom)} firstPageH=${PdfVerticalPerfLog.f(layoutInfo.firstOrNull()?.height ?: 0f)}" + ) + } + val zoomAnimatable = remember { Animatable(fitZoom) } val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) } val panYAnimatable = remember { Animatable(0f) } + val dragCameraUpdates = remember { + Channel>(Channel.CONFLATED) + } + val oneHandZoomDistancePx = with(density) { + PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP.dp.toPx() + } + var oneHandZoomStartZoom by remember { mutableFloatStateOf(fitZoom) } + var oneHandZoomStartPan by remember { mutableStateOf(Offset.Zero) } + var oneHandZoomPivotScreen by remember { mutableStateOf(Offset.Zero) } + var isVerticalOneHandZooming by remember { mutableStateOf(false) } + val latestIsVerticalOneHandZooming by rememberUpdatedState(isVerticalOneHandZooming) + + DisposableEffect(dragCameraUpdates) { + onDispose { + dragCameraUpdates.close() + } + } + + LaunchedEffect(dragCameraUpdates) { + for ((targetZoom, targetPanX, targetPanY) in dragCameraUpdates) { + if (zoomAnimatable.value != targetZoom) { + zoomAnimatable.snapTo(targetZoom) + } + if (panXAnimatable.value != targetPanX) { + panXAnimatable.snapTo(targetPanX) + } + if (panYAnimatable.value != targetPanY) { + panYAnimatable.snapTo(targetPanY) + } + } + } LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) { onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value)) @@ -601,6 +665,21 @@ internal fun PdfVerticalReader( return clampValues(targetZoom, targetPanX, targetPanY) } + fun updatePanBoundsForZoom(finalZoom: Float) { + val zoomedDocWidth = screenWidth * finalZoom + val (finalMinX, finalMaxX) = if (zoomedDocWidth < screenWidth) { + val centeredX = (screenWidth - zoomedDocWidth) / 2f + centeredX to centeredX + } else { + -(zoomedDocWidth - screenWidth) to 0f + } + panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX) + + val zoomedDocHeight = totalDocHeight * finalZoom + val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx) + panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx) + } + LaunchedEffect(resetZoomTrigger) { if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) { scope.launch { @@ -864,23 +943,39 @@ internal fun PdfVerticalReader( LaunchedEffect(isInteracting) { Timber.tag("PdfTouchDebug").i("VerticalReader: isInteracting changed to $isInteracting") + PdfVerticalPerfLog.i( + "interaction-state interacting=$isInteracting dragging=$isDragging flinging=$isFlinging " + + "zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}" + ) } LaunchedEffect(highResScale) { Timber.tag("PdfPerformance").i("VerticalReader HighResScale changed to: $highResScale") + PdfVerticalPerfLog.i( + "high-res-scale scale=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " + + "interacting=$isInteracting flinging=$isFlinging fastFlinging=$isFastFlinging" + ) } LaunchedEffect(Unit) { - snapshotFlow { isInteracting || (isFlinging && isFastFlinging) }.collectLatest { isBusy -> + snapshotFlow { isInteracting || isFlinging }.collectLatest { isBusy -> Timber.tag("PdfDrawPerf").d( "VerticalReader Interaction State: isBusy=$isBusy (Interacting=$isInteracting, Flinging=$isFlinging, Fast=$isFastFlinging)" ) + PdfVerticalPerfLog.d( + "render-resolution-gate busy=$isBusy interacting=$isInteracting flinging=$isFlinging " + + "fastFlinging=$isFastFlinging highRes=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)}" + ) if (!isBusy) { delay(50) val target = zoomAnimatable.value if (highResScale != target) { Timber.tag("PdfDrawPerf").v("VerticalReader: Updating highResScale to $target") + PdfVerticalPerfLog.i( + "high-res-scale-update from=${PdfVerticalPerfLog.f(highResScale)} to=${PdfVerticalPerfLog.f(target)} " + + "pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}" + ) highResScale = target } } @@ -893,7 +988,7 @@ internal fun PdfVerticalReader( } LaunchedEffect(zoomAnimatable.value) { - if (!isInteracting && !(isFlinging && isFastFlinging)) { + if (!isInteracting && !isFlinging) { if (highResScale != zoomAnimatable.value) { highResScale = zoomAnimatable.value } @@ -1071,6 +1166,91 @@ internal fun PdfVerticalReader( } } + val onDoubleTapDragZoomStart: (Offset) -> Unit = { + if (!isScrollLocked) { + isVerticalOneHandZooming = true + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.oneHandStart requestedPivot=$it center=(${(screenWidth / 2f).toInt()},${(screenHeight / 2f).toInt()}) " + + "zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})" + ) + oneHandZoomPivotScreen = Offset(screenWidth / 2f, screenHeight / 2f) + oneHandZoomStartZoom = zoomAnimatable.value + oneHandZoomStartPan = Offset(panXAnimatable.value, panYAnimatable.value) + isInteracting = true + isDragging = true + scope.launch { + zoomAnimatable.stop() + panXAnimatable.stop() + panYAnimatable.stop() + panXAnimatable.updateBounds(null, null) + panYAnimatable.updateBounds(null, null) + } + } + } + + val onDoubleTapDragZoom: (Offset, Float) -> Unit = { _, totalDragY -> + if (!isScrollLocked) { + val screenDragY = totalDragY * oneHandZoomStartZoom + val targetZoom = pdfOneHandZoomScale( + startScale = oneHandZoomStartZoom, + totalDragY = screenDragY, + dragDistanceForDoublePx = oneHandZoomDistancePx, + minScale = fitZoom, + maxScale = 5f + ) + val rawPan = topLeftPdfPanForScaleChange( + previousScale = oneHandZoomStartZoom, + nextScale = targetZoom, + previousPan = oneHandZoomStartPan, + pivot = oneHandZoomPivotScreen + ) + val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawPan.x, rawPan.y) + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v( + "vertical.oneHandUpdate dragY=$totalDragY screenDragY=$screenDragY " + + "targetZoom=$targetZoom finalZoom=$finalZoom pan=($finalX,$finalY)" + ) + onZoomChange(finalZoom) + dragCameraUpdates.trySend(Triple(finalZoom, finalX, finalY)) + } + } + + val onDoubleTapDragZoomEnd: () -> Unit = { + val wasOneHandZooming = isVerticalOneHandZooming + isVerticalOneHandZooming = false + if (!isScrollLocked || wasOneHandZooming) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.oneHandEnd zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})" + ) + isInteracting = false + isDragging = false + val currentZoom = zoomAnimatable.value + if (currentZoom > fitZoom && currentZoom < fitZoom * 1.05f) { + scope.launch { + val (finalZoom, finalX, finalY) = clampCamera( + fitZoom, + panXAnimatable.value, + panYAnimatable.value + ) + coroutineScope { + launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(180, easing = FastOutSlowInEasing)) } + launch { panXAnimatable.animateTo(finalX, animationSpec = tween(180, easing = FastOutSlowInEasing)) } + launch { panYAnimatable.animateTo(finalY, animationSpec = tween(180, easing = FastOutSlowInEasing)) } + } + onZoomChange(finalZoom) + updatePanBoundsForZoom(finalZoom) + } + } else { + updatePanBoundsForZoom(currentZoom) + } + } + } + + val currentOnPageClick by rememberUpdatedState(onPageClick) + val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom) + val currentOnDoubleTapDragZoomStart by rememberUpdatedState(onDoubleTapDragZoomStart) + val currentOnDoubleTapDragZoom by rememberUpdatedState(onDoubleTapDragZoom) + val currentOnDoubleTapDragZoomEnd by rememberUpdatedState(onDoubleTapDragZoomEnd) + val globalDrawingModifier = Modifier.pointerInput( isEditMode, layoutInfo, @@ -1180,31 +1360,86 @@ internal fun PdfVerticalReader( .fillMaxSize() .background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor) .then(globalDrawingModifier) - .pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) { - Timber.tag("PdfTouchDebug").v( - "VerticalReader: TapPointerInput init. isEditMode=$isEditMode" - ) - + // Vertical zoom gestures live here so page tap handlers do not steal + // alternating double-tap-hold attempts. + .pointerInput( + layoutInfo, + isEditMode, + selectedTool, + isStylusOnlyMode, + isScrollLocked + ) { val isTapDetectionAllowed = !isEditMode || selectedTool == InkType.TEXT || isStylusOnlyMode - if (!isTapDetectionAllowed) return@pointerInput + if (!isTapDetectionAllowed) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.rootDetector.disabled edit=$isEditMode tool=$selectedTool stylusOnly=$isStylusOnlyMode" + ) + return@pointerInput + } - detectTapGestures(onTap = { - if (!isEditMode) { - Timber.tag("PdfTouchDebug").d("VerticalReader: Tap detected") - selectionClearTrigger++ - onPageClick() - } else if (selectedTool == InkType.TEXT) { - onPageClick() + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.rootDetector.enabled scrollLocked=$isScrollLocked edit=$isEditMode " + + "tool=$selectedTool pages=${layoutInfo.size} zoom=${zoomAnimatable.value}" + ) + + fun isOverPage(screenOffset: Offset): Boolean { + val zoom = zoomAnimatable.value.takeIf { it > 0f } ?: fitZoom + val docX = (screenOffset.x - panXAnimatable.value) / zoom + val docY = (screenOffset.y - panYAnimatable.value) / zoom + return layoutInfo.any { page -> + docX >= 0f && + docX <= page.width && + docY >= page.y && + docY <= page.y + page.height } - }, onDoubleTap = { offset -> - if (!isScrollLocked) { - Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected") - onDoubleTapToZoom(offset) + } + + detectPdfTapAndOneHandZoomGestures( + viewConfiguration = viewConfiguration, + canStartOneHandZoom = { !isScrollLocked }, + canHandleQuickDoubleTap = { !isScrollLocked }, + consumeSingleTap = false, + onTap = { offset -> + val overPage = isOverPage(offset) + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.rootTap offset=$offset overPage=$overPage" + ) + if (!overPage) { + selectionClearTrigger++ + currentOnPageClick() + } + }, + onQuickDoubleTap = { offset -> + if (!isScrollLocked) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.rootQuickDoubleTap offset=$offset zoom=${zoomAnimatable.value}" + ) + currentOnDoubleTapToZoom(offset) + } + }, + onOneHandZoomHoldStart = { offset -> + if (!isScrollLocked) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.rootOneHandHoldStart offset=$offset" + ) + currentOnDoubleTapDragZoomStart(offset) + } + }, + onOneHandZoom = { offset, totalDragY -> + if (!isScrollLocked) { + currentOnDoubleTapDragZoom(offset, totalDragY) + } + }, + onOneHandZoomEnd = { _ -> + if (!isScrollLocked || latestIsVerticalOneHandZooming) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d("vertical.rootOneHandEnd") + currentOnDoubleTapDragZoomEnd() + } } - }) + ) } .pointerInput( totalDocHeight, @@ -1224,8 +1459,25 @@ internal fun PdfVerticalReader( ) val down = awaitFirstDown(requireUnconsumed = false) + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.scrollDetector.down consumed=${down.isConsumed} pos=${down.position} " + + "zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})" + ) isInteracting = true isDragging = false + val gestureStartNanos = PdfVerticalPerfLog.nowNanos() + var gestureLastSampleMs = System.currentTimeMillis() + var gestureEventCount = 0 + var gestureConsumedEventCount = 0 + var gestureCanceledEventCount = 0 + var gestureZoomEventCount = 0 + var gestureMaxPanDelta = 0f + PdfVerticalPerfLog.i( + "gesture-start type=${down.type} scrollLocked=$isScrollLocked edit=$isEditMode tool=$selectedTool " + + "zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} highRes=${PdfVerticalPerfLog.f(highResScale)} " + + "pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} currentPage=${state.currentPage} " + + "visible=${state.firstVisiblePage}-${state.lastVisiblePage}" + ) Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}") @@ -1282,10 +1534,28 @@ internal fun PdfVerticalReader( do { val event = awaitPointerEvent() + gestureEventCount++ val isMultiTouch = event.changes.size > 1 val canceled = event.changes.any { it.isConsumed } && !isMultiTouch + if (latestIsVerticalOneHandZooming && !isMultiTouch) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v( + "vertical.scrollDetector.skipOneHandActive events=$gestureEventCount " + + "changes=${event.changes.joinToString { change -> + "pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}" + }}" + ) + continue + } + if (canceled) { + gestureCanceledEventCount++ + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.scrollDetector.canceledByConsumed mode=$gestureDisambiguationMode " + + "events=$gestureEventCount changes=${event.changes.joinToString { change -> + "pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}" + }}" + ) Timber.tag("PdfTouchDebug").v( "VerticalReader: Event Canceled (Child consumed?)." ) @@ -1311,7 +1581,11 @@ internal fun PdfVerticalReader( ) totalPanDistance += panMagnitude + gestureMaxPanDelta = max(gestureMaxPanDelta, panMagnitude) gestureZoomAccumulator *= zoomChange + if (abs(zoomChange - 1f) > 0.001f) { + gestureZoomEventCount++ + } val isZoomPastSlop = abs(gestureZoomAccumulator - 1f) > 0.05f val isPanPastSlop = totalPanDistance > touchSlop @@ -1320,11 +1594,17 @@ internal fun PdfVerticalReader( if (isPanPastSlop || isZoomPastSlop) { if (spanMagnitude > panMagnitude * 1.5f) { gestureDisambiguationMode = 2 + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.scrollDetector.modeZoom span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance" + ) Timber.tag("PdfTouchDebug").d( "Locked to ZOOM (Span > Pan * 1.5)" ) } else { gestureDisambiguationMode = 1 + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.scrollDetector.modePan span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance" + ) Timber.tag("PdfTouchDebug").d( "Locked to PAN (Pan Dominant)" ) @@ -1333,6 +1613,9 @@ internal fun PdfVerticalReader( } else if (gestureDisambiguationMode == 1) { if (spanMagnitude > (panMagnitude * 3f) && spanMagnitude > 4f) { gestureDisambiguationMode = 2 + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "vertical.scrollDetector.modePanToZoom span=$spanMagnitude pan=$panMagnitude" + ) Timber.tag("PdfTouchDebug").d( "Breakout: Switching PAN -> ZOOM" ) @@ -1379,15 +1662,29 @@ internal fun PdfVerticalReader( onZoomChange(accumulatedZoom) } - scope.launch { - zoomAnimatable.snapTo(accumulatedZoom) - panXAnimatable.snapTo(accumulatedPanX) - panYAnimatable.snapTo(accumulatedPanY) - } + dragCameraUpdates.trySend( + Triple(accumulatedZoom, accumulatedPanX, accumulatedPanY) + ) + val consumedChanges = event.changes.count { it.positionChanged() } event.changes.forEach { if (it.positionChanged()) it.consume() } + if (consumedChanges > 0) { + gestureConsumedEventCount++ + } + + val nowMs = System.currentTimeMillis() + if (nowMs - gestureLastSampleMs >= PdfVerticalPerfLog.SAMPLE_INTERVAL_MS) { + gestureLastSampleMs = nowMs + PdfVerticalPerfLog.d( + "gesture-drag-sample events=$gestureEventCount consumed=$gestureConsumedEventCount " + + "mode=$gestureDisambiguationMode multi=$isMultiTouch panDelta=${PdfVerticalPerfLog.f(panMagnitude)} " + + "totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} zoomChange=${PdfVerticalPerfLog.f(zoomChange)} " + + "zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)} " + + "highRes=${PdfVerticalPerfLog.f(highResScale)}" + ) + } if (event.changes.isNotEmpty()) { velocityTrackerAccumulator += panChange @@ -1405,75 +1702,102 @@ internal fun PdfVerticalReader( } isDragging = false - val validFlingCondition = panLocked + val gestureDurationMs = PdfVerticalPerfLog.elapsedMs(gestureStartNanos) - if (validFlingCondition) { + if (panLocked) { val velocity = tracker.calculateVelocity() val flingSensitivity = 2.0f val minFlingVelocity = 250f val (finalZoom, finalX, finalY) = clampCamera( accumulatedZoom, accumulatedPanX, accumulatedPanY ) + val zoomedDocWidth = screenWidth * finalZoom + val zoomedDocHeight = totalDocHeight * finalZoom - scope.launch { - isFlinging = true - try { - if (accumulatedZoom !in fitZoom..5f) { - zoomAnimatable.animateTo( - finalZoom, animationSpec = tween(300) - ) - } - onZoomChange(zoomAnimatable.targetValue) - val zoomedDocWidth = screenWidth * finalZoom - val zoomedDocHeight = totalDocHeight * finalZoom - - val flingMinX: Float - val flingMaxX: Float - if (zoomedDocWidth < screenWidth) { - val centeredX = (screenWidth - zoomedDocWidth) / 2f - flingMinX = centeredX - flingMaxX = centeredX - } else { - flingMinX = -(zoomedDocWidth - screenWidth) - flingMaxX = 0f - } - - val minPanY = - (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost( - headerHeightPx - ) - Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:") - Timber.tag(SCROLL_BOUNDS_TAG) - .d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight") - Timber.tag(SCROLL_BOUNDS_TAG) - .d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]") - panXAnimatable.updateBounds(flingMinX, flingMaxX) - panYAnimatable.updateBounds(minPanY, headerHeightPx) - - coroutineScope { - launch { - val rawX = velocity.x * flingSensitivity - val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX - else 0f - - if (flingX != 0f) panXAnimatable.animateDecay( - flingX, decay - ) - } - launch { - val rawY = velocity.y * flingSensitivity - val flingY = if (abs(rawY) > minFlingVelocity) rawY - else 0f - - if (flingY != 0f) panYAnimatable.animateDecay( - flingY, decay - ) - } - } - } finally { - isFlinging = false - } + val flingMinX: Float + val flingMaxX: Float + if (zoomedDocWidth < screenWidth) { + val centeredX = (screenWidth - zoomedDocWidth) / 2f + flingMinX = centeredX + flingMaxX = centeredX + } else { + flingMinX = -(zoomedDocWidth - screenWidth) + flingMaxX = 0f } + + val minPanY = + (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost( + headerHeightPx + ) + val rawX = velocity.x * flingSensitivity + val rawY = velocity.y * flingSensitivity + val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX else 0f + val flingY = if (abs(rawY) > minFlingVelocity) rawY else 0f + val shouldRunFling = flingX != 0f || flingY != 0f || accumulatedZoom !in fitZoom..5f + PdfVerticalPerfLog.i( + "gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " + + "canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " + + "totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=$panLocked shouldRunFling=$shouldRunFling " + + "velocity=${PdfVerticalPerfLog.xy(velocity.x, velocity.y)} fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " + + "zoom=${PdfVerticalPerfLog.f(finalZoom)} pan=${PdfVerticalPerfLog.xy(finalX, finalY)}" + ) + + if (shouldRunFling) { + scope.launch { + isFlinging = true + val flingStartNanos = PdfVerticalPerfLog.nowNanos() + PdfVerticalPerfLog.i( + "fling-start fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " + + "boundsX=${PdfVerticalPerfLog.xy(flingMinX, flingMaxX)} boundsY=${PdfVerticalPerfLog.xy(minPanY, headerHeightPx)} " + + "zoomedDocH=${PdfVerticalPerfLog.f(zoomedDocHeight)} highRes=${PdfVerticalPerfLog.f(highResScale)}" + ) + try { + if (accumulatedZoom !in fitZoom..5f) { + zoomAnimatable.animateTo( + finalZoom, animationSpec = tween(300) + ) + } + onZoomChange(zoomAnimatable.targetValue) + Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:") + Timber.tag(SCROLL_BOUNDS_TAG) + .d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight") + Timber.tag(SCROLL_BOUNDS_TAG) + .d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]") + panXAnimatable.updateBounds(flingMinX, flingMaxX) + panYAnimatable.updateBounds(minPanY, headerHeightPx) + + coroutineScope { + launch { + if (flingX != 0f) panXAnimatable.animateDecay( + flingX, decay + ) + } + launch { + if (flingY != 0f) panYAnimatable.animateDecay( + flingY, decay + ) + } + } + } finally { + PdfVerticalPerfLog.i( + "fling-end duration=${PdfVerticalPerfLog.elapsedMs(flingStartNanos)}ms " + + "zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} " + + "velocity=${PdfVerticalPerfLog.xy(panXAnimatable.velocity, panYAnimatable.velocity)}" + ) + isFlinging = false + } + } + } else { + panXAnimatable.updateBounds(flingMinX, flingMaxX) + panYAnimatable.updateBounds(minPanY, headerHeightPx) + } + } else { + PdfVerticalPerfLog.i( + "gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " + + "canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " + + "totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=false no-fling " + + "zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)}" + ) } } }) { @@ -1543,6 +1867,11 @@ internal fun PdfVerticalReader( Timber.tag("PdfDrawPerf").d( "Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})" ) + PdfVerticalPerfLog.d( + "visible-pages pages=${finalPages.map { it.index }} base=${baseVisiblePages.map { it.index }} " + + "draggingBox=${draggedBox != null} zoom=${PdfVerticalPerfLog.f(zoom)} panY=${PdfVerticalPerfLog.f(panY)} " + + "viewport=${PdfVerticalPerfLog.xy(viewportTop, viewportBottom)} buffered=${PdfVerticalPerfLog.xy(searchTop, searchBottom)}" + ) finalPages } else { cached @@ -1569,6 +1898,10 @@ internal fun PdfVerticalReader( if (mostVisible != null && mostVisible.index != state.currentPage) { Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)") + PdfVerticalPerfLog.d( + "current-page-change from=${state.currentPage} to=${mostVisible.index} " + + "viewport=${PdfVerticalPerfLog.xy(realViewportTop, realViewportBottom)} panY=${PdfVerticalPerfLog.f(panY)} zoom=${PdfVerticalPerfLog.f(zoom)}" + ) state.currentPage = mostVisible.index } } @@ -1678,24 +2011,6 @@ internal fun PdfVerticalReader( { text: String -> onSearchText(text) } } - val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom) - val onDoubleTapLambda = remember(page, screenWidth, screenHeight) { - { localOffset: Offset -> - Timber.tag("PdfZoomDebug").d( - "Page ${page.index} Double Tap: Local=$localOffset, PageY=${page.y}" - ) - val contentX = localOffset.x - val contentY = localOffset.y + page.y - val currentZ = zoomAnimatable.value - val panX = panXAnimatable.value - val panY = panYAnimatable.value - val screenX = contentX * currentZ + panX - val screenY = contentY * currentZ + panY - Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log - currentOnDoubleTapToZoom(Offset(screenX, screenY)) - } - } - val onTtsHighlightCenter: (Float) -> Unit = remember(page.index, ttsReadingPage) { { highlightCenterY -> @@ -1792,12 +2107,14 @@ internal fun PdfVerticalReader( onOcrStateChange = onOcrStateChange, onBookmarkClick = { onBookmarkClick(page.index) }, isZoomEnabled = false, - isScrolling = isDragging || (isFlinging && isFastFlinging), + isScrolling = isInteracting || + isDragging || + isFlinging || + isTileRenderIdleCooldownActive, isVerticalScroll = true, showPageNumberOverlay = showPageNumberOverlay, isScrollLocked = isScrollLocked, visualScaleProvider = currentScaleProvider, - onDoubleTap = onDoubleTapLambda, clearSelectionTrigger = selectionClearTrigger, onTtsHighlightCenterCalculated = onTtsHighlightCenter, onSearchHighlightCenterCalculated = onSearchHighlightCenter, @@ -1993,6 +2310,11 @@ internal fun PdfVerticalReader( Timber.tag("PdfPerformance").d( "VerticalReader Layout Measure/Place took ${layoutTime}ms for ${measurables.size} items" ) + PdfVerticalPerfLog.d( + "compose-layout-slow duration=${PdfVerticalPerfLog.f(layoutTime)}ms items=${measurables.size} " + + "visible=${visiblePages.map { it.index }} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " + + "pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}" + ) } measureResult } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index ee4f7e9..66e9a60 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -52,12 +52,16 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.waitForUpOrCancellation @@ -91,7 +95,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Close @@ -123,6 +126,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -139,6 +143,7 @@ import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset @@ -155,9 +160,17 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.isBackPressed +import androidx.compose.ui.input.pointer.isForwardPressed +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -166,6 +179,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -183,6 +197,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -194,14 +209,17 @@ import androidx.media3.common.util.UnstableApi import androidx.paging.compose.collectAsLazyPagingItems import androidx.work.WorkInfo import com.aryan.reader.AiDefinitionPopup -import com.aryan.reader.AiFeature import com.aryan.reader.AiDefinitionResult +import com.aryan.reader.AiFeature import com.aryan.reader.AiHubBottomSheet import com.aryan.reader.BuildConfig import com.aryan.reader.FileType import com.aryan.reader.HighlightColorPickerDialog import com.aryan.reader.MainViewModel import com.aryan.reader.R +import com.aryan.reader.ReaderBrightnessEffect +import com.aryan.reader.ReaderBrightnessSheet +import com.aryan.reader.ReaderFileInfoDialogs import com.aryan.reader.ReaderScreenOrientationEffect import com.aryan.reader.ReaderScreenOrientationSheet import com.aryan.reader.ReaderThemePanel @@ -210,7 +228,8 @@ import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.TtsWordReplacementsSheet -import com.aryan.reader.ml.SpeechBubble +import com.aryan.reader.areReaderAiFeaturesEnabled +import com.aryan.reader.callByokGeminiInlineAi import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.DictionarySettingsDialog import com.aryan.reader.epubreader.ExternalDictionaryHelper @@ -219,14 +238,15 @@ import com.aryan.reader.epubreader.TtsOverlayControls import com.aryan.reader.epubreader.loadTapToNavigateSetting import com.aryan.reader.epubreader.saveTapToNavigateSetting import com.aryan.reader.fetchAiDefinition -import com.aryan.reader.areReaderAiFeaturesEnabled -import com.aryan.reader.callByokGeminiInlineAi import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadGlobalTextureTransparency -import com.aryan.reader.loadReaderScreenOrientationMode import com.aryan.reader.loadPdfRightToLeftPagination +import com.aryan.reader.loadReaderBrightnessSettings +import com.aryan.reader.loadReaderScreenOrientationMode +import com.aryan.reader.loadReaderSliderToggled import com.aryan.reader.loadTtsReplacementPreferences +import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.pdf.data.AnnotationSettingsRepository import com.aryan.reader.pdf.data.PdfAnnotation @@ -238,14 +258,22 @@ import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.SmartSearchResult import com.aryan.reader.pdf.data.TextStyleConfig import com.aryan.reader.pdf.data.VirtualPage +import com.aryan.reader.readerSliderBookmarkPosition +import com.aryan.reader.readerSliderChromeColors +import com.aryan.reader.readerSliderToggleState import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveGlobalTextureTransparency -import com.aryan.reader.saveReaderScreenOrientationMode import com.aryan.reader.savePdfRightToLeftPagination +import com.aryan.reader.saveReaderBrightnessSettings +import com.aryan.reader.saveReaderScreenOrientationMode +import com.aryan.reader.saveReaderSliderToggled import com.aryan.reader.saveTtsReplacementPreferences import com.aryan.reader.scaledToCanvasLimit import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.pdf.PdfSpreadLayout +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shouldRenderReaderSlider import com.aryan.reader.summarizationUrl import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager @@ -270,7 +298,6 @@ import org.json.JSONObject import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File -import java.util.LinkedHashSet import java.net.HttpURLConnection import java.net.URL import kotlin.math.PI @@ -279,48 +306,9 @@ import kotlin.math.atan2 import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.pointer.isPrimaryPressed -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.isBackPressed -import androidx.compose.ui.input.pointer.isForwardPressed -internal fun resolveEraserStrokeWidth( - isEraserOverride: Boolean, - activeToolThickness: Float, - eraserToolThickness: Float -): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness - -internal fun canUsePdfSidecarsForBook( - activeBookId: String?, - loadedSidecarBookId: String?, - areSidecarsLoaded: Boolean -): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId - -internal fun currentPageScaleAfterPdfPageChange( - displayMode: DisplayMode, - isScrollLocked: Boolean, - lockedState: Triple?, - currentActiveScale: Float -): Float { - return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) { - lockedState?.first ?: currentActiveScale - } else { - 1f - } -} - -internal fun activePdfCameraAfterLockPreferenceLoad( - isScrollLocked: Boolean, - lockedState: Triple? -): Pair { - return if (isScrollLocked && lockedState != null) { - lockedState.first to Offset(lockedState.second, lockedState.third) - } else { - 1f to Offset.Zero - } -} +private const val PDF_SPREAD_PAN_FLING_MIN_VELOCITY = 600f +private const val PDF_SPREAD_PAN_FLING_MULTIPLIER = 0.72f @Suppress("KotlinConstantConditions") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall") @@ -373,11 +361,14 @@ fun PdfViewerScreen( var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) } var showVerticalPageGap by remember { mutableStateOf(loadPdfVerticalPageGapVisible(context)) } var showPageNumberOverlay by remember { mutableStateOf(loadPdfPageNumberOverlayVisible(context)) } + var showTopTabStrip by remember { mutableStateOf(loadPdfTopTabStripVisible(context)) } var showVisualOptionsSheet by remember { mutableStateOf(false) } + var pdfPageSpreadMode by remember { mutableStateOf(loadPdfPageSpreadMode(context)) } + var pdfFirstPageStandaloneInSpread by remember { mutableStateOf(loadPdfFirstPageStandaloneInSpread(context)) } + var pendingPaginationSpreadRestorePage by remember { mutableStateOf(null) } var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) } var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) } var showScreenOrientationSheet by remember { mutableStateOf(false) } - var isFullScreen by remember { mutableStateOf(false) } var documentPassword by rememberSaveable { mutableStateOf(null) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var isScrollLocked by remember { mutableStateOf(false) } @@ -439,12 +430,14 @@ fun PdfViewerScreen( val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7 var showNewTabSheet by remember { mutableStateOf(false) } + var showFileInfoDialog by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val isTabsEnabled = uiState.isTabsEnabled val openTabs = uiState.openTabs val activeTabBookId = uiState.activeTabBookId - val isPdfTabStripVisible = isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF + val canShowPdfTabs = isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF + val isPdfTabStripVisible = canShowPdfTabs && showTopTabStrip val originalFileName by remember(uiState.recentFiles, effectivePdfUri) { derivedStateOf { uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName @@ -710,6 +703,14 @@ fun PdfViewerScreen( val window = (view.context as? Activity)?.window val showStandardBars = showBars && !isEditMode + var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) } + var showBrightnessSheet by remember { mutableStateOf(false) } + ReaderBrightnessEffect(window, readerBrightnessSettings) + + val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings -> + readerBrightnessSettings = settings + saveReaderBrightnessSettings(context, settings) + } DisposableEffect(window, view) { onDispose { @@ -747,6 +748,7 @@ fun PdfViewerScreen( val dockHeight = 64.dp val dockHeightPx = with(LocalDensity.current) { dockHeight.toPx() } val density = LocalDensity.current + val viewConfiguration = LocalViewConfiguration.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val dummySearcher: suspend (String) -> List = { emptyList() } @@ -936,18 +938,98 @@ fun PdfViewerScreen( val pdfiumCore = remember { PdfiumCoreProvider.core } val verticalReaderState = rememberVerticalPdfReaderState() var virtualPages by remember { mutableStateOf>(emptyList()) } + var loadedPageLayoutBookId by remember { mutableStateOf(null) } + var pageLayoutMutationVersion by remember(currentBookId) { mutableLongStateOf(0L) } val totalDisplayPages by remember(virtualPages, totalPages) { derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages } } - val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages }) - val currentPage by remember { + val pdfSpreadSettings = remember(pdfPageSpreadMode, pdfFirstPageStandaloneInSpread) { + ReaderSettings( + pageSpreadMode = pdfPageSpreadMode, + pdfFirstPageStandaloneInSpread = pdfFirstPageStandaloneInSpread + ) + } + val paginationSpreadStarts = remember( + totalDisplayPages, + pdfSpreadSettings.pageSpreadMode, + pdfSpreadSettings.pdfFirstPageStandaloneInSpread + ) { + PdfSpreadLayout.spreadStartPageIndices(totalDisplayPages, pdfSpreadSettings) + } + val paginationPagerPageCount by remember( + displayMode, + totalDisplayPages, + paginationSpreadStarts, + pdfSpreadSettings.pageSpreadMode + ) { + derivedStateOf { + if (displayMode == DisplayMode.PAGINATION && PdfSpreadLayout.isTwoPageSpreadEnabled(pdfSpreadSettings)) { + paginationSpreadStarts.size.coerceAtLeast(1) + } else { + totalDisplayPages + } + } + } + val pagerState = rememberPagerState(initialPage = 0, pageCount = { paginationPagerPageCount }) + + fun paginationDisplayPageForPagerPage(pagerPage: Int): Int { + if (!PdfSpreadLayout.isTwoPageSpreadEnabled(pdfSpreadSettings)) { + return pagerPage.coerceIn(0, (totalDisplayPages - 1).coerceAtLeast(0)) + } + return paginationSpreadStarts + .getOrElse(pagerPage.coerceIn(0, (paginationSpreadStarts.size - 1).coerceAtLeast(0))) { 0 } + } + + fun paginationPagerPageForDisplayPage(displayPage: Int): Int { + if (!PdfSpreadLayout.isTwoPageSpreadEnabled(pdfSpreadSettings)) { + return displayPage.coerceIn(0, (paginationPagerPageCount - 1).coerceAtLeast(0)) + } + val normalizedPage = PdfSpreadLayout.normalizePageIndex(displayPage, totalDisplayPages, pdfSpreadSettings) + val spreadIndex = paginationSpreadStarts.indexOf(normalizedPage) + return spreadIndex.coerceAtLeast(0).coerceIn(0, (paginationPagerPageCount - 1).coerceAtLeast(0)) + } + + suspend fun scrollPaginationToDisplayPage(displayPage: Int) { + pagerState.scrollToPage(paginationPagerPageForDisplayPage(displayPage)) + } + + suspend fun animatePaginationToDisplayPage(displayPage: Int) { + pagerState.animateScrollToPage(paginationPagerPageForDisplayPage(displayPage)) + } + + fun currentPaginationDisplayPage(): Int { + return paginationDisplayPageForPagerPage(pagerState.currentPage) + } + + val currentPage by remember( + displayMode, + totalDisplayPages, + paginationPagerPageCount, + paginationSpreadStarts, + pdfSpreadSettings.pageSpreadMode, + pdfSpreadSettings.pdfFirstPageStandaloneInSpread + ) { derivedStateOf { when (displayMode) { - DisplayMode.PAGINATION -> pagerState.currentPage + DisplayMode.PAGINATION -> currentPaginationDisplayPage() DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage } } } + + LaunchedEffect( + pendingPaginationSpreadRestorePage, + pdfSpreadSettings.pageSpreadMode, + pdfSpreadSettings.pdfFirstPageStandaloneInSpread, + totalDisplayPages, + displayMode + ) { + val targetPage = pendingPaginationSpreadRestorePage ?: return@LaunchedEffect + if (displayMode == DisplayMode.PAGINATION && totalDisplayPages > 0) { + scrollPaginationToDisplayPage(targetPage) + } + pendingPaginationSpreadRestorePage = null + } var isDocumentReady by remember { mutableStateOf(false) } suspend fun renderSpeechBubblePrefetchBitmap( @@ -969,7 +1051,7 @@ fun PdfViewerScreen( val renderScale = (targetLongEdge / longEdge).coerceAtLeast(1f) val renderWidth = (pageWidth * renderScale).roundToInt().coerceAtLeast(1) val renderHeight = (pageHeight * renderScale).roundToInt().coerceAtLeast(1) - val renderBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888) + val renderBitmap = createBitmap(renderWidth, renderHeight) try { page.renderPageBitmap( @@ -1447,9 +1529,9 @@ fun PdfViewerScreen( if (isEditMode && targetPage >= 0 && targetPage < totalDisplayPages) { if (displayMode == DisplayMode.PAGINATION) { - if (pagerState.currentPage != targetPage) { + if (currentPaginationDisplayPage() != targetPage) { Timber.tag("CursorNav").d("Cursor moved to Page $targetPage. Auto-paging.") - pagerState.animateScrollToPage(targetPage) + animatePaginationToDisplayPage(targetPage) } } } @@ -1569,13 +1651,40 @@ fun PdfViewerScreen( val onInsertPage: () -> Unit = { coroutineScope.launch { - val targetIndex = (currentPage + 1).coerceIn(0, virtualPages.size) + val activeBookId = currentBookId ?: return@launch + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.insert.request bookId=$activeBookId loadedLayoutBookId=$loadedPageLayoutBookId " + + "isReady=$isDocumentReady mutation=$pageLayoutMutationVersion currentPage=$currentPage " + + "totalPdfPages=$totalPages displayMode=$displayMode current=${virtualPages.pdfLayoutDebugSummary()}" + ) + if (!canManagePdfVirtualPages( + isDocumentReady = isDocumentReady, + currentBookId = activeBookId, + loadedPageLayoutBookId = loadedPageLayoutBookId, + virtualPageCount = virtualPages.size + ) + ) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "ui.insert.blocked bookId=$activeBookId loadedLayoutBookId=$loadedPageLayoutBookId " + + "isReady=$isDocumentReady virtualCount=${virtualPages.size}" + ) + Timber.tag("RichTextMigration").w("INSERT: Ignoring page insert until saved layout is loaded.") + return@launch + } + val layoutBeforeInsert = virtualPages.ifEmpty { + (0 until totalPages).map { VirtualPage.PdfPage(it) } + } + val targetIndex = (currentPage + 1).coerceIn(0, layoutBeforeInsert.size) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.insert.target bookId=$activeBookId targetIndex=$targetIndex before=${layoutBeforeInsert.pdfLayoutDebugSummary()}" + ) Timber.tag("RichTextMigration").i("INSERT: User requested blank page at index $targetIndex") + pageLayoutMutationVersion++ val (refWidth, refHeight) = withContext(Dispatchers.IO) { - if (virtualPages.isNotEmpty()) { - val refIndex = (currentPage).coerceIn(0, virtualPages.size - 1) - when (val vp = virtualPages[refIndex]) { + if (layoutBeforeInsert.isNotEmpty()) { + val refIndex = (currentPage).coerceIn(0, layoutBeforeInsert.size - 1) + when (val vp = layoutBeforeInsert[refIndex]) { is VirtualPage.PdfPage -> { var w = 595 var h = 842 @@ -1605,44 +1714,61 @@ fun PdfViewerScreen( } } - if (currentBookId != null) { - val shiftedBoxes = textBoxes.map { box -> - if (box.pageIndex >= targetIndex) { - box.copy(pageIndex = box.pageIndex + 1) - } else { - box - } - } - if (shiftedBoxes != textBoxes) { + run { + val annotationsBeforeInsert = allAnnotations + val undoStackBeforeInsert = undoStack.toList() + val redoStackBeforeInsert = redoStack.toList() + val tempNewPage = VirtualPage.BlankPage(generateShortId(), refWidth, refHeight, wasManuallyAdded = true) + val optimisticPages = layoutBeforeInsert.toMutableList() + optimisticPages.add(targetIndex, tempNewPage) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.insert.optimistic bookId=$activeBookId targetIndex=$targetIndex " + + "newBlankId=${tempNewPage.id} ref=${refWidth}x$refHeight " + + "optimistic=${optimisticPages.pdfLayoutDebugSummary()}" + ) + + allAnnotations = remapPdfAnnotationsForLayoutChange( + currentLayout = layoutBeforeInsert, + updatedLayout = optimisticPages, + annotations = annotationsBeforeInsert + ) + val shiftedBoxes = remapPdfTextBoxesForLayoutChange( + currentLayout = layoutBeforeInsert, + updatedLayout = optimisticPages, + textBoxes = textBoxes + ) + if (shiftedBoxes != textBoxes.toList()) { textBoxes.clear() textBoxes.addAll(shiftedBoxes) } - val shiftedHighlights = userHighlights.map { highlight -> - if (highlight.pageIndex >= targetIndex) { - highlight.copy(pageIndex = highlight.pageIndex + 1) - } else { - highlight - } - } + val shiftedHighlights = remapPdfUserHighlightsForLayoutChange( + currentLayout = layoutBeforeInsert, + updatedLayout = optimisticPages, + highlights = userHighlights + ) if (shiftedHighlights != userHighlights.toList()) { userHighlights.clear() userHighlights.addAll(shiftedHighlights) } - - val tempNewPage = VirtualPage.BlankPage(generateShortId(), refWidth, refHeight, wasManuallyAdded = true) - val optimisticPages = virtualPages.toMutableList() - optimisticPages.add(targetIndex, tempNewPage) + undoStack.clear() + undoStack.addAll( + remapPdfHistoryActionsForLayoutChange( + currentLayout = layoutBeforeInsert, + updatedLayout = optimisticPages, + actions = undoStackBeforeInsert + ) + ) + redoStack.clear() + redoStack.addAll( + remapPdfHistoryActionsForLayoutChange( + currentLayout = layoutBeforeInsert, + updatedLayout = optimisticPages, + actions = redoStackBeforeInsert + ) + ) virtualPages = optimisticPages - if (displayMode == DisplayMode.PAGINATION) { - pagerState.animateScrollToPage(targetIndex) - } else { - verticalReaderState.scrollToPage(targetIndex) - } - - Timber.tag("RichTextMigration").d("INSERT: Triggering RichTextController.insertPageBreakAt($targetIndex, count=2)") - richTextController?.insertPageBreakAt(targetIndex, count = 2) val objectList = bookmarks.map { bookmark -> JSONObject().apply { @@ -1653,25 +1779,66 @@ fun PdfViewerScreen( } val currentJson = JSONArray(objectList).toString() - val result = viewModel.addPage( - bookId = currentBookId!!, - currentLayout = virtualPages - tempNewPage, - insertIndex = targetIndex, - currentAnnotations = allAnnotations, - currentBookmarksJson = currentJson, - referenceWidth = refWidth, - referenceHeight = refHeight, - wasManuallyAdded = true - ) + val result = withContext(NonCancellable) { + val savedResult = viewModel.addPage( + bookId = activeBookId, + currentLayout = layoutBeforeInsert, + insertIndex = targetIndex, + currentAnnotations = annotationsBeforeInsert, + currentBookmarksJson = currentJson, + referenceWidth = refWidth, + referenceHeight = refHeight, + blankPageId = tempNewPage.id, + wasManuallyAdded = true + ) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.insert.saved bookId=$activeBookId targetIndex=$targetIndex " + + "result=${savedResult.layout.pdfLayoutDebugSummary()}" + ) + richTextController?.remapPagesForLayoutChange( + currentLayout = layoutBeforeInsert, + updatedLayout = savedResult.layout + ) + savedResult + } Timber.tag("RichTextMigration").i("INSERT: Layout update complete. New virtualPages size: ${result.layout.size}") virtualPages = result.layout allAnnotations = result.annotations + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.insert.applied bookId=$activeBookId mutation=$pageLayoutMutationVersion " + + "virtual=${virtualPages.pdfLayoutDebugSummary()}" + ) + val remappedUndoStack = remapPdfHistoryActionsForLayoutChange( + currentLayout = optimisticPages, + updatedLayout = result.layout, + actions = undoStack + ) + undoStack.clear() + undoStack.addAll(remappedUndoStack) + val remappedRedoStack = remapPdfHistoryActionsForLayoutChange( + currentLayout = optimisticPages, + updatedLayout = result.layout, + actions = redoStack + ) + redoStack.clear() + redoStack.addAll(remappedRedoStack) bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) showBanner("Page added at ${targetIndex + 1}") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.insert.scroll.start bookId=$activeBookId targetIndex=$targetIndex displayMode=$displayMode" + ) + if (displayMode == DisplayMode.PAGINATION) { + pagerState.animateScrollToPage(targetIndex) + } else { + verticalReaderState.scrollToPage(targetIndex) + } + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.insert.scroll.done bookId=$activeBookId targetIndex=$targetIndex displayMode=$displayMode" + ) } } } @@ -1708,30 +1875,36 @@ fun PdfViewerScreen( val onDeletePage: () -> Unit = { coroutineScope.launch { - if (currentBookId != null && currentPage in virtualPages.indices) { + val activeBookId = currentBookId ?: return@launch + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.delete.request bookId=$activeBookId loadedLayoutBookId=$loadedPageLayoutBookId " + + "isReady=$isDocumentReady mutation=$pageLayoutMutationVersion currentPage=$currentPage " + + "totalPdfPages=$totalPages displayMode=$displayMode current=${virtualPages.pdfLayoutDebugSummary()}" + ) + if (!canManagePdfVirtualPages( + isDocumentReady = isDocumentReady, + currentBookId = activeBookId, + loadedPageLayoutBookId = loadedPageLayoutBookId, + virtualPageCount = virtualPages.size + ) + ) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "ui.delete.blocked bookId=$activeBookId loadedLayoutBookId=$loadedPageLayoutBookId " + + "isReady=$isDocumentReady virtualCount=${virtualPages.size}" + ) + Timber.tag("RichTextMigration").w("DELETE: Ignoring page delete until saved layout is loaded.") + return@launch + } + val layoutBeforeDelete = virtualPages.ifEmpty { + (0 until totalPages).map { VirtualPage.PdfPage(it) } + } + if (currentPage in layoutBeforeDelete.indices) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.delete.target bookId=$activeBookId removeIndex=$currentPage " + + "before=${layoutBeforeDelete.pdfLayoutDebugSummary()}" + ) Timber.tag("RichTextMigration").i("DELETE: User requested deletion of page at index $currentPage") - - val boxesToKeep = textBoxes.filter { it.pageIndex != currentPage } - val shiftedBoxes = boxesToKeep.map { box -> - if (box.pageIndex > currentPage) { - box.copy(pageIndex = box.pageIndex - 1) - } else { - box - } - } - textBoxes.clear() - textBoxes.addAll(shiftedBoxes) - - val highlightsToKeep = userHighlights.filter { it.pageIndex != currentPage } - val shiftedHighlights = highlightsToKeep.map { highlight -> - if (highlight.pageIndex > currentPage) { - highlight.copy(pageIndex = highlight.pageIndex - 1) - } else { - highlight - } - } - userHighlights.clear() - userHighlights.addAll(shiftedHighlights) + pageLayoutMutationVersion++ val objectList = bookmarks.map { bookmark -> JSONObject().apply { @@ -1742,20 +1915,56 @@ fun PdfViewerScreen( } val currentJson = JSONArray(objectList).toString() - val cleanedAnnotations = allAnnotations.filterKeys { it != currentPage } - - allAnnotations = cleanedAnnotations - - Timber.tag("RichTextMigration").d("DELETE: Wiping text and structural breaks for page $currentPage") - richTextController?.deleteTextOnPage(currentPage) - - val result = viewModel.removePage( - currentBookId!!, virtualPages, currentPage, cleanedAnnotations, currentJson - ) + val result = withContext(NonCancellable) { + val savedResult = viewModel.removePage( + activeBookId, layoutBeforeDelete, currentPage, allAnnotations, currentJson + ) + richTextController?.remapPagesForLayoutChange( + currentLayout = layoutBeforeDelete, + updatedLayout = savedResult.layout + ) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.delete.saved bookId=$activeBookId removeIndex=$currentPage " + + "result=${savedResult.layout.pdfLayoutDebugSummary()}" + ) + savedResult + } Timber.tag("RichTextMigration").i("DELETE: Layout update complete. New virtualPages size: ${result.layout.size}") + val shiftedBoxes = remapPdfTextBoxesForLayoutChange( + currentLayout = layoutBeforeDelete, + updatedLayout = result.layout, + textBoxes = textBoxes + ) + textBoxes.clear() + textBoxes.addAll(shiftedBoxes) + val shiftedHighlights = remapPdfUserHighlightsForLayoutChange( + currentLayout = layoutBeforeDelete, + updatedLayout = result.layout, + highlights = userHighlights + ) + userHighlights.clear() + userHighlights.addAll(shiftedHighlights) virtualPages = result.layout allAnnotations = result.annotations + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.delete.applied bookId=$activeBookId mutation=$pageLayoutMutationVersion " + + "virtual=${virtualPages.pdfLayoutDebugSummary()}" + ) + val remappedUndoStack = remapPdfHistoryActionsForLayoutChange( + currentLayout = layoutBeforeDelete, + updatedLayout = result.layout, + actions = undoStack + ) + undoStack.clear() + undoStack.addAll(remappedUndoStack) + val remappedRedoStack = remapPdfHistoryActionsForLayoutChange( + currentLayout = layoutBeforeDelete, + updatedLayout = result.layout, + actions = redoStack + ) + redoStack.clear() + redoStack.addAll(remappedRedoStack) bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) @@ -1764,17 +1973,21 @@ fun PdfViewerScreen( val newMax = (virtualPages.size - 1).coerceAtLeast(0) if (currentPage > newMax) { if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage(newMax) + scrollPaginationToDisplayPage(newMax) } else { verticalReaderState.scrollToPage(newMax) } } + } else { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "ui.delete.invalidIndex bookId=$activeBookId removeIndex=$currentPage before=${layoutBeforeDelete.pdfLayoutDebugSummary()}" + ) } } } val onInsertTextBox = { - val currentP = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val currentP = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage Timber.tag("PdfTextBoxDebug").d("Viewer: onInsertTextBox triggered. Target Page: $currentP, DisplayMode: $displayMode") @@ -1852,8 +2065,24 @@ fun PdfViewerScreen( } } - LaunchedEffect(highestRequiredTextPageIndex, virtualPages.size, allAnnotations) { - if (richTextController == null || !isDocumentReady) return@LaunchedEffect + LaunchedEffect(highestRequiredTextPageIndex, virtualPages.size, allAnnotations, loadedPageLayoutBookId) { + val activeBookId = currentBookId + if ( + richTextController == null || + !canManagePdfVirtualPages( + isDocumentReady = isDocumentReady, + currentBookId = activeBookId, + loadedPageLayoutBookId = loadedPageLayoutBookId, + virtualPageCount = virtualPages.size + ) + ) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).d( + "ui.autoPage.skip bookId=$activeBookId hasRichController=${richTextController != null} " + + "isReady=$isDocumentReady loadedLayoutBookId=$loadedPageLayoutBookId " + + "virtualCount=${virtualPages.size} highestRequired=$highestRequiredTextPageIndex" + ) + return@LaunchedEffect + } delay(500) @@ -1863,6 +2092,10 @@ fun PdfViewerScreen( // Expansion Logic if (requiredPages > virtualPages.size) { Timber.tag("RichTextFlow").i("Text overflow detected. Required pages: $requiredPages, current: ${virtualPages.size}. Adding page.") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.autoPage.expand.start bookId=$activeBookId requiredPages=$requiredPages " + + "current=${virtualPages.pdfLayoutDebugSummary()}" + ) val lastPage = virtualPages.lastOrNull() val (refWidth, refHeight) = when(lastPage) { @@ -1888,7 +2121,7 @@ fun PdfViewerScreen( val currentJson = JSONArray(objectList).toString() val result = viewModel.addPage( - bookId = currentBookId!!, + bookId = activeBookId!!, currentLayout = virtualPages, insertIndex = virtualPages.size, currentAnnotations = allAnnotations, @@ -1897,11 +2130,16 @@ fun PdfViewerScreen( referenceHeight = refHeight, wasManuallyAdded = false // Auto-added page ) + pageLayoutMutationVersion++ virtualPages = result.layout allAnnotations = result.annotations bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.autoPage.expand.done bookId=$activeBookId mutation=$pageLayoutMutationVersion " + + "result=${virtualPages.pdfLayoutDebugSummary()}" + ) } // Contraction Logic else { @@ -1919,6 +2157,10 @@ fun PdfViewerScreen( userHighlights.none { it.pageIndex == currentLastIndex } ) { Timber.tag("RichTextFlow").i("Auto-pruning empty page at index $currentLastIndex.") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.autoPage.prune.start bookId=$activeBookId removeIndex=$currentLastIndex " + + "highestRequired=$highestRequiredTextPageIndex before=${virtualPages.pdfLayoutDebugSummary()}" + ) pageRemoved = true val objectList = bookmarks.map { @@ -1931,13 +2173,18 @@ fun PdfViewerScreen( val currentJson = JSONArray(objectList).toString() val result = viewModel.removePage( - currentBookId!!, virtualPages, currentLastIndex, allAnnotations, currentJson + activeBookId!!, virtualPages, currentLastIndex, allAnnotations, currentJson ) + pageLayoutMutationVersion++ virtualPages = result.layout allAnnotations = result.annotations bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.autoPage.prune.done bookId=$activeBookId mutation=$pageLayoutMutationVersion " + + "result=${virtualPages.pdfLayoutDebugSummary()}" + ) currentLastIndex-- lastPage = virtualPages.getOrNull(currentLastIndex) @@ -1962,8 +2209,8 @@ fun PdfViewerScreen( try { when (displayMode) { DisplayMode.PAGINATION -> { - if (pagerState.currentPage != targetPage) { - pagerState.scrollToPage(targetPage) + if (currentPaginationDisplayPage() != targetPage) { + scrollPaginationToDisplayPage(targetPage) } } DisplayMode.VERTICAL_SCROLL -> { @@ -1998,19 +2245,47 @@ fun PdfViewerScreen( } } - LaunchedEffect(isDocumentReady, currentBookId) { - if (isDocumentReady && currentBookId != null && totalPages > 0) { - val layout = viewModel.loadPageLayout(currentBookId!!, totalPages) + LaunchedEffect(isDocumentReady, currentBookId, totalPages) { + val loadingBookId = currentBookId + if (isDocumentReady && loadingBookId != null && totalPages > 0) { + val loadMutationVersion = pageLayoutMutationVersion + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.layoutLoad.start bookId=$loadingBookId totalPdfPages=$totalPages " + + "mutationAtStart=$loadMutationVersion currentMutation=$pageLayoutMutationVersion " + + "loadedLayoutBookId=$loadedPageLayoutBookId current=${virtualPages.pdfLayoutDebugSummary()}" + ) + val layout = viewModel.loadPageLayout(loadingBookId, totalPages) + if (currentBookId != loadingBookId || loadMutationVersion != pageLayoutMutationVersion) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "ui.layoutLoad.stale bookId=$loadingBookId currentBookId=$currentBookId " + + "mutationAtStart=$loadMutationVersion currentMutation=$pageLayoutMutationVersion " + + "loaded=${layout.pdfLayoutDebugSummary()}" + ) + Timber.tag("RichTextMigration").w( + "Skipping stale page layout load for $loadingBookId; mutation version changed." + ) + return@LaunchedEffect + } virtualPages = layout + loadedPageLayoutBookId = loadingBookId + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.layoutLoad.applied bookId=$loadingBookId loadedLayoutBookId=$loadedPageLayoutBookId " + + "mutation=$pageLayoutMutationVersion layout=${layout.pdfLayoutDebugSummary()}" + ) if (initialPage != null && initialPage >= totalPages && initialPage < layout.size) { Timber.d("Restoring position to added page: $initialPage") if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage(initialPage) + scrollPaginationToDisplayPage(initialPage) } else { verticalReaderState.scrollToPage(initialPage) } } + } else { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).d( + "ui.layoutLoad.skip isReady=$isDocumentReady bookId=$loadingBookId totalPdfPages=$totalPages " + + "loadedLayoutBookId=$loadedPageLayoutBookId current=${virtualPages.pdfLayoutDebugSummary()}" + ) } } @@ -2021,7 +2296,7 @@ fun PdfViewerScreen( LaunchedEffect(displayMode) { if (initialScrollDone) { if (displayMode == DisplayMode.VERTICAL_SCROLL) { - val pageToScroll = pagerState.currentPage + val pageToScroll = currentPaginationDisplayPage() var attempts = 0 while (verticalReaderState.snapToPageHandler == null && attempts < 50) { @@ -2031,17 +2306,17 @@ fun PdfViewerScreen( verticalReaderState.snapToPage(pageToScroll) } else { val pageToScroll = verticalReaderState.currentPage - pagerState.scrollToPage(pageToScroll) + scrollPaginationToDisplayPage(pageToScroll) } } } val isBookmarked by remember( - bookmarks, pagerState.currentPage, verticalReaderState.currentPage, displayMode + bookmarks, currentPage, verticalReaderState.currentPage, displayMode ) { derivedStateOf { val currentPage = if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage + currentPaginationDisplayPage() } else { verticalReaderState.currentPage } @@ -2158,7 +2433,7 @@ fun PdfViewerScreen( val onBookmarkClick: () -> Unit = { val currentPage = if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage + currentPaginationDisplayPage() } else { verticalReaderState.currentPage } @@ -2167,19 +2442,31 @@ fun PdfViewerScreen( LaunchedEffect(currentBookId) { val loadingBookId = currentBookId + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.sidecarLoad.start bookId=$loadingBookId previousLoadedLayoutBookId=$loadedPageLayoutBookId " + + "previousVirtual=${virtualPages.pdfLayoutDebugSummary()}" + ) areAnnotationsLoaded = false loadedSidecarBookId = null allAnnotations = emptyMap() textBoxes.clear() userHighlights.clear() + virtualPages = emptyList() + loadedPageLayoutBookId = null + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.sidecarLoad.reset bookId=$loadingBookId virtualCleared=true loadedLayoutBookId=$loadedPageLayoutBookId" + ) selectedTextBoxId = null undoStack.clear() redoStack.clear() erasedAnnotationsFromStroke.clear() drawingState.onDrawCancel() - if (loadingBookId == null) return@LaunchedEffect + if (loadingBookId == null) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i("ui.sidecarLoad.noBook") + return@LaunchedEffect + } val loaded = annotationRepository.loadAnnotations(loadingBookId) val loadedBoxes = textBoxRepository.loadTextBoxes(loadingBookId) @@ -2192,6 +2479,10 @@ fun PdfViewerScreen( userHighlights.addAll(loadedHighlights) loadedSidecarBookId = loadingBookId areAnnotationsLoaded = true + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.sidecarLoad.done bookId=$loadingBookId annotationPages=${loaded.keys.sorted()} " + + "textBoxes=${loadedBoxes.size} highlights=${loadedHighlights.size}" + ) } var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) } @@ -2348,12 +2639,28 @@ fun PdfViewerScreen( var summarizationResult by remember { mutableStateOf(null) } var isSummarizationLoading by remember { mutableStateOf(false) } - var isPageSliderVisible by remember { mutableStateOf(false) } + var isPageSliderVisible by remember(bookId) { + mutableStateOf(loadReaderSliderToggled(context, bookId)) + } var sliderStartPage by remember { mutableIntStateOf(0) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var isFastScrubbing by remember { mutableStateOf(false) } val scrubDebounceJob = remember { mutableStateOf(null) } var startPageThumbnail by remember { mutableStateOf(null) } + val pdfSliderChromeVisible = shouldRenderReaderSlider( + isToggledOn = isPageSliderVisible, + isBottomChromeVisible = showStandardBars, + isSearchActive = searchState.isSearchActive + ) + + LaunchedEffect(bookId, isPageSliderVisible) { + saveReaderSliderToggled(context, bookId, isPageSliderVisible) + if (isPageSliderVisible) { + val position = readerSliderBookmarkPosition(currentPage) + sliderStartPage = position.startPage + sliderCurrentPage = position.currentPage + } + } val speakerPlayer = remember(context, coroutineScope) { SpeakerSamplePlayer( @@ -2497,14 +2804,14 @@ fun PdfViewerScreen( { targetPage: Int -> coroutineScope.launch { if (targetPage in 0 until totalPages) { - val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val current = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage if (current != targetPage) { recordJumpHistory(current, targetPage) } if (displayMode == DisplayMode.PAGINATION) { - pagerState.animateScrollToPage(targetPage) + animatePaginationToDisplayPage(targetPage) } else { verticalReaderState.scrollToPage(targetPage) } @@ -2862,26 +3169,58 @@ fun PdfViewerScreen( ttsDisplayPageIndex = displayPageForTts val cleanStartIndex = if (startCharIndex != null && startCharIndex >= 0) { - processedText.indexMap.indexOfFirst { it >= startCharIndex }.coerceAtLeast(0) + val mappedIndex = processedText.indexMap.indexOfFirst { it >= startCharIndex } + if (mappedIndex >= 0) mappedIndex else processedText.cleanText.lastIndex.coerceAtLeast(0) } else { 0 } - val textToChunk = processedText.cleanText.substring(cleanStartIndex) - - val chunks = splitTextIntoChunks(textToChunk) + val chunks = splitTextIntoChunks(processedText.cleanText) + val chunkStartOffsets = mutableListOf() + var searchIndex = 0 + chunks.forEach { chunk -> + val foundIndex = processedText.cleanText.indexOf(chunk, searchIndex) + .takeIf { it >= 0 } + ?: searchIndex + chunkStartOffsets.add(foundIndex) + searchIndex = foundIndex + chunk.length + } + var startChunkIndex = 0 + for (index in chunks.indices) { + val chunkStart = chunkStartOffsets.getOrNull(index) ?: 0 + val chunkEnd = chunkStart + chunks[index].length + if (cleanStartIndex >= chunkStart && cleanStartIndex < chunkEnd) { + startChunkIndex = index + break + } + if (cleanStartIndex < chunkStart) { + startChunkIndex = index + break + } + } val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() } ?: effectivePdfUri.lastPathSegment ?: context.getString(R.string.default_document_title) val pageTitle = context.getString(R.string.pdf_page_short, pageToRead + 1) - val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) } + val ttsChunks = chunks.mapIndexed { index, text -> + val chunkStart = chunkStartOffsets.getOrNull(index) ?: 0 + val textForChunk = if (index == startChunkIndex && cleanStartIndex > chunkStart) { + text.substring((cleanStartIndex - chunkStart).coerceIn(0, text.length)) + } else { + text + } + TtsChunk(textForChunk, "", index) + } ttsController.start( chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = bookTitle, chapterTitle = pageTitle, coverImageUri = null, + bookId = bookId, + pageIndex = displayPageForTts, + startChunkIndex = startChunkIndex, continueSession = continueSession, ttsMode = currentTtsMode, playbackSource = "READER", @@ -2995,8 +3334,18 @@ fun PdfViewerScreen( } } - LaunchedEffect(isPageSliderVisible) { - if (isPageSliderVisible) { + LaunchedEffect(isPageSliderVisible, pdfSliderChromeVisible, currentPage) { + if (isPageSliderVisible && !pdfSliderChromeVisible) { + val position = readerSliderBookmarkPosition(currentPage) + sliderStartPage = position.startPage + sliderCurrentPage = position.currentPage + } + } + + LaunchedEffect(pdfSliderChromeVisible, sliderStartPage, pdfDocument, totalPages) { + startPageThumbnail?.recycle() + startPageThumbnail = null + if (pdfSliderChromeVisible) { val doc = pdfDocument if (doc != null && totalPages > 0) { Timber.d("Slider visible. Rendering thumbnail for page $sliderStartPage") @@ -3007,8 +3356,6 @@ fun PdfViewerScreen( } } else { Timber.d("Slider hidden. Clearing thumbnail.") - startPageThumbnail?.recycle() - startPageThumbnail = null } } @@ -3068,6 +3415,11 @@ fun PdfViewerScreen( LaunchedEffect(effectivePdfUri, pdfiumCore, documentPassword) { Timber.tag("PdfTabSync").i("UI: LaunchedEffect triggered by URI change: $effectivePdfUri") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.start uri=$effectivePdfUri scheme=${effectivePdfUri.scheme} " + + "selectedBookId=${uiState.selectedBookId} previousBookId=$currentBookId " + + "documentPasswordSet=${documentPassword != null}" + ) Timber.tag("PdfTabSync").d("UI: Loading State -> activeTabBookId: ${uiState.activeTabBookId}, isLoading: $isLoadingDocument") @@ -3083,6 +3435,11 @@ fun PdfViewerScreen( allAnnotations = emptyMap() textBoxes.clear() userHighlights.clear() + virtualPages = emptyList() + loadedPageLayoutBookId = null + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.reset uri=$effectivePdfUri virtualCleared=true loadedLayoutBookId=$loadedPageLayoutBookId" + ) selectedTextBoxId = null undoStack.clear() redoStack.clear() @@ -3091,23 +3448,54 @@ fun PdfViewerScreen( if (showPasswordDialog) isPasswordError = false - ttsController.stop() ocrUsedForCurrentPageTts = false flatTableOfContents = emptyList() val fastId = getFastFileId(context, effectivePdfUri) val selectedId = uiState.selectedBookId + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.ids uri=$effectivePdfUri fastId=$fastId selectedId=$selectedId activeTabBookId=${uiState.activeTabBookId}" + ) + val shouldPreserveCurrentTtsSession = + uiState.isOpeningFromTtsNotification || + ( + ttsState.playbackSource == "READER" && + !ttsState.bookId.isNullOrBlank() && + ttsState.bookId == selectedId + ) + + if (!shouldPreserveCurrentTtsSession) { + ttsController.stop() + } if (selectedId != null && selectedId != fastId) { Timber.tag("FolderAnnotationSync").i("Detected ID mismatch. Legacy: $fastId, Selected: $selectedId. Initiating migration.") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.migrateFastToSelected legacyId=$fastId selectedId=$selectedId" + ) viewModel.checkAndMigrateLegacyBookId(fastId, selectedId) currentBookId = selectedId } else { currentBookId = fastId } + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.activeId uri=$effectivePdfUri currentBookId=$currentBookId" + ) - val cachedItem = documentCache.get(currentBookId!!) + val activeBookIdForLoad = currentBookId!! + val rawUriBookId = effectivePdfUri.toString() + if (rawUriBookId != activeBookIdForLoad) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.migrateRawUri legacyId=$rawUriBookId activeId=$activeBookIdForLoad" + ) + viewModel.checkAndMigrateLegacyBookId(rawUriBookId, activeBookIdForLoad) + } + + val cachedItem = documentCache.get(activeBookIdForLoad) if (cachedItem != null) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.cacheHit bookId=$activeBookIdForLoad cachedTotalPages=${cachedItem.totalPages}" + ) Timber.tag("PdfTabSync").i("UI: Restoring from cache for $currentBookId") pdfDocument = cachedItem.doc pfdState = cachedItem.pfd @@ -3117,41 +3505,49 @@ fun PdfViewerScreen( val mapPage = tabStateMap[currentBookId!!] val uiPage = uiState.initialPageInBook + val restorePage = if (uiState.initialPageInBookIsExplicit) { + uiPage ?: mapPage ?: initialPage + } else { + mapPage ?: uiPage ?: initialPage + } Timber.tag("PdfTabSync").d("UI: Restoring position | tabStateMap=$mapPage, uiState=$uiPage, initialPage=$initialPage") - pendingRestorePage = mapPage ?: uiPage ?: initialPage + pendingRestorePage = restorePage initialScrollDone = false isDocumentReady = true isLoadingDocument = false + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.cacheReady bookId=$activeBookIdForLoad totalPdfPages=$totalPages " + + "isReady=$isDocumentReady virtual=${virtualPages.pdfLayoutDebugSummary()}" + ) return@LaunchedEffect } + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i("ui.open.cacheMiss bookId=$activeBookIdForLoad") val mapPageInit = tabStateMap[currentBookId!!] val uiPageInit = uiState.initialPageInBook + val restorePageInit = if (uiState.initialPageInBookIsExplicit) { + uiPageInit ?: mapPageInit ?: initialPage + } else { + mapPageInit ?: uiPageInit ?: initialPage + } Timber.tag("PdfTabSync").d("UI: Initial position | tabStateMap=$mapPageInit, uiState=$uiPageInit, initialPage=$initialPage") - pendingRestorePage = mapPageInit ?: uiPageInit ?: initialPage + pendingRestorePage = restorePageInit initialScrollDone = false pdfDocument = null pfdState = null totalPages = 0 - var currentPfdOpened: ParcelFileDescriptor? = null try { withContext(Dispatchers.IO) { - Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri") + Timber.tag("PdfTabSync").v("UI: Opening document for $effectivePdfUri") val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF - if (pdfUri.scheme != "opds-pse" && selectedDocumentType == FileType.PDF) { - currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r") - if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor") - } - val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore) if (!isActive) { doc.close() - currentPfdOpened?.close() return@withContext } @@ -3161,8 +3557,12 @@ fun PdfViewerScreen( wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() } } } - pfdState = currentPfdOpened + pfdState = null val pagesCount = doc.getPageCount() + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.documentLoaded bookId=$currentBookId uri=$effectivePdfUri pagesCount=$pagesCount " + + "docType=$selectedDocumentType" + ) if (pagesCount > 0) { try { @@ -3175,6 +3575,9 @@ fun PdfViewerScreen( } totalPages = pagesCount + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.totalPagesSet bookId=$currentBookId totalPdfPages=$totalPages" + ) if (pagesCount > 0) { val cachedRatios = pdfTextRepository.getPageRatios(currentBookId!!) @@ -3232,12 +3635,17 @@ fun PdfViewerScreen( pageAspectRatios = ratios isDocumentReady = true isLoadingDocument = false + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "ui.open.ready bookId=$currentBookId totalPdfPages=$totalPages " + + "isReady=$isDocumentReady virtual=${virtualPages.pdfLayoutDebugSummary()} " + + "loadedLayoutBookId=$loadedPageLayoutBookId" + ) documentCache.put( currentBookId!!, DocumentCacheItem( doc = doc, - pfd = currentPfdOpened, + pfd = null, totalPages = pagesCount, pageAspectRatios = ratios, flatTableOfContents = flatTableOfContents @@ -3282,12 +3690,19 @@ fun PdfViewerScreen( } else { isDocumentReady = true isLoadingDocument = false + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "ui.open.readyZeroPages bookId=$currentBookId totalPdfPages=$totalPages" + ) } Timber.tag("PdfTabSync").v("UI: Pdfium Document created. Page count: $pagesCount") } } catch (e: Throwable) { if (e is CancellationException || e.javaClass.name.contains("CancellationException")) throw e + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e( + e, + "ui.open.failed uri=$effectivePdfUri currentBookId=$currentBookId totalPdfPages=$totalPages" + ) Timber.tag("PdfTabSync").e(e, "UI: Error in load effect for $effectivePdfUri") val errorString = e.toString() val causeString = e.cause?.toString() ?: "" @@ -3309,12 +3724,6 @@ fun PdfViewerScreen( isLoadingDocument = false } if (pdfDocument == null) { - currentPfdOpened?.let { - try { - it.close() - } catch (_: Exception) { - } - } pfdState = null } } @@ -3342,7 +3751,15 @@ fun PdfViewerScreen( summarizationResult = null } - LaunchedEffect(pagerState.currentPage, displayMode, isScrollLocked, lockedState) { + LaunchedEffect( + currentPage, + displayMode, + isScrollLocked, + lockedState, + totalDisplayPages, + pdfSpreadSettings.pageSpreadMode, + pdfSpreadSettings.pdfFirstPageStandaloneInSpread + ) { val nextPageScale = currentPageScaleAfterPdfPageChange( displayMode = displayMode, isScrollLocked = isScrollLocked, @@ -3350,9 +3767,46 @@ fun PdfViewerScreen( currentActiveScale = currentActiveScale ) currentPageScale = nextPageScale + val isCurrentTwoPageSpread = + displayMode == DisplayMode.PAGINATION && + PdfSpreadLayout.visiblePageIndices(currentPage, totalDisplayPages, pdfSpreadSettings).size > 1 + if (isCurrentTwoPageSpread) { + val currentLockedState = lockedState + val nextPageOffset = if (isScrollLocked && currentLockedState != null) { + Offset(currentLockedState.second, currentLockedState.third) + } else { + Offset.Zero + } + currentActiveScale = nextPageScale + currentActiveOffset = nextPageOffset + } else if (displayMode == DisplayMode.PAGINATION && !isScrollLocked) { + currentActiveScale = 1f + currentActiveOffset = Offset.Zero + } ocrUsedForCurrentPageTts = false } + LaunchedEffect(resetZoomTrigger) { + if ( + resetZoomTrigger != 0L && + displayMode == DisplayMode.PAGINATION && + PdfSpreadLayout.visiblePageIndices(currentPage, totalDisplayPages, pdfSpreadSettings).size > 1 && + currentActiveScale > 1f && + !isScrollLocked + ) { + val startScale = currentActiveScale + val startOffset = currentActiveOffset + Animatable(0f).animateTo(1f, animationSpec = tween(durationMillis = 300)) { + currentActiveScale = androidx.compose.ui.util.lerp(startScale, 1f, value) + currentActiveOffset = lerp(startOffset, Offset.Zero, value) + currentPageScale = currentActiveScale + } + currentActiveScale = 1f + currentActiveOffset = Offset.Zero + currentPageScale = 1f + } + } + DisposableEffect(Unit) { onDispose { Timber.d("DisposableEffect: Screen disposing. Closing PDF document and PFD.") @@ -3641,14 +4095,14 @@ fun PdfViewerScreen( val onInternalLinkNav: (Int) -> Unit = { targetPage -> coroutineScope.launch { if (targetPage in 0 until totalPages) { - val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val current = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage if (current != targetPage) { recordJumpHistory(current, targetPage) } if (displayMode == DisplayMode.PAGINATION) { - pagerState.animateScrollToPage(targetPage) + animatePaginationToDisplayPage(targetPage) } else { verticalReaderState.scrollToPage(targetPage) } @@ -3663,10 +4117,11 @@ fun PdfViewerScreen( val dynamicBeyondViewportPageCount = remember( paginationDraggingOriginPage, - pagerState.currentPage + currentPaginationDisplayPage() ) { if (paginationDraggingOriginPage != null) { - val distance = abs(pagerState.currentPage - paginationDraggingOriginPage) + val originPagerPage = paginationPagerPageForDisplayPage(paginationDraggingOriginPage) + val distance = abs(pagerState.currentPage - originPagerPage) (distance + 1).coerceAtLeast(1) } else { 1 @@ -3679,15 +4134,15 @@ fun PdfViewerScreen( coroutineScope.launch { val targetPage = result.locationInSource - val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val current = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage if (current != targetPage) { recordJumpHistory(current, targetPage) } if (displayMode == DisplayMode.PAGINATION) { - if (pagerState.currentPage != targetPage) { - pagerState.scrollToPage(targetPage) + if (currentPaginationDisplayPage() != targetPage) { + scrollPaginationToDisplayPage(targetPage) } } else { verticalReaderState.scrollToPage(targetPage) @@ -3736,10 +4191,6 @@ fun PdfViewerScreen( showAiDefinitionPopup -> showAiDefinitionPopup = false showDictionaryUpsellDialog -> showDictionaryUpsellDialog = false showCustomizeToolsSheet -> showCustomizeToolsSheet = false - isPageSliderVisible -> { - isPageSliderVisible = false - showBars = true - } searchState.isSearchActive -> { searchState.isSearchActive = false @@ -3767,20 +4218,22 @@ fun PdfViewerScreen( userHighlights = visibleUserHighlights, currentPage = currentPage, totalPages = totalDisplayPages, - isTabsEnabled = isPdfTabStripVisible, + isTabsEnabled = canShowPdfTabs, openTabs = openTabs, activeTabBookId = activeTabBookId, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, + isTopTabStripVisible = showTopTabStrip, customHighlightColors = customHighlightColors, onPageSelected = { targetPage -> coroutineScope.launch { - val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val current = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage if (current != targetPage) { recordJumpHistory(current, targetPage) } if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage(targetPage) + scrollPaginationToDisplayPage(targetPage) } else { verticalReaderState.scrollToPage(targetPage) } @@ -3809,6 +4262,10 @@ fun PdfViewerScreen( showNewTabSheet = true } }, + onTopTabStripVisibilityChange = { isVisible -> + showTopTabStrip = isVisible + savePdfTopTabStripVisible(context, isVisible) + }, onRenameBookmark = { bookmarkToRename, newTitle -> if (newTitle.isNotBlank()) { val updatedBookmark = bookmarkToRename.copy(title = newTitle) @@ -3976,11 +4433,13 @@ fun PdfViewerScreen( } } - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().clipToBounds()) { HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxSize(), - key = { page -> "$activeDocumentRenderKey:$page" }, + modifier = Modifier.fillMaxSize().clipToBounds(), + key = { page -> + "$activeDocumentRenderKey:${pdfSpreadSettings.pageSpreadMode}:${pdfSpreadSettings.pdfFirstPageStandaloneInSpread}:$page:${paginationDisplayPageForPagerPage(page)}" + }, beyondViewportPageCount = dynamicBeyondViewportPageCount, reverseLayout = rightToLeftPagination, userScrollEnabled = run { @@ -3989,10 +4448,399 @@ fun PdfViewerScreen( !isPageSliderVisible && paginationDraggingBoxId == null } - ) { pageIndex -> - val isVisiblePage = remember(pagerState.currentPage, pageIndex) { - abs(pagerState.currentPage - pageIndex) <= 1 + ) { pagerPageIndex -> + val spreadPageIndices = remember( + pagerPageIndex, + totalDisplayPages, + pdfSpreadSettings.pageSpreadMode, + pdfSpreadSettings.pdfFirstPageStandaloneInSpread + ) { + PdfSpreadLayout.visiblePageIndices( + pageIndex = paginationDisplayPageForPagerPage(pagerPageIndex), + pageCount = totalDisplayPages, + settings = pdfSpreadSettings + ) } + val isVisiblePage = remember(pagerState.currentPage, pagerPageIndex) { + abs(pagerState.currentPage - pagerPageIndex) <= 1 + } + val isActivePagerPage = pagerState.currentPage == pagerPageIndex + val useSharedSpreadZoom = spreadPageIndices.size > 1 + val latestSpreadScale = rememberUpdatedState(currentActiveScale) + val latestSpreadOffset = rememberUpdatedState(currentActiveOffset) + val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp + var spreadPanFlingJob by remember { mutableStateOf(null) } + Row( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + .then( + if (useSharedSpreadZoom) { + Modifier + .pointerInput( + useSharedSpreadZoom, + isDrawingActive, + isScrollLocked, + totalDisplayPages + ) { + if (!useSharedSpreadZoom || isDrawingActive) return@pointerInput + val oneHandZoomDistancePx = with(density) { + PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP.dp.toPx() + } + var oneHandZoomStartScale = 1f + var oneHandZoomStartOffset = Offset.Zero + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.detector.enabled scrollLocked=$isScrollLocked drawing=$isDrawingActive " + + "pages=$totalDisplayPages scale=${latestSpreadScale.value} offset=${latestSpreadOffset.value}" + ) + + fun spreadTargetOffset( + startScale: Float, + targetScale: Float, + startOffset: Offset, + pivot: Offset + ): Offset { + if (targetScale <= 1.1f) return Offset.Zero + val viewportSize = Size(size.width.toFloat(), size.height.toFloat()) + return centeredPdfCameraOffsetForScaleChange( + previousScale = startScale, + nextScale = targetScale, + previousOffset = startOffset, + pivot = pivot, + viewportSize = viewportSize, + contentSize = viewportSize + ) + } + + detectPdfTapAndOneHandZoomGestures( + viewConfiguration = viewConfiguration, + canStartOneHandZoom = { + useSharedSpreadZoom && !isDrawingActive && !isScrollLocked + }, + canHandleQuickDoubleTap = { !isScrollLocked }, + consumeSingleTap = false, + onTap = { offset -> + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.tap passthrough offset=$offset" + ) + }, + onQuickDoubleTap = quickDoubleTap@{ tapOffset -> + if (isScrollLocked) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.quickDoubleTap.blocked scrollLocked=true offset=$tapOffset" + ) + return@quickDoubleTap + } + val startScale = latestSpreadScale.value + val startOffset = latestSpreadOffset.value + val targetScale = if (startScale > 1.1f) 1f else 2.5f + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.quickDoubleTap offset=$tapOffset startScale=$startScale " + + "targetScale=$targetScale startOffset=$startOffset" + ) + val targetOffset = spreadTargetOffset( + startScale = startScale, + targetScale = targetScale, + startOffset = startOffset, + pivot = tapOffset + ) + coroutineScope.launch { + Animatable(0f).animateTo( + 1f, + animationSpec = tween(durationMillis = 300) + ) { + currentActiveScale = androidx.compose.ui.util.lerp( + startScale, + targetScale, + value + ) + currentActiveOffset = lerp( + startOffset, + targetOffset, + value + ) + currentPageScale = currentActiveScale + } + if (currentActiveScale <= 1.05f) { + currentActiveScale = 1f + currentActiveOffset = Offset.Zero + currentPageScale = 1f + } + } + }, + onOneHandZoomHoldStart = { _ -> + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.oneHandHoldStart scale=${latestSpreadScale.value} " + + "offset=${latestSpreadOffset.value}" + ) + spreadPanFlingJob?.cancel() + spreadPanFlingJob = null + oneHandZoomStartScale = latestSpreadScale.value + oneHandZoomStartOffset = latestSpreadOffset.value + }, + onOneHandZoom = { _, totalDragY -> + val viewportCenter = Offset(size.width / 2f, size.height / 2f) + val nextScale = pdfOneHandZoomScale( + startScale = oneHandZoomStartScale, + totalDragY = totalDragY, + dragDistanceForDoublePx = oneHandZoomDistancePx, + minScale = 1f, + maxScale = 4f + ) + currentActiveScale = nextScale + currentActiveOffset = spreadTargetOffset( + startScale = oneHandZoomStartScale, + targetScale = nextScale, + startOffset = oneHandZoomStartOffset, + pivot = viewportCenter + ) + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v( + "spread.oneHandUpdate dragY=$totalDragY startScale=$oneHandZoomStartScale " + + "nextScale=$nextScale offset=$currentActiveOffset" + ) + currentPageScale = currentActiveScale + }, + onOneHandZoomEnd = { _ -> + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.oneHandEnd scale=$currentActiveScale offset=$currentActiveOffset" + ) + if (currentActiveScale > 1f && currentActiveScale < 1.05f) { + currentActiveScale = 1f + currentActiveOffset = Offset.Zero + currentPageScale = 1f + } + } + ) + } + .pointerInput( + useSharedSpreadZoom, + isDrawingActive, + isScrollLocked, + totalDisplayPages + ) { + if (!useSharedSpreadZoom || isDrawingActive) return@pointerInput + val touchSlop = viewConfiguration.touchSlop + val decay = splineBasedDecay(this) + val velocityTracker = VelocityTracker() + + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + spreadPanFlingJob?.cancel() + spreadPanFlingJob = null + velocityTracker.resetTracking() + + var gestureScale = latestSpreadScale.value + var gestureOffset = latestSpreadOffset.value + var accumulatedZoom = 1f + var accumulatedPan = Offset.Zero + var velocityAccumulator = Offset.Zero + var mode = 0 + var hasConsumedGesture = false + + do { + val event = awaitPointerEvent() + val canceled = event.changes.any { it.isConsumed } + if (canceled) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.panDetector.canceledByConsumed mode=$mode scale=$gestureScale " + + "changes=${event.changes.joinToString { change -> + "pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}" + }}" + ) + } + if (!canceled) { + val pointerCount = event.changes.count { it.pressed } + val rawPanChange = event.calculatePan() + val panChange = if (isScrollLocked && pointerCount == 1) { + Offset.Zero + } else { + rawPanChange + } + val zoomChange = event.calculateZoom() + accumulatedZoom *= zoomChange + accumulatedPan += panChange + + if (gestureScale > 1f) { + if (mode == 0) { + mode = if (pointerCount > 1 && abs(accumulatedZoom - 1f) > 0.025f) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.panDetector.modeZoom scale=$gestureScale accumulatedZoom=$accumulatedZoom" + ) + 2 + } else if (accumulatedPan.getDistance() > touchSlop) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.panDetector.modePan scale=$gestureScale accumulatedPan=$accumulatedPan" + ) + 1 + } else { + 0 + } + } + + if (mode == 1 || mode == 2) { + val oldScale = gestureScale + val nextScale = if (mode == 2 && pointerCount > 1) { + (gestureScale * zoomChange).coerceIn(1f, 4f) + } else { + gestureScale + } + val ratio = if (oldScale == 0f) 1f else nextScale / oldScale + val previousCentroid = event.calculateCentroid(useCurrent = false) + val viewportCenter = Offset(size.width / 2f, size.height / 2f) + val nextOffset = if (mode == 2 && pointerCount > 1 && previousCentroid != Offset.Unspecified) { + gestureOffset * ratio + (previousCentroid - viewportCenter) * (1 - ratio) + panChange + } else { + gestureOffset + panChange + } + + gestureScale = nextScale + gestureOffset = clampPdfSpreadCameraOffset( + scale = gestureScale, + offset = nextOffset, + viewportWidth = size.width.toFloat(), + viewportHeight = size.height.toFloat() + ) + currentActiveScale = gestureScale + currentActiveOffset = gestureOffset + currentPageScale = gestureScale + hasConsumedGesture = true + if (mode == 1 && panChange != Offset.Zero && event.changes.isNotEmpty()) { + velocityAccumulator += panChange + velocityTracker.addPosition( + event.changes[0].uptimeMillis, + velocityAccumulator + ) + } + event.changes.forEach { + if (it.positionChanged()) it.consume() + } + } + } else if (pointerCount > 1) { + if (mode == 0) { + mode = if (abs(accumulatedZoom - 1f) > 0.025f) { + Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d( + "spread.panDetector.modeZoomAtBase accumulatedZoom=$accumulatedZoom" + ) + 2 + } else { + 0 + } + } + + if (mode == 2) { + val oldScale = gestureScale + val nextScale = (gestureScale * zoomChange).coerceIn(1f, 4f) + val ratio = if (oldScale == 0f) 1f else nextScale / oldScale + val previousCentroid = event.calculateCentroid(useCurrent = false) + val viewportCenter = Offset(size.width / 2f, size.height / 2f) + val nextOffset = if (previousCentroid != Offset.Unspecified) { + gestureOffset * ratio + (previousCentroid - viewportCenter) * (1 - ratio) + panChange + } else { + gestureOffset + panChange + } + gestureScale = nextScale + gestureOffset = clampPdfSpreadCameraOffset( + scale = gestureScale, + offset = nextOffset, + viewportWidth = size.width.toFloat(), + viewportHeight = size.height.toFloat() + ) + currentActiveScale = gestureScale + currentActiveOffset = gestureOffset + currentPageScale = gestureScale + hasConsumedGesture = true + event.changes.forEach { + if (it.positionChanged()) it.consume() + } + } + } + } + } while (!canceled && event.changes.any { it.pressed }) + + if (hasConsumedGesture && currentActiveScale > 1f && currentActiveScale < 1.05f) { + coroutineScope.launch { + val startScale = currentActiveScale + val startOffset = currentActiveOffset + Animatable(0f).animateTo(1f, animationSpec = tween(durationMillis = 180)) { + currentActiveScale = androidx.compose.ui.util.lerp(startScale, 1f, value) + currentActiveOffset = + lerp(startOffset, Offset.Zero, value) + currentPageScale = currentActiveScale + } + currentActiveScale = 1f + currentActiveOffset = Offset.Zero + currentPageScale = 1f + } + } else if (hasConsumedGesture && mode == 1 && currentActiveScale > 1f) { + val velocity = velocityTracker.calculateVelocity() + val flingX = if (!isScrollLocked && abs(velocity.x) > PDF_SPREAD_PAN_FLING_MIN_VELOCITY) { + velocity.x * PDF_SPREAD_PAN_FLING_MULTIPLIER + } else { + 0f + } + val flingY = if (abs(velocity.y) > PDF_SPREAD_PAN_FLING_MIN_VELOCITY) { + velocity.y * PDF_SPREAD_PAN_FLING_MULTIPLIER + } else { + 0f + } + + if (flingX != 0f || flingY != 0f) { + spreadPanFlingJob = coroutineScope.launch { + try { + val startOffset = currentActiveOffset + var decayedX = startOffset.x + var decayedY = startOffset.y + kotlinx.coroutines.coroutineScope { + launch { + if (flingX != 0f) { + Animatable(startOffset.x).animateDecay(flingX, decay) { + decayedX = value + currentActiveOffset = clampPdfSpreadCameraOffset( + scale = currentActiveScale, + offset = Offset(decayedX, decayedY), + viewportWidth = size.width.toFloat(), + viewportHeight = size.height.toFloat() + ) + } + } + } + launch { + if (flingY != 0f) { + Animatable(startOffset.y).animateDecay(flingY, decay) { + decayedY = value + currentActiveOffset = clampPdfSpreadCameraOffset( + scale = currentActiveScale, + offset = Offset(decayedX, decayedY), + viewportWidth = size.width.toFloat(), + viewportHeight = size.height.toFloat() + ) + } + } + } + } + } finally { + spreadPanFlingJob = null + } + } + } + } + } + } + .graphicsLayer { + scaleX = currentActiveScale + scaleY = currentActiveScale + translationX = currentActiveOffset.x + translationY = currentActiveOffset.y + } + } else { + Modifier + } + ), + horizontalArrangement = Arrangement.spacedBy(spreadPageGap, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + spreadPageIndices.forEach { pageIndex -> + key(pageIndex) { val isPageBookmarked by remember(bookmarks, pageIndex) { derivedStateOf { bookmarks.any { it.pageIndex == pageIndex } @@ -4174,23 +5022,30 @@ fun PdfViewerScreen( activeTheme = activeTheme, activeTextureAlpha = 1f - globalTextureTransparency, excludeImages = excludeImages, - isScrollLocked = isScrollLocked, + isScrollLocked = if (useSharedSpreadZoom) false else isScrollLocked, customHighlightColors = customHighlightColors, + externalScale = if (useSharedSpreadZoom) currentActiveScale else 1f, onPaletteClick = { highlightColorPickerInitialSlot = PdfHighlightColor.YELLOW showHighlightColorPicker = true }, onScaleChanged = { newScale -> - if (pagerState.currentPage == pageIndex) { + if (isActivePagerPage && !useSharedSpreadZoom) { currentPageScale = newScale } }, ttsHighlightData = if (ttsDisplayPageIndex == pageIndex) ttsHighlightData else null, searchQuery = searchState.searchQuery, searchHighlightMode = searchHighlightMode, - searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget else null, + searchResultToHighlight = if (isActivePagerPage) searchHighlightTarget else null, ocrHoverHighlights = stableOcrRects, - modifier = Modifier.fillMaxSize(), + modifier = if (spreadPageIndices.size > 1) { + Modifier + .weight(1f) + .fillMaxHeight() + } else { + Modifier.fillMaxSize() + }, showAllTextHighlights = showAllTextHighlights, onHighlightLoading = { /* no-op for paginated mode */ }, onPreSingleTap = onPaginationPreSingleTap, @@ -4209,10 +5064,15 @@ fun PdfViewerScreen( onInternalLinkClicked = onInternalLinkNav, isBookmarked = isPageBookmarked, onBookmarkClick = { onToggleBookmark(pageIndex) }, - isZoomEnabled = true, + isZoomEnabled = !useSharedSpreadZoom, showPageNumberOverlay = showPageNumberOverlay, + visualScaleProvider = if (useSharedSpreadZoom) { + { currentActiveScale } + } else { + { 1f } + }, clearSelectionTrigger = selectionClearTrigger, - resetZoomTrigger = resetZoomTrigger, + resetZoomTrigger = if (useSharedSpreadZoom) 0L else resetZoomTrigger, pageAnnotations = pageAnnotationsProvider, drawingState = drawingState, onDrawStart = onDrawStartPagination, @@ -4257,9 +5117,9 @@ fun PdfViewerScreen( onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) }, activeToolThickness = currentStrokeWidthState, eraserToolThickness = currentEraserStrokeWidthState, - lockedState = lockedState, + lockedState = if (useSharedSpreadZoom) null else lockedState, onZoomAndPanChanged = { newScale, newOffset -> - if (pagerState.currentPage == pageIndex) { + if (isActivePagerPage && !useSharedSpreadZoom) { currentActiveScale = newScale currentActiveOffset = newOffset } @@ -4277,13 +5137,13 @@ fun PdfViewerScreen( }, onTwoFingerSwipe = { direction -> coroutineScope.launch { - val targetPage = - pagerState.currentPage + direction - if (targetPage in 0 until totalDisplayPages) { - pagerState.animateScrollToPage( - targetPage - ) + val current = currentPaginationDisplayPage() + val targetPage = if (direction > 0) { + PdfSpreadLayout.nextPageIndex(current, totalDisplayPages, pdfSpreadSettings) + } else { + PdfSpreadLayout.previousPageIndex(current, totalDisplayPages, pdfSpreadSettings) } + animatePaginationToDisplayPage(targetPage) } }, richTextController = richTextController, @@ -4359,7 +5219,7 @@ fun PdfViewerScreen( } } else if (paginationDraggingOffset.x + paginationDraggingSize.width > screenWidth - edgeThreshold && isMovingRight) { coroutineScope.launch { - if (pagerState.currentPage < totalDisplayPages - 1 && !pagerState.isScrollInProgress) { + if (pagerState.currentPage < pagerState.pageCount - 1 && !pagerState.isScrollInProgress) { pagerState.animateScrollToPage(pagerState.currentPage + 1) } } @@ -4370,7 +5230,16 @@ fun PdfViewerScreen( val boxId = paginationDraggingBoxId if (boxId != null) { coroutineScope.launch { - val targetPage = pagerState.currentPage + val currentSpreadPageIndices = PdfSpreadLayout.visiblePageIndices( + pageIndex = currentPaginationDisplayPage(), + pageCount = totalDisplayPages, + settings = pdfSpreadSettings + ) + val targetPage = if (pageIndex in currentSpreadPageIndices) { + pageIndex + } else { + currentSpreadPageIndices.firstOrNull() ?: currentPaginationDisplayPage() + } val targetVirtualPage = virtualPages.getOrNull(targetPage) val pageAspectRatio = if (targetVirtualPage is VirtualPage.BlankPage) { if (targetVirtualPage.height > 0) targetVirtualPage.width.toFloat() / targetVirtualPage.height.toFloat() else 1f @@ -4448,17 +5317,23 @@ fun PdfViewerScreen( }, onDragPageTurn = { direction -> coroutineScope.launch { - val targetPage = pagerState.currentPage + direction - if (targetPage in 0 until totalDisplayPages) { - pagerState.animateScrollToPage(targetPage) + val current = currentPaginationDisplayPage() + val targetPage = if (direction > 0) { + PdfSpreadLayout.nextPageIndex(current, totalDisplayPages, pdfSpreadSettings) + } else { + PdfSpreadLayout.previousPageIndex(current, totalDisplayPages, pdfSpreadSettings) } + animatePaginationToDisplayPage(targetPage) } }, isBubbleZoomModeActive = isBubbleZoomModeActive, isVisible = isVisiblePage, - isActivePage = pagerState.currentPage == pageIndex, + isActivePage = isActivePagerPage, isScrolling = pagerState.isScrollInProgress ) + } + } + } } if (paginationDraggingBoxId != null) { @@ -5028,61 +5903,43 @@ fun PdfViewerScreen( } } - // --- Slider UI Overlay --- + val jumpBackPage = jumpHistory.getOrNull(jumpHistoryCursor - 1) + val jumpForwardPage = jumpHistory.getOrNull(jumpHistoryCursor + 1) + val effectiveNavBarForJumpBar = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp + val isPdfJumpHistoryVisible = showStandardBars && !searchState.isSearchActive && (jumpBackPage != null || jumpForwardPage != null) + val pdfBottomChromePadding = 56.dp + effectiveNavBarForJumpBar + val pdfSliderBottomPadding = pdfBottomChromePadding + if (isPdfJumpHistoryVisible) 40.dp else 0.dp + val pdfSliderPageBackground = if (activeTheme.backgroundColor == Color.Unspecified) Color.White else activeTheme.backgroundColor + val pdfSliderPageText = if (activeTheme.textColor == Color.Unspecified) Color.Black else activeTheme.textColor + val pdfReaderSliderColors = readerSliderChromeColors( + pageBackground = pdfSliderPageBackground, + pageText = pdfSliderPageText, + themePrimary = MaterialTheme.colorScheme.primary + ) + + // --- Slider UI attached to the bottom chrome --- AnimatedVisibility( - visible = isPageSliderVisible, + visible = pdfSliderChromeVisible, enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), - exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut()) { - Box(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { - MutableInteractionSource() - }, indication = null - ) { - isPageSliderVisible = false - showBars = true - }) - - if (isFastScrubbing) { - PageScrubbingAnimation( - currentPage = sliderCurrentPage.roundToInt() + 1, - totalPages = totalPages - ) - } - - // Top back button - IconButton( - onClick = { - isPageSliderVisible = false - showBars = true - }, modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_desc_exit_slider_navigation) - ) - } - - // Bottom controls + exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = pdfSliderBottomPadding) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(72.dp)) Box( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) .clickable( - indication = null, interactionSource = remember { - MutableInteractionSource() - }) {}, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) {} ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp) - .padding(bottom = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp), + .padding(horizontal = 32.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -5100,32 +5957,23 @@ fun PdfViewerScreen( delay(200) if (isActive) { val targetPage = newValue.roundToInt() - - if (targetPage != sliderStartPage) { - recordJumpHistory(sliderStartPage, targetPage) - } if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage( - newValue.roundToInt() - ) + scrollPaginationToDisplayPage(targetPage) } else { - verticalReaderState.scrollToPage( - newValue.roundToInt() - ) + verticalReaderState.scrollToPage(targetPage) } isFastScrubbing = false } } }, - valueRange = 0f..(totalPages - 1).toFloat() - .coerceAtLeast(0f), - steps = if (totalPages > 2) totalPages - 2 else 0, + valueRange = 0f..(totalDisplayPages - 1).toFloat().coerceAtLeast(0f), + steps = if (totalDisplayPages > 2) totalDisplayPages - 2 else 0, modifier = Modifier.fillMaxWidth(), thumb = { Surface( modifier = Modifier.size(20.dp), shape = CircleShape, - color = MaterialTheme.colorScheme.primary, + color = pdfReaderSliderColors.thumbColor, tonalElevation = 0.dp, shadowElevation = 0.dp ) {} @@ -5133,15 +5981,9 @@ fun PdfViewerScreen( track = { sliderState -> val trackHeight = 2.dp val trackShape = RoundedCornerShape(trackHeight) - - val range = - sliderState.valueRange.endInclusive - sliderState.valueRange.start - val fraction = if (range == 0f) 0f - else { - ((sliderState.value - sliderState.valueRange.start) / range).coerceIn( - 0f, - 1f - ) + val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start + val fraction = if (range == 0f) 0f else { + ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f) } Box( @@ -5149,7 +5991,7 @@ fun PdfViewerScreen( .fillMaxWidth() .height(trackHeight) .background( - color = MaterialTheme.colorScheme.surfaceVariant, + color = pdfReaderSliderColors.inactiveTrackColor, shape = trackShape ) ) { @@ -5158,47 +6000,39 @@ fun PdfViewerScreen( .fillMaxWidth(fraction) .fillMaxHeight() .background( - color = MaterialTheme.colorScheme.primary, + color = pdfReaderSliderColors.activeTrackColor, shape = trackShape ) ) } - }) + } + ) - val startPageOffsetFraction = if (totalPages > 1) { - sliderStartPage.toFloat() / (totalPages - 1) + val startPageOffsetFraction = if (totalDisplayPages > 1) { + sliderStartPage.toFloat() / (totalDisplayPages - 1) } else { 0f } - val thumbWidth = 20.dp val trackWidth = maxWidth - thumbWidth val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2) val indicatorSize = 8.dp - val indicatorOffset = - startPagePixelPosition - (indicatorSize / 2) + val indicatorOffset = startPagePixelPosition - (indicatorSize / 2) Surface( modifier = Modifier .align(Alignment.CenterStart) .offset(x = indicatorOffset) .size(indicatorSize), shape = CircleShape, - color = MaterialTheme.colorScheme.primary + color = pdfReaderSliderColors.bookmarkColor ) {} - Timber.d("maxWidth: $maxWidth, trackWidth: $trackWidth") - Timber.d( - "startPage: $sliderStartPage, totalPages: $totalPages, fraction: $startPageOffsetFraction" - ) - Timber.d( - "Calculated X Offset (before centering): $startPagePixelPosition" - ) - startPageThumbnail?.let { thumbnail -> ThumbnailWithIndicator( thumbnail = thumbnail, + borderColor = pdfReaderSliderColors.bookmarkColor, modifier = Modifier .graphicsLayer { clip = false } .align(Alignment.TopStart) @@ -5210,23 +6044,25 @@ fun PdfViewerScreen( sliderCurrentPage = sliderStartPage.toFloat() coroutineScope.launch { if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage(sliderStartPage) + scrollPaginationToDisplayPage(sliderStartPage) } else { - verticalReaderState.scrollToPage( - sliderStartPage - ) + verticalReaderState.scrollToPage(sliderStartPage) } } - }) + } + ) } } - // Page number text Text( - text = "${sliderCurrentPage.roundToInt() + 1} / $totalPages", + text = pdfPageRangeText( + pageIndex = sliderCurrentPage.roundToInt(), + pageCount = totalDisplayPages, + displayMode = displayMode, + settings = pdfSpreadSettings + ), style = MaterialTheme.typography.bodyLarge, - color = if (displayMode == DisplayMode.VERTICAL_SCROLL) Color.Black - else MaterialTheme.colorScheme.onSurface, + color = pdfReaderSliderColors.contentColor, fontSize = 18.sp ) } @@ -5234,23 +6070,40 @@ fun PdfViewerScreen( } } + if (pdfSliderChromeVisible && isFastScrubbing) { + PageScrubbingAnimation( + pageLabel = pdfPageRangeLabel( + pageIndex = sliderCurrentPage.roundToInt(), + pageCount = totalDisplayPages, + displayMode = displayMode, + settings = pdfSpreadSettings + ) + ) + } + val isPdfTtsPlayingOrLoading = ttsState.isPlaying || ttsState.isLoading val showPdfThemePanel = { showThemePanel = true } val showPdfDictionarySettings = { showDictionarySettingsSheet = true } val togglePdfScrollLock = { - isScrollLocked = !isScrollLocked - savePdfScrollLocked(context, bookId, isScrollLocked) - if (isScrollLocked) { + val nextLocked = !isScrollLocked + isScrollLocked = nextLocked + savePdfScrollLocked(context, bookId, nextLocked) + if (nextLocked) { + currentPageScale = currentActiveScale savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) } } val showPdfSlider = { - val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage - sliderStartPage = currentPageForSlider - sliderCurrentPage = currentPageForSlider.toFloat() - isPageSliderVisible = true - showBars = false + val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage + val nextState = readerSliderToggleState( + isCurrentlyToggledOn = isPageSliderVisible, + currentPage = currentPageForSlider + ) + sliderStartPage = nextState.bookmarkPosition.startPage + sliderCurrentPage = nextState.bookmarkPosition.currentPage + isPageSliderVisible = nextState.isToggledOn + showBars = true } val showPdfToc = { coroutineScope.launch { drawerState.open() } @@ -5316,10 +6169,16 @@ fun PdfViewerScreen( isLoadingDocument = isLoadingDocument, errorMessage = errorMessage, currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage + currentPaginationDisplayPage() } else { verticalReaderState.currentPage }, + currentPageLabel = pdfPageRangeLabel( + pageIndex = currentPage, + pageCount = totalDisplayPages, + displayMode = displayMode, + settings = pdfSpreadSettings + ), totalPages = totalPages, pagerStatePageCount = pagerState.pageCount, hiddenTools = hiddenTools, @@ -5331,22 +6190,25 @@ fun PdfViewerScreen( isRightToLeftPagination = rightToLeftPagination, isKeepScreenOn = isKeepScreenOn, isTtsSessionActive = isTtsSessionActive, + isSliderActive = isPageSliderVisible, isBookmarked = isBookmarked, canDeletePage = virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage, isReflowingThisBook = isReflowingThisBook, hasReflowFile = hasReflowFile, isPdfDocumentLoaded = pdfDocument != null, - isTabsEnabled = isTabsEnabled, + isTabsEnabled = isPdfTabStripVisible, openTabs = openTabs, activeTabBookId = activeTabBookId, + usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName, effectiveFileType = effectiveFileType, onNavigateBack = { saveStateAndExit() }, onShowThemePanel = showPdfThemePanel, + onShowBrightnessControl = { showBrightnessSheet = true }, onToggleScrollLock = togglePdfScrollLock, onShowDictionarySettings = showPdfDictionarySettings, onShowPenPlayground = { showPenPlayground = true }, onImportSvg = { - val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val page = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage coroutineScope.launch(Dispatchers.IO) { val svgAnnotations = SvgToAnnotationConverter.importSvgFromAssets( @@ -5416,6 +6278,7 @@ fun PdfViewerScreen( onShowTtsSettings = { showTtsSettingsSheet = true }, onShowTtsReplacements = { showTtsReplacementsSheet = true }, onToggleBookmark = onBookmarkClick, + onShowFileInfo = { showFileInfoDialog = true }, onInsertPage = onInsertPage, onDeletePage = onDeletePage, onReflowAction = { @@ -5465,7 +6328,7 @@ fun PdfViewerScreen( }, onNewTabClick = { showNewTabSheet = true }, onGenerateDemoAnnotations = { - val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val page = if (displayMode == DisplayMode.PAGINATION) currentPaginationDisplayPage() else verticalReaderState.currentPage val demoAnnots = DemoAnnotationGenerator.generateDemoAnnotations(page) if (demoAnnots.isNotEmpty()) { @@ -5682,14 +6545,10 @@ fun PdfViewerScreen( ) } - val jumpBackPage = jumpHistory.getOrNull(jumpHistoryCursor - 1) - val jumpForwardPage = jumpHistory.getOrNull(jumpHistoryCursor + 1) - val effectiveNavBarForJumpBar = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp - PdfJumpHistoryBar( modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = 56.dp + effectiveNavBarForJumpBar), + .padding(bottom = pdfBottomChromePadding), showStandardBars = showStandardBars, searchStateActive = searchState.isSearchActive, backPage = jumpBackPage, @@ -5724,8 +6583,10 @@ fun PdfViewerScreen( isHighlightingLoading = isHighlightingLoading, isEditMode = isEditMode, isTtsSessionActive = isTtsSessionActive, + isSliderActive = isPageSliderVisible, ttsErrorMessage = null, onShowThemePanel = showPdfThemePanel, + onShowBrightnessControl = { showBrightnessSheet = true }, onToggleScrollLock = togglePdfScrollLock, onShowDictionarySettings = showPdfDictionarySettings, onShowSlider = showPdfSlider, @@ -6482,7 +7343,7 @@ fun PdfViewerScreen( if (showAiHubSheet) { val currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage + currentPaginationDisplayPage() } else { verticalReaderState.currentPage } @@ -6988,15 +7849,29 @@ fun PdfViewerScreen( ) }, onDismiss = { highlightToNoteId = null }, - onSave = { noteText -> + onSave = { noteText, comments -> val index = userHighlights.indexOfFirst { it.id == targetHighlight.id } if (index != -1) { userHighlights[index] = - targetHighlight.copy(note = noteText.takeIf { it.isNotBlank() }) + userHighlights[index].copy( + note = noteText.takeIf { it.isNotBlank() }, + comments = comments + ) } highlightToNoteId = null }, + onUpdate = { noteText, comments -> + val index = + userHighlights.indexOfFirst { it.id == targetHighlight.id } + if (index != -1) { + userHighlights[index] = + userHighlights[index].copy( + note = noteText.takeIf { it.isNotBlank() }, + comments = comments + ) + } + }, onDelete = { onHighlightDelete(targetHighlight.id) highlightToNoteId = null @@ -7199,11 +8074,32 @@ fun PdfViewerScreen( } } + if (showBrightnessSheet) { + ReaderBrightnessSheet( + settings = readerBrightnessSettings, + onSettingsChange = updateReaderBrightness, + onDismiss = { showBrightnessSheet = false } + ) + } + if (showVisualOptionsSheet) { PdfVisualOptionsSheet( + displayMode = displayMode, systemUiMode = systemUiMode, + pageSpreadMode = pdfPageSpreadMode, + firstPageStandaloneInSpread = pdfFirstPageStandaloneInSpread, showVerticalPageGap = showVerticalPageGap, showPageNumberOverlay = showPageNumberOverlay, + onPageSpreadModeChange = { mode -> + pendingPaginationSpreadRestorePage = currentPage + pdfPageSpreadMode = mode + savePdfPageSpreadMode(context, mode) + }, + onFirstPageStandaloneInSpreadChange = { enabled -> + pendingPaginationSpreadRestorePage = currentPage + pdfFirstPageStandaloneInSpread = enabled + savePdfFirstPageStandaloneInSpread(context, enabled) + }, onSystemUiModeChange = { mode -> systemUiMode = mode savePdfSystemUiMode(context, mode) @@ -7229,6 +8125,15 @@ fun PdfViewerScreen( onDismiss = { showScreenOrientationSheet = false } ) } + ReaderFileInfoDialogs( + isFileInfoVisible = showFileInfoDialog, + onFileInfoVisibleChange = { showFileInfoDialog = it }, + uiState = uiState, + primaryBookId = uiState.selectedBookId, + secondaryBookId = currentBookId ?: activeTabBookId, + uriString = effectivePdfUri.toString(), + viewModel = viewModel + ) if (showCustomizeToolsSheet) { PdfCustomizeToolsSheet( hiddenTools = hiddenTools, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt new file mode 100644 index 0000000..7fa37b4 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt @@ -0,0 +1,141 @@ +package com.aryan.reader.pdf + +import androidx.compose.ui.geometry.Offset +import com.aryan.reader.shared.pdf.PdfSpreadLayout +import com.aryan.reader.shared.reader.ReaderSettings + +internal fun resolveEraserStrokeWidth( + isEraserOverride: Boolean, + activeToolThickness: Float, + eraserToolThickness: Float +): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness + +internal fun canUsePdfSidecarsForBook( + activeBookId: String?, + loadedSidecarBookId: String?, + areSidecarsLoaded: Boolean +): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId + +internal fun canManagePdfVirtualPages( + isDocumentReady: Boolean, + currentBookId: String?, + loadedPageLayoutBookId: String?, + virtualPageCount: Int +): Boolean { + return isDocumentReady && + currentBookId != null && + loadedPageLayoutBookId == currentBookId && + virtualPageCount > 0 +} + +internal fun currentPageScaleAfterPdfPageChange( + displayMode: DisplayMode, + isScrollLocked: Boolean, + lockedState: Triple?, + currentActiveScale: Float +): Float { + return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) { + lockedState?.first ?: currentActiveScale + } else { + 1f + } +} + +internal fun pdfPageRangeText( + pageIndex: Int, + pageCount: Int, + displayMode: DisplayMode, + settings: ReaderSettings +): String { + val pageRange = if (displayMode == DisplayMode.PAGINATION) { + PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings) + } else { + "${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}" + } + return "$pageRange / $pageCount" +} + +internal fun pdfPageRangeLabel( + pageIndex: Int, + pageCount: Int, + displayMode: DisplayMode, + settings: ReaderSettings +): String { + val pageRange = if (displayMode == DisplayMode.PAGINATION) { + PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings) + } else { + "${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}" + } + return if ('-' in pageRange) { + "Pages $pageRange of $pageCount" + } else { + "Page $pageRange of $pageCount" + } +} + +internal fun clampPdfSpreadCameraOffset( + scale: Float, + offset: Offset, + viewportWidth: Float, + viewportHeight: Float +): Offset { + if (viewportWidth <= 0f || viewportHeight <= 0f || scale <= 1f) return Offset.Zero + val maxOffsetX = ((viewportWidth * scale) - viewportWidth).coerceAtLeast(0f) / 2f + val maxOffsetY = ((viewportHeight * scale) - viewportHeight).coerceAtLeast(0f) / 2f + return Offset( + x = offset.x.coerceIn(-maxOffsetX, maxOffsetX), + y = offset.y.coerceIn(-maxOffsetY, maxOffsetY) + ) +} + +internal fun activePdfCameraAfterLockPreferenceLoad( + isScrollLocked: Boolean, + lockedState: Triple? +): Pair { + return if (isScrollLocked && lockedState != null) { + lockedState.first to Offset(lockedState.second, lockedState.third) + } else { + 1f to Offset.Zero + } +} + +internal fun shouldReportPdfPageCamera( + isZoomEnabled: Boolean, + isVerticalScroll: Boolean, + isScrollLocked: Boolean, + lockedState: Triple?, + hasAppliedLockedState: Boolean +): Boolean { + return !isZoomEnabled || + isVerticalScroll || + !isScrollLocked || + lockedState == null || + hasAppliedLockedState +} + +internal fun initialPdfPageCamera( + isZoomEnabled: Boolean, + isVerticalScroll: Boolean, + isScrollLocked: Boolean, + lockedState: Triple? +): Pair { + return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) { + lockedState.first to Offset(lockedState.second, lockedState.third) + } else { + 1f to Offset.Zero + } +} + +internal fun shouldResetPdfZoomAfterBubbleZoomCleanup( + isBubbleZoomModeActive: Boolean, + scale: Float, + isVerticalScroll: Boolean, + isZoomEnabled: Boolean, + isScrollLocked: Boolean +): Boolean { + return !isBubbleZoomModeActive && + scale > 1f && + !isVerticalScroll && + isZoomEnabled && + !isScrollLocked +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt b/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt index 16fbc79..c22d71c 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint +import android.graphics.RectF import android.graphics.Typeface import android.graphics.pdf.PdfRenderer import android.net.Uri @@ -33,12 +34,22 @@ import androidx.compose.ui.unit.isSpecified import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.VirtualPage +import com.aryan.reader.shared.pdf.PdfAnnotationKind +import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.PdfPageBounds +import com.aryan.reader.shared.pdf.PdfPagePoint +import com.aryan.reader.shared.pdf.SharedPdfAnnotation +import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper +import com.aryan.reader.shared.pdf.pdfInkAppearancePoints import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale +import java.util.TimeZone import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -111,7 +122,8 @@ internal object PdfiumAnnotationExporter { textBoxes = emptyList(), highlights = highlights.orEmpty(), richTextPageLayouts = emptyList(), - rasterOverlays = rasterOverlays + rasterOverlays = rasterOverlays, + pageSizes = pageSizes ) if (!payload.hasAnnotations()) { @@ -119,38 +131,51 @@ internal object PdfiumAnnotationExporter { return@withContext } - val exported = NativePdfiumBridge.exportAnnotatedPdf( - sourcePath = sourceFile.absolutePath, - destPath = destFile.absolutePath, - inkPageIndices = payload.inkPageIndices, - inkTypes = payload.inkTypes, - inkColors = payload.inkColors, - inkStrokeWidths = payload.inkStrokeWidths, - inkPointOffsets = payload.inkPointOffsets, - inkPointCounts = payload.inkPointCounts, - inkPoints = payload.inkPoints, - textPageIndices = payload.textPageIndices, - textBounds = payload.textBounds, - textColors = payload.textColors, - textBackgroundColors = payload.textBackgroundColors, - textFontSizes = payload.textFontSizes, - textFlags = payload.textFlags, - textValues = payload.textValues, - textFontPaths = payload.textFontPaths, - textFontNames = payload.textFontNames, - rasterPageIndices = payload.rasterPageIndices, - rasterBounds = payload.rasterBounds, - rasterWidths = payload.rasterWidths, - rasterHeights = payload.rasterHeights, - rasterPixelOffsets = payload.rasterPixelOffsets, - rasterPixels = payload.rasterPixels, - highlightPageIndices = payload.highlightPageIndices, - highlightColors = payload.highlightColors, - highlightRectOffsets = payload.highlightRectOffsets, - highlightRectCounts = payload.highlightRectCounts, - highlightRects = payload.highlightRects, - highlightContents = payload.highlightContents - ) + val exported = PdfiumEngineProvider.withPdfium { + NativePdfiumBridge.exportAnnotatedPdf( + sourcePath = sourceFile.absolutePath, + destPath = destFile.absolutePath, + inkPageIndices = payload.inkPageIndices, + inkTypes = payload.inkTypes, + inkColors = payload.inkColors, + inkStrokeWidths = payload.inkStrokeWidths, + inkPointOffsets = payload.inkPointOffsets, + inkPointCounts = payload.inkPointCounts, + inkPoints = payload.inkPoints, + inkNames = payload.inkNames, + inkContents = payload.inkContents, + textPageIndices = payload.textPageIndices, + textBounds = payload.textBounds, + textColors = payload.textColors, + textBackgroundColors = payload.textBackgroundColors, + textFontSizes = payload.textFontSizes, + textFlags = payload.textFlags, + textValues = payload.textValues, + textFontPaths = payload.textFontPaths, + textFontNames = payload.textFontNames, + rasterPageIndices = payload.rasterPageIndices, + rasterBounds = payload.rasterBounds, + rasterWidths = payload.rasterWidths, + rasterHeights = payload.rasterHeights, + rasterPixelOffsets = payload.rasterPixelOffsets, + rasterPixels = payload.rasterPixels, + highlightPageIndices = payload.highlightPageIndices, + highlightColors = payload.highlightColors, + highlightRectOffsets = payload.highlightRectOffsets, + highlightRectCounts = payload.highlightRectCounts, + highlightRects = payload.highlightRects, + highlightNames = payload.highlightNames, + highlightContents = payload.highlightContents, + highlightCommentOffsets = payload.highlightCommentOffsets, + highlightCommentCounts = payload.highlightCommentCounts, + highlightCommentParentIndices = payload.highlightCommentParentIndices, + highlightCommentNames = payload.highlightCommentNames, + highlightCommentAuthors = payload.highlightCommentAuthors, + highlightCommentContents = payload.highlightCommentContents, + highlightCommentCreatedDates = payload.highlightCommentCreatedDates, + highlightCommentModifiedDates = payload.highlightCommentModifiedDates + ) + } if (!exported) { throw IOException("PDFium failed to write annotated PDF.") @@ -183,15 +208,21 @@ internal object PdfiumAnnotationExporter { highlights: List, richTextPageLayouts: List = emptyList(), fontPathResolver: (String?) -> String? = { it }, - rasterOverlays: List = emptyList() + rasterOverlays: List = emptyList(), + pageSizes: List = emptyList() ): PdfiumAnnotationExportPayload { - val inkItems = inkAnnotations.entries - .flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } } - .filter { (_, annotation) -> - annotation.points.size >= 2 && - annotation.inkType != InkType.ERASER && - annotation.inkType != InkType.TEXT - } + val exportPayload = SharedPdfAnnotationExportMapper.build( + sharedExportAnnotations( + inkAnnotations = inkAnnotations, + highlights = highlights, + pageSizes = pageSizes + ) + ) + val inkItems = exportPayload.inkAnnotations + val inkPointsForExport = inkItems.map { annotation -> + val pageSize = pageSizeFor(pageSizes, annotation.pageIndex) + annotation.pdfInkAppearancePoints(pageSize.width.toFloat(), pageSize.height.toFloat()) + } val inkPageIndices = IntArray(inkItems.size) val inkTypes = IntArray(inkItems.size) @@ -199,17 +230,22 @@ internal object PdfiumAnnotationExporter { val inkStrokeWidths = FloatArray(inkItems.size) val inkPointOffsets = IntArray(inkItems.size) val inkPointCounts = IntArray(inkItems.size) - val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2) + val inkPoints = FloatArray(inkPointsForExport.sumOf { it.size } * 2) + val inkNames = Array(inkItems.size) { "" } + val inkContents = Array(inkItems.size) { "" } var inkPointCursor = 0 - inkItems.forEachIndexed { index, (pageIndex, annotation) -> - inkPageIndices[index] = pageIndex - inkTypes[index] = annotation.inkType.ordinal - inkColors[index] = annotation.color.toArgb() + inkItems.forEachIndexed { index, annotation -> + val points = inkPointsForExport[index] + inkPageIndices[index] = annotation.pageIndex + inkTypes[index] = annotation.tool.toAndroidInkTypeOrdinal() + inkColors[index] = annotation.colorArgb inkStrokeWidths[index] = annotation.strokeWidth inkPointOffsets[index] = inkPointCursor / 2 - inkPointCounts[index] = annotation.points.size - annotation.points.forEach { point -> + inkPointCounts[index] = points.size + inkNames[index] = annotation.id + inkContents[index] = annotation.contents + points.forEach { point -> inkPoints[inkPointCursor++] = point.x inkPoints[inkPointCursor++] = point.y } @@ -246,22 +282,49 @@ internal object PdfiumAnnotationExporter { rasterPixelCursor += overlay.pixels.size } - val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() } + val boundedHighlights = exportPayload.highlightAnnotations val highlightPageIndices = IntArray(boundedHighlights.size) val highlightColors = IntArray(boundedHighlights.size) val highlightRectOffsets = IntArray(boundedHighlights.size) val highlightRectCounts = IntArray(boundedHighlights.size) - val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4) + val highlightRects = FloatArray(boundedHighlights.sumOf { it.boundsList.size } * 4) + val highlightNames = Array(boundedHighlights.size) { "" } val highlightContents = Array(boundedHighlights.size) { "" } + val highlightCommentCount = boundedHighlights.sumOf { it.comments.size } + val highlightCommentOffsets = IntArray(boundedHighlights.size) + val highlightCommentCounts = IntArray(boundedHighlights.size) + val highlightCommentParentIndices = IntArray(highlightCommentCount) + val highlightCommentNames = Array(highlightCommentCount) { "" } + val highlightCommentAuthors = Array(highlightCommentCount) { "" } + val highlightCommentContents = Array(highlightCommentCount) { "" } + val highlightCommentCreatedDates = Array(highlightCommentCount) { "" } + val highlightCommentModifiedDates = Array(highlightCommentCount) { "" } var highlightRectCursor = 0 + var highlightCommentCursor = 0 boundedHighlights.forEachIndexed { index, highlight -> highlightPageIndices[index] = highlight.pageIndex - highlightColors[index] = highlight.color.color.toArgb() + highlightColors[index] = highlight.colorArgb highlightRectOffsets[index] = highlightRectCursor / 4 - highlightRectCounts[index] = highlight.bounds.size - highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text - highlight.bounds.forEach { rect -> + highlightRectCounts[index] = highlight.boundsList.size + highlightNames[index] = highlight.id + highlightContents[index] = highlight.contents + highlightCommentOffsets[index] = highlightCommentCursor + highlightCommentCounts[index] = highlight.comments.size + val localCommentIndices = mutableMapOf() + highlight.comments.forEachIndexed { localIndex, comment -> + val globalIndex = highlightCommentCursor + localIndex + highlightCommentParentIndices[globalIndex] = comment.parentId?.let(localCommentIndices::get) ?: -1 + localCommentIndices[comment.id] = localIndex + highlightCommentNames[globalIndex] = comment.id + highlightCommentAuthors[globalIndex] = comment.author + highlightCommentContents[globalIndex] = comment.contents + highlightCommentCreatedDates[globalIndex] = comment.createdAt.toPdfDateString() + highlightCommentModifiedDates[globalIndex] = comment.modifiedAt.toPdfDateString() + .ifBlank { comment.createdAt.toPdfDateString() } + } + highlightCommentCursor += highlight.comments.size + highlight.boundsList.forEach { rect -> highlightRects[highlightRectCursor++] = rect.left highlightRects[highlightRectCursor++] = rect.top highlightRects[highlightRectCursor++] = rect.right @@ -277,6 +340,8 @@ internal object PdfiumAnnotationExporter { inkPointOffsets = inkPointOffsets, inkPointCounts = inkPointCounts, inkPoints = inkPoints, + inkNames = inkNames, + inkContents = inkContents, textPageIndices = textPageIndices, textBounds = textBounds, textColors = textColors, @@ -297,7 +362,103 @@ internal object PdfiumAnnotationExporter { highlightRectOffsets = highlightRectOffsets, highlightRectCounts = highlightRectCounts, highlightRects = highlightRects, - highlightContents = highlightContents + highlightNames = highlightNames, + highlightContents = highlightContents, + highlightCommentOffsets = highlightCommentOffsets, + highlightCommentCounts = highlightCommentCounts, + highlightCommentParentIndices = highlightCommentParentIndices, + highlightCommentNames = highlightCommentNames, + highlightCommentAuthors = highlightCommentAuthors, + highlightCommentContents = highlightCommentContents, + highlightCommentCreatedDates = highlightCommentCreatedDates, + highlightCommentModifiedDates = highlightCommentModifiedDates + ) + } + + private fun sharedExportAnnotations( + inkAnnotations: Map>, + highlights: List, + pageSizes: List + ): List { + val annotations = mutableListOf() + inkAnnotations.entries.forEach { (pageIndex, pageAnnotations) -> + pageAnnotations.forEach { annotation -> + if (annotation.type != AnnotationType.INK) return@forEach + annotations += SharedPdfAnnotation( + id = annotation.id, + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + tool = annotation.inkType.toSharedPdfInkTool(), + points = annotation.points.map { point -> + PdfPagePoint(point.x, point.y, point.timestamp) + }, + note = annotation.note, + colorArgb = annotation.color.toArgb(), + strokeWidth = annotation.strokeWidth + ) + } + } + highlights.forEach { highlight -> + val boundsList = highlight.bounds.mapNotNull { rect -> + rect.toNormalizedPdfPageBounds(pageSizeFor(pageSizes, highlight.pageIndex)) + } + annotations += SharedPdfAnnotation( + id = highlight.id, + pageIndex = highlight.pageIndex, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + bounds = boundsList.firstOrNull(), + boundsList = boundsList, + text = highlight.text, + note = highlight.note, + comments = highlight.comments, + colorArgb = highlight.color.color.toArgb(), + rangeStartIndex = highlight.range.first, + rangeEndIndex = (highlight.range.second - 1).coerceAtLeast(highlight.range.first) + ) + } + return annotations + } + + private fun InkType.toSharedPdfInkTool(): PdfInkTool { + return when (this) { + InkType.PEN -> PdfInkTool.PEN + InkType.HIGHLIGHTER -> PdfInkTool.HIGHLIGHTER + InkType.HIGHLIGHTER_ROUND -> PdfInkTool.HIGHLIGHTER_ROUND + InkType.ERASER -> PdfInkTool.ERASER + InkType.FOUNTAIN_PEN -> PdfInkTool.FOUNTAIN_PEN + InkType.PENCIL -> PdfInkTool.PENCIL + InkType.TEXT -> PdfInkTool.TEXT + } + } + + private fun PdfInkTool.toAndroidInkTypeOrdinal(): Int { + return when (this) { + PdfInkTool.HIGHLIGHTER -> InkType.HIGHLIGHTER.ordinal + PdfInkTool.HIGHLIGHTER_ROUND -> InkType.HIGHLIGHTER_ROUND.ordinal + PdfInkTool.FOUNTAIN_PEN -> InkType.FOUNTAIN_PEN.ordinal + PdfInkTool.PENCIL -> InkType.PENCIL.ordinal + PdfInkTool.TEXT -> InkType.TEXT.ordinal + PdfInkTool.ERASER -> InkType.ERASER.ordinal + PdfInkTool.NONE, + PdfInkTool.PEN -> InkType.PEN.ordinal + } + } + + private fun RectF.toNormalizedPdfPageBounds(pageSize: PdfiumPageSize): PdfPageBounds? { + val pageWidth = pageSize.width.takeIf { it > 0 }?.toFloat() ?: return null + val pageHeight = pageSize.height.takeIf { it > 0 }?.toFloat() ?: return null + val pdfLeft = minOf(left, right) + val pdfRight = maxOf(left, right) + val pdfTop = maxOf(top, bottom) + val pdfBottom = minOf(top, bottom) + if (pdfRight <= pdfLeft || pdfTop <= pdfBottom) return null + + return PdfPageBounds( + left = pdfLeft / pageWidth, + top = (pageHeight - pdfTop) / pageHeight, + right = pdfRight / pageWidth, + bottom = (pageHeight - pdfBottom) / pageHeight ) } @@ -681,6 +842,13 @@ internal object PdfiumAnnotationExporter { private fun String.sanitizeRasterTextPreservingLength(): String = replace(PAGE_BREAK_CHAR, '\n') .replace('\r', ' ') + + private fun Long.toPdfDateString(): String { + if (this <= 0L) return "" + return SimpleDateFormat("'D:'yyyyMMddHHmmss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(this)) + } } internal data class PdfiumRasterOverlay( @@ -694,7 +862,7 @@ internal data class PdfiumRasterOverlay( val pixels: IntArray ) -private data class PdfiumPageSize( +internal data class PdfiumPageSize( val width: Int, val height: Int ) { @@ -738,6 +906,8 @@ internal data class PdfiumAnnotationExportPayload( val inkPointOffsets: IntArray, val inkPointCounts: IntArray, val inkPoints: FloatArray, + val inkNames: Array, + val inkContents: Array, val textPageIndices: IntArray, val textBounds: FloatArray, val textColors: IntArray, @@ -758,7 +928,16 @@ internal data class PdfiumAnnotationExportPayload( val highlightRectOffsets: IntArray, val highlightRectCounts: IntArray, val highlightRects: FloatArray, - val highlightContents: Array + val highlightNames: Array, + val highlightContents: Array, + val highlightCommentOffsets: IntArray, + val highlightCommentCounts: IntArray, + val highlightCommentParentIndices: IntArray, + val highlightCommentNames: Array, + val highlightCommentAuthors: Array, + val highlightCommentContents: Array, + val highlightCommentCreatedDates: Array, + val highlightCommentModifiedDates: Array ) { fun hasAnnotations(): Boolean = inkPageIndices.isNotEmpty() || diff --git a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt index fe49a33..9205052 100644 --- a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt +++ b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt @@ -44,9 +44,11 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp +import com.aryan.reader.pdf.data.VirtualPage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import androidx.compose.ui.text.font.Font @@ -460,6 +462,65 @@ private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore: return this + AnnotatedString(PAGE_BREAK_CHAR.toString()) } +internal fun androidRichTextInsertionIndexForPage( + insertPageIndex: Int, + pageLayouts: List, + textLength: Int +): Int { + val rawIndex = if (insertPageIndex <= 0) { + 0 + } else { + pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: textLength + } + return rawIndex.coerceIn(0, textLength) +} + +internal fun androidRichTextBlankInsertBreakCount(text: String, insertionCharIndex: Int): Int { + val safeIndex = insertionCharIndex.coerceIn(0, text.length) + if (safeIndex == 0 || safeIndex == text.length) return 1 + + val hasBoundaryBreakBefore = text.getOrNull(safeIndex - 1) == PAGE_BREAK_CHAR + val hasBoundaryBreakAfter = text.getOrNull(safeIndex) == PAGE_BREAK_CHAR + return if (hasBoundaryBreakBefore || hasBoundaryBreakAfter) 1 else 2 +} + +internal fun remapAndroidRichTextForLayoutChange( + currentLayout: List, + updatedLayout: List, + pageLayouts: List +): AnnotatedString { + if (pageLayouts.isEmpty()) return AnnotatedString("") + + val mapping = buildPdfPageIndexMapping( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + sourcePageIndices = pageLayouts.map { it.pageIndex } + ) + if (mapping.isEmpty()) return AnnotatedString("") + + val contentByTargetPage = linkedMapOf() + pageLayouts.sortedBy { it.pageIndex }.forEach { layout -> + val targetPageIndex = mapping[layout.pageIndex] ?: return@forEach + val pageContent = layout.visibleText.withoutTrailingAndroidPageBreak() + contentByTargetPage[targetPageIndex] = pageContent + } + + val lastPageWithContent = contentByTargetPage + .filterValues { it.text.isNotEmpty() } + .keys + .maxOrNull() + ?: return AnnotatedString("") + + val builder = AnnotatedString.Builder() + for (pageIndex in 0..lastPageWithContent) { + contentByTargetPage[pageIndex]?.let { builder.append(it) } + if (pageIndex < lastPageWithContent) { + builder.append(PAGE_BREAK_CHAR.toString()) + } + } + return builder.toAnnotatedString() +} + class PdfRichTextRepository(private val context: Context) { private val _document = MutableStateFlow(null) val document = _document.asStateFlow() @@ -1166,30 +1227,139 @@ class RichTextController( val original = globalTextFieldValue.annotatedString Timber.tag("RichTextMigration").d("insertPageBreakAt: Target Page Index: $insertPageIndex, Count: $count") - val insertionCharIndex = if (insertPageIndex == 0) 0 else { - val prevLayout = pageLayouts.find { it.pageIndex == insertPageIndex - 1 } - val idx = prevLayout?.globalEndIndex ?: original.length - Timber.tag("RichTextMigration").v("insertPageBreakAt: Prev Page (${insertPageIndex - 1}) ends at global index $idx") - idx - } - val safeIndex = insertionCharIndex.coerceIn(0, original.length) + val safeIndex = androidRichTextInsertionIndexForPage( + insertPageIndex = insertPageIndex, + pageLayouts = pageLayouts, + textLength = original.length + ) + Timber.tag("RichTextMigration").v("insertPageBreakAt: insertion index $safeIndex") - Timber.tag("RichTextMigration").i("insertPageBreakAt: Inserting $count PAGE_BREAK_CHARs at global index $safeIndex") + insertPageBreaksIntoGlobalText( + original = original, + safeIndex = safeIndex, + count = count, + caller = "InsertPageBreakAt" + ) + } + } - val builder = AnnotatedString.Builder() - builder.append(original.subSequence(0, safeIndex)) + fun insertBlankPageAt(insertPageIndex: Int) { + scope.launch { + forceSyncAndClear() - repeat(count) { - builder.append(PAGE_BREAK_CHAR.toString()) - } + val original = globalTextFieldValue.annotatedString + val safeIndex = androidRichTextInsertionIndexForPage( + insertPageIndex = insertPageIndex, + pageLayouts = pageLayouts, + textLength = original.length + ) + val requiredBreaks = androidRichTextBlankInsertBreakCount( + text = original.text, + insertionCharIndex = safeIndex + ) - builder.append(original.subSequence(safeIndex, original.length)) + Timber.tag("RichTextMigration").i( + "insertBlankPageAt: page=$insertPageIndex index=$safeIndex breaks=$requiredBreaks" + ) - val newCursorPos = safeIndex + count + insertPageBreaksIntoGlobalText( + original = original, + safeIndex = safeIndex, + count = requiredBreaks, + caller = "InsertBlankPageAt" + ) + } + } - globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos)) - debouncedSave(globalTextFieldValue) - repaginate(dirtyStartIndex = safeIndex, caller = "InsertPageBreakAt") + suspend fun remapPagesForLayoutChange( + currentLayout: List, + updatedLayout: List + ) = withContext(NonCancellable) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "rich.remap.start current=${currentLayout.pdfLayoutDebugSummary()} " + + "updated=${updatedLayout.pdfLayoutDebugSummary()} pageLayouts=${pageLayouts.size} " + + "textLen=${globalTextFieldValue.annotatedString.length}" + ) + forceSyncAndClear() + + val original = globalTextFieldValue.annotatedString + if (pageLayouts.isEmpty() && original.text.isNotEmpty()) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "rich.remap.skipNoLayouts current=${currentLayout.pdfLayoutDebugSummary()} " + + "updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}" + ) + Timber.tag("RichTextMigration").w( + "remapPagesForLayoutChange skipped: no rich text page layouts for non-empty text" + ) + return@withContext + } + val remapped = remapAndroidRichTextForLayoutChange( + currentLayout = currentLayout, + updatedLayout = updatedLayout, + pageLayouts = pageLayouts + ) + + if (remapped == original) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "rich.remap.noChange current=${currentLayout.pdfLayoutDebugSummary()} " + + "updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}" + ) + return@withContext + } + + Timber.tag("RichTextMigration").i( + "remapPagesForLayoutChange: textLen ${original.length} -> ${remapped.length}, " + + "pages ${currentLayout.size} -> ${updatedLayout.size}" + ) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "rich.remap.apply textLen=${original.length}->${remapped.length} " + + "current=${currentLayout.pdfLayoutDebugSummary()} updated=${updatedLayout.pdfLayoutDebugSummary()}" + ) + + globalTextFieldValue = TextFieldValue( + remapped, + selection = TextRange(remapped.length) + ) + repaginateSync(0) + saveCurrentGlobalTextImmediately() + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "rich.remap.done textLen=${globalTextFieldValue.annotatedString.length} pageLayouts=${pageLayouts.size}" + ) + } + + private fun insertPageBreaksIntoGlobalText( + original: AnnotatedString, + safeIndex: Int, + count: Int, + caller: String + ) { + val safeCount = count.coerceAtLeast(0) + if (safeCount == 0) return + + Timber.tag("RichTextMigration").i("$caller: Inserting $safeCount PAGE_BREAK_CHARs at global index $safeIndex") + + val builder = AnnotatedString.Builder() + builder.append(original.subSequence(0, safeIndex)) + + repeat(safeCount) { + builder.append(PAGE_BREAK_CHAR.toString()) + } + + builder.append(original.subSequence(safeIndex, original.length)) + + val newCursorPos = safeIndex + safeCount + + globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos)) + debouncedSave(globalTextFieldValue) + repaginate(dirtyStartIndex = safeIndex, caller = caller) + } + + private suspend fun saveCurrentGlobalTextImmediately() { + saveJob?.cancel() + val finalAnnotated = globalTextFieldValue.annotatedString + withContext(Dispatchers.Default) { + val doc = RichTextMapper.fromAnnotatedString(finalAnnotated, lastPageHeight) + repository.save(bookId, doc) } } diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt index 5b6b0e4..1a7e6b7 100644 --- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt +++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt @@ -64,6 +64,19 @@ interface ReaderTextPage : AutoCloseable { data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF) data class ReaderTextRect(val rect: RectF) +internal data class PdfNativePageOverlayExtraction( + val embeddedAnnotations: List = emptyList(), + val annotationScreenRects: List> = emptyList(), + val imageScreenRects: List = emptyList(), + val resolvedNativePointer: Boolean = true +) + +internal data class PdfNativeTapResult( + val linkInfo: String? = null, + val clickHandled: Boolean = false, + val resolvedNativePointer: Boolean = true +) + interface ReaderWebLinks : AutoCloseable { suspend fun countWebLinks(): Int suspend fun getURL(linkIndex: Int, maxLength: Int): String? @@ -103,7 +116,16 @@ object DocumentFactory { ArchiveDocumentWrapper(cacheFile) } else { val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF") - PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) }) + try { + PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) }) + } catch (e: Throwable) { + try { + pfd.close() + } catch (closeError: Exception) { + e.addSuppressed(closeError) + } + throw e + } } } } @@ -134,13 +156,25 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument { val page = PdfiumEngineProvider.withPdfium { if (isClosed.get()) null else pdfDocument.openPage(pageIndex) } ?: return null - return PdfPageWrapper(page) + return PdfPageWrapper(page, isClosed) } override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium { pdfDocument.getFixedTableOfContents() } + internal fun getNativeDocumentPointerForLockedAccess(): Long { + if (isClosed.get()) return 0L + return try { + val documentField = pdfDocument.javaClass.getDeclaredField("document").apply { isAccessible = true } + val docUInstance = documentField.get(pdfDocument) ?: return 0L + val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true } + ptrField.get(docUInstance) as? Long ?: 0L + } catch (_: Exception) { + 0L + } + } + override fun close() { if (!isClosed.compareAndSet(false, true)) return PdfiumEngineProvider.withPdfiumBlocking { @@ -149,24 +183,28 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument { } } -class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage { +class PdfPageWrapper( + val pdfPage: PdfPageKt, + private val ownerClosed: AtomicBoolean = AtomicBoolean(false) +) : ReaderPage { private val isClosed = AtomicBoolean(false) + private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) 0 else pdfPage.getPageWidthPoint() + if (isUnavailable()) 0 else pdfPage.getPageWidthPoint() } override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) 0 else pdfPage.getPageHeightPoint() + if (isUnavailable()) 0 else pdfPage.getPageHeightPoint() } override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) 0 else pdfPage.getPageRotation() + if (isUnavailable()) 0 else pdfPage.getPageRotation() } override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) { PdfiumEngineProvider.withPdfium { - if (!isClosed.get()) { + if (!isUnavailable()) { pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot) } } @@ -174,21 +212,21 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage { override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords) + if (isUnavailable()) 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) = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY) + if (isUnavailable()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY) } override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage()) + if (isUnavailable()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage(), ownerClosed, isClosed) } override suspend fun getLinks(): List { return PdfiumEngineProvider.withPdfium { - if (isClosed.get()) { + if (isUnavailable()) { emptyList() } else { pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) } @@ -197,9 +235,191 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage { } override fun getNativePointer(): Long { + if (isUnavailable()) return 0L return extractNativePointer(pdfPage) } + internal suspend fun extractNativePageOverlays( + bitmapWidthPx: Int, + bitmapHeightPx: Int, + pageRotation: Int, + pageIndex: Int, + linkAnnotationSubtype: Int + ): PdfNativePageOverlayExtraction = PdfiumEngineProvider.withPdfium { + if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) { + return@withPdfium PdfNativePageOverlayExtraction() + } + + val pagePtr = extractNativePointer(pdfPage) + if (pagePtr == 0L) { + return@withPdfium PdfNativePageOverlayExtraction(resolvedNativePointer = false) + } + + val imageRects = extractImageScreenRectsLocked(pagePtr, bitmapWidthPx, bitmapHeightPx, pageRotation) + val embeddedAnnotations = extractEmbeddedAnnotationsLocked(pagePtr, pageIndex, linkAnnotationSubtype) + val mappedAnnots = embeddedAnnotations.mapNotNull { annotation -> + val screenRect = pdfPage.mapRectToDevice( + 0, + 0, + bitmapWidthPx, + bitmapHeightPx, + pageRotation, + annotation.rect + ) + if (screenRect.width() > 0 && screenRect.height() > 0) { + annotation to screenRect + } else { + null + } + } + + PdfNativePageOverlayExtraction( + embeddedAnnotations = embeddedAnnotations, + annotationScreenRects = mappedAnnots, + imageScreenRects = imageRects + ) + } + + internal suspend fun resolveNativeTap( + documentWrapper: PdfDocumentWrapper?, + bitmapWidthPx: Int, + bitmapHeightPx: Int, + pageRotation: Int, + deviceX: Int, + deviceY: Int + ): PdfNativeTapResult = PdfiumEngineProvider.withPdfium { + if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) { + return@withPdfium PdfNativeTapResult() + } + + val pagePtr = extractNativePointer(pdfPage) + if (pagePtr == 0L) { + return@withPdfium PdfNativeTapResult(resolvedNativePointer = false) + } + + val pdfCoords = pdfPage.mapDeviceCoordsToPage( + 0, + 0, + bitmapWidthPx, + bitmapHeightPx, + pageRotation, + deviceX, + deviceY + ) + val docPtr = documentWrapper?.getNativeDocumentPointerForLockedAccess() ?: 0L + + Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr") + + val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint( + docPtr, + pagePtr, + pdfCoords.x.toDouble(), + pdfCoords.y.toDouble() + ) + if (linkInfo != null) { + return@withPdfium PdfNativeTapResult(linkInfo = linkInfo) + } + + PdfNativeTapResult( + clickHandled = NativePdfiumBridge.performClick( + pagePtr, + pdfCoords.x.toDouble(), + pdfCoords.y.toDouble() + ) + ) + } + + private suspend fun extractImageScreenRectsLocked( + pagePtr: Long, + bitmapWidthPx: Int, + bitmapHeightPx: Int, + pageRotation: Int + ): List { + return try { + val objectCount = NativePdfiumBridge.getPageObjectCount(pagePtr) + if (objectCount <= 0) return emptyList() + + val rects = mutableListOf() + val outRect = FloatArray(4) + for (index in 0 until objectCount) { + if (NativePdfiumBridge.getPageObjectType(pagePtr, index) != 3) continue + if (!NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)) continue + + val pdfRect = RectF( + minOf(outRect[0], outRect[2]), + maxOf(outRect[1], outRect[3]), + maxOf(outRect[0], outRect[2]), + minOf(outRect[1], outRect[3]) + ) + val deviceRect = pdfPage.mapRectToDevice( + 0, + 0, + bitmapWidthPx, + bitmapHeightPx, + pageRotation, + pdfRect + ) + if (deviceRect.width() > 0 && deviceRect.height() > 0) { + rects += Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom) + } + } + rects + } catch (e: Exception) { + Timber.tag("PdfImageDebug").e(e, "Error extracting image rects") + emptyList() + } + } + + private fun extractEmbeddedAnnotationsLocked( + pagePtr: Long, + pageIndex: Int, + linkAnnotationSubtype: Int + ): List { + return try { + val count = NativePdfiumBridge.getAnnotCount(pagePtr) + Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") + if (count <= 0) return emptyList() + + val annotations = mutableListOf() + for (index in 0 until count) { + val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, index) + if (subtype == linkAnnotationSubtype) continue + + var contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "Contents") + if (contents.isNullOrBlank()) { + contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "RC") + } + + val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, index) + val pdfRect = if (pdfRectArray != null) { + RectF( + minOf(pdfRectArray[0], pdfRectArray[2]), + maxOf(pdfRectArray[1], pdfRectArray[3]), + maxOf(pdfRectArray[0], pdfRectArray[2]), + minOf(pdfRectArray[1], pdfRectArray[3]) + ) + } else { + RectF() + } + + annotations += EmbeddedAnnotation( + index = index, + subtype = subtype, + rect = pdfRect, + contents = contents, + author = NativePdfiumBridge.getAnnotString(pagePtr, index, "T"), + name = NativePdfiumBridge.getAnnotString(pagePtr, index, "NM"), + inReplyTo = NativePdfiumBridge.getAnnotString(pagePtr, index, "IRT") + ) + } + + groupEmbeddedAnnotationsForDisplay(annotations) + } catch (e: Exception) { + Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations") + emptyList() + } + } + private fun extractNativePointer(obj: Any): Long { val priorityFields = listOf("page", "mNativePagePtr", "pagePtr", "mNativePage") @@ -231,66 +451,74 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage { override fun close() { if (!isClosed.compareAndSet(false, true)) return + if (ownerClosed.get()) return PdfiumEngineProvider.withPdfiumBlocking { closePdfiumResource("PdfPageWrapper") { pdfPage.close() } } } } -class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage { +class PdfTextPageWrapper( + private val textPage: PdfTextPageKt, + private val ownerClosed: AtomicBoolean = AtomicBoolean(false), + private val pageClosed: AtomicBoolean = AtomicBoolean(false) +) : ReaderTextPage { private val isClosed = AtomicBoolean(false) + private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get() override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) 0 else textPage.textPageCountChars() + if (isUnavailable()) 0 else textPage.textPageCountChars() } override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) null else textPage.textPageGetText(startIndex, count) + if (isUnavailable()) 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) } + if (isUnavailable()) 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) + if (isUnavailable()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance) } override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) null else textPage.textPageGetCharBox(index) + if (isUnavailable()) null else textPage.textPageGetCharBox(index) } override suspend fun textPageGetUnicode(index: Int): Int { return PdfiumEngineProvider.withPdfium { - if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code + if (isUnavailable()) 0 else textPage.textPageGetUnicode(index).code } } override suspend fun loadWebLink(): ReaderWebLinks? { val links = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) null else textPage.loadWebLink() + if (isUnavailable()) null else textPage.loadWebLink() } ?: return null return object : ReaderWebLinks { private val isClosed = AtomicBoolean(false) + private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get() override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) 0 else links.countWebLinks() + if (isUnavailable()) 0 else links.countWebLinks() } override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) null else links.getURL(linkIndex, maxLength) + if (isUnavailable()) null else links.getURL(linkIndex, maxLength) } override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) 0 else links.countRects(linkIndex) + if (isUnavailable()) 0 else links.countRects(linkIndex) } override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium { - if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex) + if (isUnavailable()) RectF() else links.getRect(linkIndex, rectIndex) } override fun close() { if (!isClosed.compareAndSet(false, true)) return + if (ownerClosed.get() || pageClosed.get()) return PdfiumEngineProvider.withPdfiumBlocking { closePdfiumResource("PdfWebLinksWrapper") { links.close() } } @@ -299,6 +527,7 @@ class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage { } override fun close() { if (!isClosed.compareAndSet(false, true)) return + if (ownerClosed.get() || pageClosed.get()) return PdfiumEngineProvider.withPdfiumBlocking { closePdfiumResource("PdfTextPageWrapper") { textPage.close() } } diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PageLayoutRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PageLayoutRepository.kt index 29a72e6..8535d78 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PageLayoutRepository.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PageLayoutRepository.kt @@ -20,6 +20,8 @@ package com.aryan.reader.pdf.data import android.content.Context +import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG +import com.aryan.reader.pdf.pdfLayoutDebugSummary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray @@ -41,6 +43,12 @@ class PageLayoutRepository(private val context: Context) { } suspend fun saveLayout(bookId: String, pages: List) = withContext(Dispatchers.IO) { + val file = getFile(bookId) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "repo.saveLayout.start bookId=$bookId file=${file.absolutePath} " + + "beforeExists=${file.exists()} beforeBytes=${if (file.exists()) file.length() else 0L} " + + "beforeMtime=${if (file.exists()) file.lastModified() else 0L} layout=${pages.pdfLayoutDebugSummary()}" + ) val jsonArray = JSONArray() pages.forEach { page -> val obj = JSONObject() @@ -59,13 +67,27 @@ class PageLayoutRepository(private val context: Context) { } jsonArray.put(obj) } - getFile(bookId).writeText(jsonArray.toString()) + file.writeText(jsonArray.toString()) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "repo.saveLayout.done bookId=$bookId file=${file.absolutePath} " + + "afterExists=${file.exists()} afterBytes=${file.length()} afterMtime=${file.lastModified()} " + + "layout=${pages.pdfLayoutDebugSummary()}" + ) } suspend fun loadLayout(bookId: String, totalPdfPages: Int): List = withContext(Dispatchers.IO) { val file = getFile(bookId) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "repo.loadLayout.start bookId=$bookId totalPdfPages=$totalPdfPages file=${file.absolutePath} " + + "exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " + + "mtime=${if (file.exists()) file.lastModified() else 0L}" + ) if (!file.exists()) { - return@withContext (0 until totalPdfPages).map { VirtualPage.PdfPage(it) } + val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) } + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( + "repo.loadLayout.missing bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}" + ) + return@withContext fallback } try { @@ -84,18 +106,32 @@ class PageLayoutRepository(private val context: Context) { list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual)) } } + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "repo.loadLayout.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}" + ) list - } catch (_: Exception) { - (0 until totalPdfPages).map { VirtualPage.PdfPage(it) } + } catch (e: Exception) { + val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) } + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e( + e, + "repo.loadLayout.failed bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}" + ) + fallback } } suspend fun getLayoutOrNull(bookId: String): List? = withContext(Dispatchers.IO) { val file = getFile(bookId) + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "repo.getLayoutOrNull.start bookId=$bookId file=${file.absolutePath} " + + "exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " + + "mtime=${if (file.exists()) file.lastModified() else 0L}" + ) Timber.tag("PdfExportDebug").d("PageLayoutRepo: Looking for layout at ${file.absolutePath}") Timber.tag("PdfExportDebug").d("PageLayoutRepo: File exists: ${file.exists()}") if (!file.exists()) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w("repo.getLayoutOrNull.missing bookId=$bookId") Timber.tag("PdfExportDebug").w("PageLayoutRepo: No layout file for book $bookId") return@withContext null } @@ -114,14 +150,19 @@ class PageLayoutRepository(private val context: Context) { } else { val w = obj.optInt("w", 595) val h = obj.optInt("h", 842) - list.add(VirtualPage.BlankPage(obj.getString("id"), w, h)) + val isManual = obj.optBoolean("manual", false) + list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual)) } } Timber.tag("PdfExportDebug").i("PageLayoutRepo: Parsed ${list.size} virtual pages (${ list.count { it is VirtualPage.PdfPage } } PDF, ${list.count { it is VirtualPage.BlankPage }} blank)") + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( + "repo.getLayoutOrNull.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}" + ) list } catch (e: Exception) { + Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "repo.getLayoutOrNull.failed bookId=$bookId") Timber.tag("PdfExportDebug").e(e, "PageLayoutRepo: Failed to parse layout") null } @@ -135,4 +176,4 @@ class PageLayoutRepository(private val context: Context) { Timber.tag("PdfExportDebug").v("PageLayoutRepo: Layout file path: ${file.absolutePath}") return file } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt index f2d1fc4..e1bc860 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt @@ -28,6 +28,7 @@ import com.aryan.reader.pdf.InkType import com.aryan.reader.pdf.PdfHighlightColor import com.aryan.reader.pdf.PdfPoint import com.aryan.reader.pdf.PdfUserHighlight +import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment import org.json.JSONArray import org.json.JSONObject import java.util.Locale @@ -237,6 +238,10 @@ object HighlightSerializer { if (!h.note.isNullOrBlank()) { obj.put("note", h.note) } + val commentsArray = h.comments.toJsonArray() + if (commentsArray.length() > 0) { + obj.put("comments", commentsArray) + } val boundsArray = JSONArray() h.bounds.forEach { r -> @@ -279,7 +284,8 @@ object HighlightSerializer { color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW }, text = obj.optString("text", ""), range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)), - note = obj.optString("note").takeIf { !it.isNullOrBlank() } + note = obj.optString("note").takeIf { !it.isNullOrBlank() }, + comments = obj.optJSONArray("comments").toSharedPdfAnnotationComments() ) ) } @@ -288,4 +294,50 @@ object HighlightSerializer { } return result } + + private fun List.toJsonArray(): JSONArray { + val array = JSONArray() + forEach { comment -> + val contents = comment.contents.trim() + if (contents.isBlank()) return@forEach + val obj = JSONObject() + obj.put("id", comment.id) + comment.parentId?.takeIf { it.isNotBlank() }?.let { obj.put("parentId", it) } + comment.author.takeIf { it.isNotBlank() }?.let { obj.put("author", it) } + obj.put("contents", contents) + if (comment.createdAt > 0L) obj.put("createdAt", comment.createdAt) + val modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt + if (modifiedAt > 0L) obj.put("modifiedAt", modifiedAt) + array.put(obj) + } + return array + } + + private fun JSONArray?.toSharedPdfAnnotationComments(): List { + if (this == null) return emptyList() + val comments = mutableListOf() + for (index in 0 until length()) { + val obj = optJSONObject(index) ?: continue + val contents = obj.optString("contents") + .ifBlank { obj.optString("text") } + .ifBlank { obj.optString("comment") } + .trim() + if (contents.isBlank()) continue + val createdAt = obj.optLong("createdAt", obj.optLong("created", 0L)) + comments += SharedPdfAnnotationComment( + id = obj.optString("id").takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString(), + parentId = obj.optString("parentId") + .ifBlank { obj.optString("inReplyTo") } + .takeIf { it.isNotBlank() }, + author = obj.optString("author").trim(), + contents = contents, + createdAt = createdAt, + modifiedAt = obj.optLong( + "modifiedAt", + obj.optLong("modified", createdAt) + ) + ) + } + return comments + } } diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt index c81b16c..00fcb2a 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt @@ -372,43 +372,50 @@ class PdfTextRepository(context: Context) { ): List { return withContext(Dispatchers.IO) { val rects = mutableListOf() + var bitmap: android.graphics.Bitmap? = null + var targetWidth = 0 + var targetHeight = 0 try { - document.openPage(pageIndex)?.use { page -> - val targetWidth = 1080 - val ptrWidth = page.getPageWidthPoint() - val ptrHeight = page.getPageHeightPoint() + PdfiumEngineProvider.withPdfium { + document.openPage(pageIndex)?.use { page -> + targetWidth = 1080 + val ptrWidth = page.getPageWidthPoint() + val ptrHeight = page.getPageHeightPoint() - if (ptrWidth <= 0 || ptrHeight <= 0) return@use + if (ptrWidth <= 0 || ptrHeight <= 0) return@use - val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat() - val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1) + val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat() + targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1) - val bitmap = createBitmap(targetWidth, targetHeight) - page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) + bitmap = createBitmap(targetWidth, targetHeight) + page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false) + } + } - val visionText = OcrHelper.extractTextFromBitmap(bitmap, onModelDownloading) + val renderedBitmap = bitmap ?: return@withContext rects + val visionText = OcrHelper.extractTextFromBitmap(renderedBitmap, onModelDownloading) - visionText?.textBlocks?.forEach { block -> - block.lines.forEach { line -> - line.elements.forEach { element -> - if (element.text.contains(query, ignoreCase = true)) { - element.boundingBox?.let { box -> - val normalized = RectF( - box.left.toFloat() / targetWidth, - box.top.toFloat() / targetHeight, - box.right.toFloat() / targetWidth, - box.bottom.toFloat() / targetHeight - ) - rects.add(normalized) - } + visionText?.textBlocks?.forEach { block -> + block.lines.forEach { line -> + line.elements.forEach { element -> + if (element.text.contains(query, ignoreCase = true)) { + element.boundingBox?.let { box -> + val normalized = RectF( + box.left.toFloat() / targetWidth, + box.top.toFloat() / targetHeight, + box.right.toFloat() / targetWidth, + box.bottom.toFloat() / targetHeight + ) + rects.add(normalized) } } } } - bitmap.recycle() } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to get OCR rects for page $pageIndex") + } finally { + bitmap?.recycle() } rects } diff --git a/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt b/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt index 93eeb30..d3cc2f1 100644 --- a/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt +++ b/app/src/main/java/com/aryan/reader/pptx/PptxDocument.kt @@ -34,17 +34,32 @@ import com.aryan.reader.pdf.ReaderLink import com.aryan.reader.pdf.ReaderPage import com.aryan.reader.pdf.ReaderTextPage import com.aryan.reader.pdf.ReaderTextRect +import com.aryan.reader.shared.pptx.SharedPptxAutoFitMode +import com.aryan.reader.shared.pptx.SharedPptxCharBox +import com.aryan.reader.shared.pptx.SharedPptxCustomGeometry +import com.aryan.reader.shared.pptx.SharedPptxDeck +import com.aryan.reader.shared.pptx.SharedPptxDeckCache +import com.aryan.reader.shared.pptx.SharedPptxElement +import com.aryan.reader.shared.pptx.SharedPptxGradientFill +import com.aryan.reader.shared.pptx.SharedPptxImageCrop +import com.aryan.reader.shared.pptx.SharedPptxImageElement +import com.aryan.reader.shared.pptx.SharedPptxParagraph +import com.aryan.reader.shared.pptx.SharedPptxPathCommand +import com.aryan.reader.shared.pptx.SharedPptxRect +import com.aryan.reader.shared.pptx.SharedPptxShapeElement +import com.aryan.reader.shared.pptx.SharedPptxSlide +import com.aryan.reader.shared.pptx.SharedPptxTableCell +import com.aryan.reader.shared.pptx.SharedPptxTableElement +import com.aryan.reader.shared.pptx.SharedPptxTableRow +import com.aryan.reader.shared.pptx.SharedPptxTextAlign +import com.aryan.reader.shared.pptx.SharedPptxTextInsets +import com.aryan.reader.shared.pptx.SharedPptxTextRun +import com.aryan.reader.shared.pptx.SharedPptxVerticalAnchor import io.legere.pdfiumandroid.api.Bookmark import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.jsoup.Jsoup -import org.jsoup.nodes.Element -import org.jsoup.parser.Parser import timber.log.Timber import java.io.File -import java.security.MessageDigest -import java.util.Locale -import java.util.zip.ZipFile import kotlin.math.cos import kotlin.math.max import kotlin.math.min @@ -53,14 +68,10 @@ import kotlin.math.sin import androidx.core.graphics.withSave import androidx.core.graphics.withTranslation -internal const val PPTX_RENDERER_VERSION = 6 private const val EMU_PER_POINT = 12_700f -private const val DEFAULT_SLIDE_WIDTH_EMU = 12_192_000 -private const val DEFAULT_SLIDE_HEIGHT_EMU = 6_858_000 private const val DEFAULT_TEXT_SIZE_PT = 18f private const val DEFAULT_TEXT_MARGIN_PT = 91_440f / EMU_PER_POINT private const val DEFAULT_LINE_SPACING_MULTIPLE = 1.0f -private const val MAX_NUMBERING_LEVELS = 9 internal data class PptxDeck( val widthPoint: Int, @@ -95,7 +106,6 @@ internal data class PptxShapeElement( val lineWidthPoint: Float, val paragraphs: List, val hyperlink: String?, - val placeholderKey: PptxPlaceholderKey?, val textInsets: PptxTextInsets = PptxTextInsets(), val verticalAnchor: PptxVerticalAnchor = PptxVerticalAnchor.TOP, val rotationDegrees: Float = 0f, @@ -257,905 +267,184 @@ internal sealed interface PptxPathCommand { object Close : PptxPathCommand } -internal data class PptxPlaceholderKey( - val type: String?, - val index: String? -) - -private data class PptxRelationships( - val byId: Map -) - -private data class PptxRelationship( - val id: String, - val target: String, - val resolvedTarget: String, - val type: String, - val targetMode: String? -) - -private data class PptxTheme( - val colors: Map = emptyMap(), - val majorTypeface: String? = null, - val minorTypeface: String? = null -) { - fun color(name: String): Int? { - return colors[name] ?: colors[name.lowercase(Locale.ROOT)] - } -} - -private data class ParsedPart( - val backgroundColor: Int? = null, - val elements: List = emptyList(), - val textDefaults: PptxTextDefaults = PptxTextDefaults() -) - -private data class PptxTextDefaults( - val title: Map = emptyMap(), - val body: Map = emptyMap(), - val other: Map = emptyMap() -) { - fun merge(override: PptxTextDefaults): PptxTextDefaults { - return PptxTextDefaults( - title = title.mergeStyles(override.title), - body = body.mergeStyles(override.body), - other = other.mergeStyles(override.other) - ) - } - - fun forPlaceholder(key: PptxPlaceholderKey?): Map { - return when (key?.type?.placeholderFamily()) { - "title" -> title - "body", null -> if (key != null) body else other - "subtitle" -> other.ifEmpty { body } - else -> other - } - } -} - -private data class PptxParagraphStyle( - val alignment: PptxTextAlign? = null, - val bullet: String? = null, - val autoNumberType: String? = null, - val autoNumberStartAt: Int? = null, - val bulletExplicit: Boolean = false, - val marginLeftPt: Float? = null, - val indentPt: Float? = null, - val spaceBeforePt: Float? = null, - val spaceAfterPt: Float? = null, - val lineSpacingMultiple: Float? = null, - val run: PptxRunStyle = PptxRunStyle() -) { - fun merge(override: PptxParagraphStyle): PptxParagraphStyle { - return PptxParagraphStyle( - alignment = override.alignment ?: alignment, - bullet = if (override.bulletExplicit) override.bullet else bullet, - autoNumberType = if (override.bulletExplicit) override.autoNumberType else autoNumberType, - autoNumberStartAt = if (override.bulletExplicit) override.autoNumberStartAt else autoNumberStartAt, - bulletExplicit = bulletExplicit || override.bulletExplicit, - marginLeftPt = override.marginLeftPt ?: marginLeftPt, - indentPt = override.indentPt ?: indentPt, - spaceBeforePt = override.spaceBeforePt ?: spaceBeforePt, - spaceAfterPt = override.spaceAfterPt ?: spaceAfterPt, - lineSpacingMultiple = override.lineSpacingMultiple ?: lineSpacingMultiple, - run = run.merge(override.run) - ) - } -} - -private data class PptxRunStyle( - val sizePt: Float? = null, - val color: Int? = null, - val bold: Boolean? = null, - val italic: Boolean? = null, - val typeface: String? = null, - val baseline: Float? = null -) { - fun merge(override: PptxRunStyle): PptxRunStyle { - return PptxRunStyle( - sizePt = override.sizePt ?: sizePt, - color = override.color ?: color, - bold = override.bold ?: bold, - italic = override.italic ?: italic, - typeface = override.typeface ?: typeface, - baseline = override.baseline ?: baseline - ) - } -} - -private data class PptxTableStyle( - val whole: PptxTableCellStyle = PptxTableCellStyle(), - val firstRow: PptxTableCellStyle = PptxTableCellStyle(), - val lastRow: PptxTableCellStyle = PptxTableCellStyle(), - val firstColumn: PptxTableCellStyle = PptxTableCellStyle(), - val lastColumn: PptxTableCellStyle = PptxTableCellStyle(), - val band1Horizontal: PptxTableCellStyle = PptxTableCellStyle(), - val band2Horizontal: PptxTableCellStyle = PptxTableCellStyle() -) { - fun cellStyle( - rowIndex: Int, - columnIndex: Int, - rowCount: Int, - columnCount: Int, - options: PptxTableStyleOptions - ): PptxTableCellStyle { - var style = whole - if (options.bandRow) { - val bandIndex = rowIndex - if (options.firstRow) 1 else 0 - if (bandIndex >= 0) { - style = style.merge(if (bandIndex % 2 == 0) band1Horizontal else band2Horizontal) - } - } - if (options.firstRow && rowIndex == 0) style = style.merge(firstRow) - if (options.lastRow && rowIndex == rowCount - 1) style = style.merge(lastRow) - if (options.firstColumn && columnIndex == 0) style = style.merge(firstColumn) - if (options.lastColumn && columnIndex == columnCount - 1) style = style.merge(lastColumn) - return style - } -} - -private data class PptxTableCellStyle( - val fillColor: Int? = null, - val lineColor: Int? = null, - val run: PptxRunStyle = PptxRunStyle() -) { - fun merge(override: PptxTableCellStyle): PptxTableCellStyle { - return PptxTableCellStyle( - fillColor = override.fillColor ?: fillColor, - lineColor = override.lineColor ?: lineColor, - run = run.merge(override.run) - ) - } -} - -private data class PptxTableStyleOptions( - val firstRow: Boolean = false, - val lastRow: Boolean = false, - val firstColumn: Boolean = false, - val lastColumn: Boolean = false, - val bandRow: Boolean = false -) - -private data class PptxGroupTransform( - val scaleX: Float = 1f, - val scaleY: Float = 1f, - val dx: Float = 0f, - val dy: Float = 0f, - val rotationDegrees: Float = 0f -) { - fun then(child: PptxGroupTransform): PptxGroupTransform { - return PptxGroupTransform( - scaleX = scaleX * child.scaleX, - scaleY = scaleY * child.scaleY, - dx = dx + child.dx * scaleX, - dy = dy + child.dy * scaleY, - rotationDegrees = rotationDegrees + child.rotationDegrees - ) - } - - fun apply(element: PptxElement): PptxElement { - if (this == IDENTITY) return element - return when (element) { - is PptxShapeElement -> element.copy( - bounds = mapRect(element.bounds), - lineWidthPoint = element.lineWidthPoint * averageScale(), - rotationDegrees = element.rotationDegrees + rotationDegrees - ) - is PptxImageElement -> element.copy( - bounds = mapRect(element.bounds), - rotationDegrees = element.rotationDegrees + rotationDegrees - ) - is PptxTableElement -> element.copy( - bounds = mapRect(element.bounds), - rotationDegrees = element.rotationDegrees + rotationDegrees - ) - } - } - - private fun mapRect(rect: RectF): RectF { - val left = rect.left * scaleX + dx - val right = rect.right * scaleX + dx - val top = rect.top * scaleY + dy - val bottom = rect.bottom * scaleY + dy - return RectF(min(left, right), min(top, bottom), max(left, right), max(top, bottom)) - } - - private fun averageScale(): Float = ((scaleX + scaleY) / 2f).coerceAtLeast(0.01f) - - companion object { - val IDENTITY = PptxGroupTransform() - - fun fromGroup(group: Element): PptxGroupTransform { - val xfrm = group.childrenByLocalTag("grpSpPr") - .firstOrNull() - ?.childrenByLocalTag("xfrm") - ?.firstOrNull() - ?: return IDENTITY - val off = xfrm.childrenByLocalTag("off").firstOrNull() - val ext = xfrm.childrenByLocalTag("ext").firstOrNull() - val chOff = xfrm.childrenByLocalTag("chOff").firstOrNull() - val chExt = xfrm.childrenByLocalTag("chExt").firstOrNull() ?: return IDENTITY - val childWidth = chExt.xmlFloat("cx")?.emuToPoint()?.takeIf { it != 0f } ?: return IDENTITY - val childHeight = chExt.xmlFloat("cy")?.emuToPoint()?.takeIf { it != 0f } ?: return IDENTITY - val scaleX = (ext?.xmlFloat("cx")?.emuToPoint() ?: childWidth) / childWidth - val scaleY = (ext?.xmlFloat("cy")?.emuToPoint() ?: childHeight) / childHeight - val childX = chOff?.xmlFloat("x")?.emuToPoint() ?: 0f - val childY = chOff?.xmlFloat("y")?.emuToPoint() ?: 0f - val offX = off?.xmlFloat("x")?.emuToPoint() ?: 0f - val offY = off?.xmlFloat("y")?.emuToPoint() ?: 0f - return PptxGroupTransform( - scaleX = scaleX, - scaleY = scaleY, - dx = offX - childX * scaleX, - dy = offY - childY * scaleY, - rotationDegrees = xfrm.xmlFloat("rot")?.let { it / 60_000f } ?: 0f - ) - } - } -} - internal object PptxDeckCache { - private const val MAX_ENTRIES = 4 - private val cache = object : LinkedHashMap(MAX_ENTRIES, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > MAX_ENTRIES - } - } - - fun load(file: File): PptxDeck { - val key = "${file.contentHash()}:${file.length()}:$PPTX_RENDERER_VERSION" - synchronized(cache) { - cache[key]?.let { return it } - } - val parsed = PptxDocumentParser.parse(file) - synchronized(cache) { - cache[key] = parsed - } - return parsed - } + fun load(file: File): PptxDeck = PptxDocumentParser.parse(file) } internal object PptxDocumentParser { - fun parse(file: File): PptxDeck { - ZipFile(file).use { zip -> - val presentation = zip.xml("ppt/presentation.xml") - ?: error("ppt/presentation.xml not found in PPTX archive.") - val presentationRels = zip.relationshipsFor("ppt/presentation.xml") - val width = (presentation.firstByLocalTag("sldSz")?.xmlFloat("cx") ?: DEFAULT_SLIDE_WIDTH_EMU.toFloat()).emuToPointInt() - val height = (presentation.firstByLocalTag("sldSz")?.xmlFloat("cy") ?: DEFAULT_SLIDE_HEIGHT_EMU.toFloat()).emuToPointInt() - val slidePaths = presentation.allByLocalTag("sldId") - .mapNotNull { slideId -> slideId.xmlAttr("r:id") } - .mapNotNull { relId -> presentationRels.byId[relId]?.resolvedTarget } - .ifEmpty { - zip.entries().asSequence() - .map { it.name } - .filter { it.matches(Regex("""ppt/slides/slide\d+\.xml""")) } - .sortedWith(naturalSlidePathComparator()) - .toList() - } + fun parse(file: File): PptxDeck = SharedPptxDeckCache.load(file).toAndroidPptxDeck() +} - val slides = slidePaths.mapNotNull { slidePath -> - runCatching { parseSlide(zip, presentation, slidePath, width, height) } - .onFailure { Timber.w(it, "Failed to parse PPTX slide $slidePath") } - .getOrNull() - } +private fun SharedPptxDeck.toAndroidPptxDeck(): PptxDeck { + return PptxDeck( + widthPoint = widthPoint, + heightPoint = heightPoint, + slides = slides.map { it.toAndroidPptxSlide() } + ) +} - return PptxDeck( - widthPoint = width, - heightPoint = height, - slides = slides.ifEmpty { - listOf( - PptxSlide( - widthPoint = width, - heightPoint = height, - backgroundColor = Color.WHITE, - elements = emptyList(), - text = "", - charBoxes = emptyList() - ) - ) - } - ) - } - } +private fun SharedPptxSlide.toAndroidPptxSlide(): PptxSlide { + return PptxSlide( + widthPoint = widthPoint, + heightPoint = heightPoint, + backgroundColor = backgroundColor, + elements = elements.map { it.toAndroidPptxElement() }, + text = text, + charBoxes = charBoxes.map { it.toAndroidPptxCharBox() } + ) +} - private fun parseSlide( - zip: ZipFile, - presentation: Element, - slidePath: String, - width: Int, - height: Int - ): PptxSlide { - val slideXml = zip.xml(slidePath) ?: error("Missing slide part: $slidePath") - val slideRels = zip.relationshipsFor(slidePath) - val layoutPath = slideRels.byId.values - .firstOrNull { it.type.endsWith("/slideLayout", ignoreCase = true) } - ?.resolvedTarget - val layoutRels = layoutPath?.let { zip.relationshipsFor(it) } - val masterPath = layoutRels?.byId?.values - ?.firstOrNull { it.type.endsWith("/slideMaster", ignoreCase = true) } - ?.resolvedTarget - val masterRels = masterPath?.let { zip.relationshipsFor(it) } - val themePath = masterRels?.byId?.values - ?.firstOrNull { it.type.endsWith("/theme", ignoreCase = true) } - ?.resolvedTarget - val theme = themePath?.let { path -> zip.xml(path)?.let(::parseTheme) } ?: PptxTheme() - val presentationDefaults = presentation.presentationTextDefaults(theme) +private fun SharedPptxCharBox.toAndroidPptxCharBox(): PptxCharBox { + return PptxCharBox(char = char, bounds = bounds.toRectF()) +} - val master = masterPath?.let { path -> - zip.xml(path)?.let { - parsePart( - zip = zip, - document = it, - relationships = zip.relationshipsFor(path), - theme = theme, - renderPlaceholderText = false, - inheritedTextDefaults = presentationDefaults - ) - } - } ?: ParsedPart() - val layout = layoutPath?.let { path -> - zip.xml(path)?.let { - parsePart( - zip = zip, - document = it, - relationships = zip.relationshipsFor(path), - theme = theme, - renderPlaceholderText = false, - inheritedTextDefaults = master.textDefaults - ) - } - } ?: ParsedPart() - val slide = parsePart( - zip = zip, - document = slideXml, - relationships = slideRels, - theme = theme, - renderPlaceholderText = true, - inheritedTextDefaults = layout.textDefaults - ) - val inheritedElements = master.elements + layout.elements - val elements = inheritedElements + inheritPlaceholderProperties(slide.elements, inheritedElements) - val backgroundColor = slide.backgroundColor ?: layout.backgroundColor ?: master.backgroundColor ?: Color.WHITE - val textIndex = PptxTextIndexer.index(elements) - - return PptxSlide( - widthPoint = width, - heightPoint = height, - backgroundColor = backgroundColor, - elements = elements, - text = textIndex.text, - charBoxes = textIndex.charBoxes - ) - } - - private fun parsePart( - zip: ZipFile, - document: Element, - relationships: PptxRelationships, - theme: PptxTheme, - renderPlaceholderText: Boolean, - inheritedTextDefaults: PptxTextDefaults = PptxTextDefaults() - ): ParsedPart { - val partTheme = theme.withColorMap(document.colorMapElement()) - val textDefaults = inheritedTextDefaults.merge(document.textDefaults(partTheme)) - val background = document.firstByLocalTag("bgPr")?.solidFillColor(partTheme) - ?: document.firstByLocalTag("bgRef")?.schemeColor(partTheme) - val tableStyles = zip.tableStyles(partTheme) - val elements = mutableListOf() - val tree = document.firstByLocalTag("spTree") ?: document - tree.children().forEach { child -> - parseDrawingElement( - zip = zip, - element = child, - relationships = relationships, - theme = partTheme, - renderPlaceholderText = renderPlaceholderText, - textDefaults = textDefaults, - tableStyles = tableStyles, - output = elements - ) - } - return ParsedPart(backgroundColor = background, elements = elements, textDefaults = textDefaults) - } - - private fun parseDrawingElement( - zip: ZipFile, - element: Element, - relationships: PptxRelationships, - theme: PptxTheme, - renderPlaceholderText: Boolean, - textDefaults: PptxTextDefaults, - tableStyles: Map, - output: MutableList, - transform: PptxGroupTransform = PptxGroupTransform.IDENTITY - ) { - when (element.localTag()) { - "sp", "cxnsp" -> parseShape(element, relationships, theme, renderPlaceholderText, textDefaults) - ?.let { output += transform.apply(it) } - "pic" -> parseImage(zip, element, relationships) - ?.let { output += transform.apply(it) } - "grpsp" -> element.children().forEach { child -> - val childTransform = transform.then(PptxGroupTransform.fromGroup(element)) - parseDrawingElement( - zip = zip, - element = child, - relationships = relationships, - theme = theme, - renderPlaceholderText = renderPlaceholderText, - textDefaults = textDefaults, - tableStyles = tableStyles, - output = output, - transform = childTransform - ) - } - "graphicframe" -> parseGraphicFrame(element, relationships, theme, tableStyles) - ?.let { output += transform.apply(it) } - } - } - - private fun parseShape( - element: Element, - relationships: PptxRelationships, - theme: PptxTheme, - renderPlaceholderText: Boolean, - textDefaults: PptxTextDefaults - ): PptxShapeElement? { - val spPr = element.childrenByLocalTag("spPr").firstOrNull() - val bounds = spPr?.boundsFromTransform() ?: element.boundsFromTransform() - val txBody = element.firstByLocalTag("txBody") - val bodyPr = txBody?.childrenByLocalTag("bodyPr")?.firstOrNull() - val preset = spPr?.childrenByLocalTag("prstGeom")?.firstOrNull()?.xmlAttr("prst") - ?: if (element.localTag() == "cxnsp") "line" else "rect" - val customGeometry = spPr?.childrenByLocalTag("custGeom")?.firstOrNull()?.customGeometry() - val placeholderKey = element.firstByLocalTag("ph")?.placeholderKey() - val style = element.childrenByLocalTag("style").firstOrNull() - val shapeRunDefaults = PptxRunStyle(color = style?.firstByLocalTag("fontRef")?.solidLikeColor(theme)) - val paragraphs = parseTextBody( - txBody = txBody, - theme = theme, - inheritedStyles = textDefaults.forPlaceholder(placeholderKey), - shapeRunDefaults = shapeRunDefaults - ) - val useBackgroundFill = element.xmlAttr("useBgFill").isTruthyXmlFlag() - val fillColor = when { - useBackgroundFill -> null - spPr?.firstDirectByLocalTag("noFill") != null -> null - else -> spPr?.solidFillColor(theme) ?: style?.firstByLocalTag("fillRef")?.solidLikeColor(theme) - } - val gradientFill = spPr?.gradientFill(theme) - val line = spPr?.childrenByLocalTag("ln")?.firstOrNull() - val lineColor = when { - line?.firstDirectByLocalTag("noFill") != null -> null - else -> line?.solidFillColor(theme) ?: style?.firstByLocalTag("lnRef")?.solidLikeColor(theme) - } - val lineWidth = line?.xmlFloat("w")?.emuToPoint() ?: 0.75f - val hyperlink = element.firstByLocalTag("hlinkClick") - ?.xmlAttr("r:id") - ?.let { relationships.byId[it] } - ?.let { rel -> if (rel.targetMode.equals("External", ignoreCase = true)) rel.target else rel.resolvedTarget } - - if (bounds.width() <= 0f && bounds.height() <= 0f && paragraphs.isEmpty()) return null - return PptxShapeElement( - bounds = bounds, - preset = preset.lowercase(Locale.ROOT), +private fun SharedPptxElement.toAndroidPptxElement(): PptxElement { + return when (this) { + is SharedPptxShapeElement -> PptxShapeElement( + bounds = bounds.toRectF(), + preset = preset, fillColor = fillColor, - gradientFill = gradientFill, + gradientFill = gradientFill?.toAndroidPptxGradientFill(), lineColor = lineColor, - lineWidthPoint = lineWidth, - paragraphs = paragraphs, + lineWidthPoint = lineWidthPoint, + paragraphs = paragraphs.map { it.toAndroidPptxParagraph() }, hyperlink = hyperlink, - placeholderKey = placeholderKey, - textInsets = bodyPr?.textInsets() ?: PptxTextInsets(), - verticalAnchor = bodyPr?.verticalAnchor() ?: PptxVerticalAnchor.TOP, - rotationDegrees = spPr?.rotationDegreesFromTransform() ?: element.rotationDegreesFromTransform(), - renderText = placeholderKey == null || renderPlaceholderText, - fontScale = bodyPr?.autoFitFontScale() ?: 1f, - lineSpacingReduction = bodyPr?.autoFitLineSpacingReduction() ?: 0f, - autoFitMode = bodyPr?.autoFitMode() ?: PptxAutoFitMode.NONE, - customGeometry = customGeometry + textInsets = textInsets.toAndroidPptxTextInsets(), + verticalAnchor = verticalAnchor.toAndroidPptxVerticalAnchor(), + rotationDegrees = rotationDegrees, + renderText = renderText, + fontScale = fontScale, + lineSpacingReduction = lineSpacingReduction, + autoFitMode = autoFitMode.toAndroidPptxAutoFitMode(), + customGeometry = customGeometry?.toAndroidPptxCustomGeometry() ) - } - - private fun parseImage( - zip: ZipFile, - element: Element, - relationships: PptxRelationships - ): PptxImageElement? { - val blip = element.firstByLocalTag("blip") ?: return null - val relId = blip.xmlAttr("r:embed") ?: blip.xmlAttr("r:link") ?: return null - val rel = relationships.byId[relId] ?: return null - val target = rel.resolvedTarget - val entry = zip.getEntry(target) ?: return null - val bytes = zip.getInputStream(entry).use { it.readBytes() } - val crop = element.firstByLocalTag("srcRect")?.imageCrop() ?: PptxImageCrop() - val bounds = element.childrenByLocalTag("spPr").firstOrNull()?.boundsFromTransform() - ?: element.boundsFromTransform() - return PptxImageElement( - bounds = bounds, + is SharedPptxImageElement -> PptxImageElement( + bounds = bounds.toRectF(), bytes = bytes, - contentType = target.imageContentType(), - crop = crop, - rotationDegrees = element.childrenByLocalTag("spPr").firstOrNull()?.rotationDegreesFromTransform() - ?: element.rotationDegreesFromTransform(), - opacity = blip.imageOpacity() + contentType = contentType, + crop = crop.toAndroidPptxImageCrop(), + rotationDegrees = rotationDegrees, + opacity = opacity ) - } - - private fun parseGraphicFrame( - element: Element, - relationships: PptxRelationships, - theme: PptxTheme, - tableStyles: Map - ): PptxElement? { - val table = element.firstByLocalTag("tbl") ?: return parseGraphicPlaceholder(element, relationships, theme) - val bounds = element.boundsFromTransform() - val tblPr = table.childrenByLocalTag("tblPr").firstOrNull() - val tableStyle = tblPr - ?.firstDirectByLocalTag("tableStyleId") - ?.wholeText() - ?.trim() - ?.let { tableStyles[it] } - val styleOptions = PptxTableStyleOptions( - firstRow = tblPr?.xmlAttr("firstRow").isTruthyXmlFlag(), - lastRow = tblPr?.xmlAttr("lastRow").isTruthyXmlFlag(), - firstColumn = tblPr?.xmlAttr("firstCol").isTruthyXmlFlag(), - lastColumn = tblPr?.xmlAttr("lastCol").isTruthyXmlFlag(), - bandRow = tblPr?.xmlAttr("bandRow").isTruthyXmlFlag() - ) - val gridWidths = table.firstByLocalTag("tblGrid") - ?.childrenByLocalTag("gridCol") - ?.map { it.xmlFloat("w")?.emuToPoint() } - .orEmpty() - val rowElements = table.childrenByLocalTag("tr") - val rows = rowElements.mapIndexed { rowIndex, row -> - val cells = row.childrenByLocalTag("tc").mapIndexed { index, cell -> - val tcPr = cell.childrenByLocalTag("tcPr").firstOrNull() - val style = tableStyle?.cellStyle( - rowIndex = rowIndex, - columnIndex = index, - rowCount = rowElements.size, - columnCount = gridWidths.size.coerceAtLeast(row.childrenByLocalTag("tc").size), - options = styleOptions - ) - val textInsets = tcPr?.textInsets() ?: PptxTextInsets(left = 3.6f, top = 3.6f, right = 3.6f, bottom = 3.6f) - PptxTableCell( - widthPoint = gridWidths.getOrNull(index), - fillColor = when { - tcPr?.firstDirectByLocalTag("noFill") != null -> null - else -> tcPr?.solidFillColor(theme) ?: style?.fillColor - }, - lineColor = tcPr?.tableCellLineColor(theme) ?: style?.lineColor, - paragraphs = parseTextBody( - txBody = cell.childrenByLocalTag("txBody").firstOrNull(), - theme = theme, - shapeRunDefaults = style?.run ?: PptxRunStyle() - ), - textInsets = textInsets, - verticalAnchor = tcPr?.verticalAnchor() ?: PptxVerticalAnchor.TOP - ) - } - PptxTableRow( - heightPoint = row.xmlFloat("h")?.emuToPoint(), - cells = cells - ) - } - if (rows.all { row -> row.cells.all { it.paragraphs.isEmpty() } }) return null - return PptxTableElement( - bounds = bounds, - rows = rows, - rotationDegrees = element.rotationDegreesFromTransform() - ) - } - - private fun parseGraphicPlaceholder( - element: Element, - relationships: PptxRelationships, - theme: PptxTheme - ): PptxElement? { - val bounds = element.boundsFromTransform() - if (bounds.width() <= 0f || bounds.height() <= 0f) return null - val chartRel = element.firstByLocalTag("chart")?.xmlAttr("r:id") - val diagramRel = element.firstByLocalTag("relIds")?.xmlAttr("r:dm") - val mediaRel = element.firstByLocalTag("videoFile")?.xmlAttr("r:link") - ?: element.firstByLocalTag("audioFile")?.xmlAttr("r:link") - val label = when { - chartRel != null -> "Chart" - diagramRel != null -> "SmartArt" - mediaRel != null -> "Media" - else -> return null - } - val target = (chartRel ?: diagramRel ?: mediaRel)?.let { relationships.byId[it]?.resolvedTarget } - return PptxShapeElement( - bounds = bounds, - preset = "rect", - fillColor = Color.rgb(245, 246, 248), - gradientFill = null, - lineColor = theme.color("tx1") ?: Color.GRAY, - lineWidthPoint = 0.75f, - paragraphs = listOf( - PptxParagraph( - runs = listOf(PptxTextRun(target?.let { "$label: ${it.substringAfterLast('/')}" } ?: label)), - alignment = PptxTextAlign.CENTER - ) - ), - hyperlink = null, - placeholderKey = null, - textInsets = PptxTextInsets(left = 8f, top = 8f, right = 8f, bottom = 8f), - verticalAnchor = PptxVerticalAnchor.MIDDLE, - rotationDegrees = element.rotationDegreesFromTransform() - ) - } - - private fun parseTextBody( - txBody: Element?, - theme: PptxTheme, - inheritedStyles: Map = emptyMap(), - shapeRunDefaults: PptxRunStyle = PptxRunStyle() - ): List { - if (txBody == null) return emptyList() - val localStyles = txBody.childrenByLocalTag("lstStyle") - .firstOrNull() - ?.paragraphStyles(theme) - .orEmpty() - val styles = inheritedStyles.mergeStyles(localStyles) - val numberCounters = IntArray(MAX_NUMBERING_LEVELS) - val numberCounterStarted = BooleanArray(MAX_NUMBERING_LEVELS) - return txBody.childrenByLocalTag("p").mapNotNull { paragraph -> - val pPr = paragraph.childrenByLocalTag("pPr").firstOrNull() - val endParaRunPr = paragraph.childrenByLocalTag("endParaRPr").firstOrNull() - val level = pPr?.xmlInt("lvl")?.coerceAtLeast(0) ?: 0 - val paragraphStyle = (styles[level] ?: styles[0] ?: PptxParagraphStyle()) - .merge(pPr?.paragraphStyle(theme) ?: PptxParagraphStyle()) - val paragraphRunStyle = shapeRunDefaults - .merge(paragraphStyle.run) - .merge(endParaRunPr?.runStyle(theme) ?: PptxRunStyle()) - val runs = mutableListOf() - paragraph.children().forEach { child -> - when (child.localTag()) { - "r", "fld" -> { - val rPr = child.childrenByLocalTag("rPr").firstOrNull() - val runStyle = paragraphRunStyle.merge(rPr?.runStyle(theme) ?: PptxRunStyle()) - val text = child.firstByLocalTag("t")?.wholeText().orEmpty() - if (text.isNotEmpty()) { - runs += PptxTextRun( - text = text, - sizePt = runStyle.sizePt, - color = runStyle.color, - bold = runStyle.bold ?: false, - italic = runStyle.italic ?: false, - typeface = runStyle.typeface, - baseline = runStyle.baseline ?: 0f, - sizeExplicit = rPr?.xmlAttr("sz") != null, - colorExplicit = rPr?.hasTextColor() == true, - boldExplicit = rPr?.xmlAttr("b") != null, - italicExplicit = rPr?.xmlAttr("i") != null, - typefaceExplicit = rPr?.hasTypeface() == true, - baselineExplicit = rPr?.xmlAttr("baseline") != null - ) - } - } - "br" -> { - val rPr = child.childrenByLocalTag("rPr").firstOrNull() - val runStyle = paragraphRunStyle.merge(rPr?.runStyle(theme) ?: PptxRunStyle()) - runs += PptxTextRun( - "\n", - sizePt = runStyle.sizePt, - color = runStyle.color, - bold = runStyle.bold ?: false, - italic = runStyle.italic ?: false, - typeface = runStyle.typeface, - baseline = runStyle.baseline ?: 0f - ) - } - "tab" -> runs += PptxTextRun( - "\t", - sizePt = paragraphRunStyle.sizePt, - color = paragraphRunStyle.color, - bold = paragraphRunStyle.bold ?: false, - italic = paragraphRunStyle.italic ?: false, - typeface = paragraphRunStyle.typeface, - baseline = paragraphRunStyle.baseline ?: 0f - ) - } - } - val safeRuns = runs.ifEmpty { listOf(PptxTextRun("")) } - if (safeRuns.none { it.text.isNotBlank() }) return@mapNotNull null - val bullet = paragraphStyle.resolvedBullet(level, numberCounters, numberCounterStarted) - PptxParagraph( - runs = safeRuns, - alignment = paragraphStyle.alignment ?: PptxTextAlign.START, - bullet = bullet, - level = level, - marginLeftPt = paragraphStyle.marginLeftPt, - indentPt = paragraphStyle.indentPt, - spaceBeforePt = paragraphStyle.spaceBeforePt ?: 0f, - spaceAfterPt = paragraphStyle.spaceAfterPt ?: 0f, - lineSpacingMultiple = paragraphStyle.lineSpacingMultiple ?: DEFAULT_LINE_SPACING_MULTIPLE, - alignmentExplicit = pPr?.xmlAttr("algn") != null, - bulletExplicit = pPr?.hasBulletDefinition() == true, - spaceBeforeExplicit = pPr?.firstByLocalTag("spcBef") != null, - spaceAfterExplicit = pPr?.firstByLocalTag("spcAft") != null, - lineSpacingExplicit = pPr?.firstByLocalTag("lnSpc") != null - ) - } - } - - private fun parseTheme(document: Element): PptxTheme { - val scheme = document.firstByLocalTag("clrScheme") ?: return PptxTheme() - val colors = scheme.children().mapNotNull { colorNode -> - val value = colorNode.firstByLocalTag("srgbClr")?.xmlAttr("val")?.toColorOrNull() - ?: colorNode.firstByLocalTag("sysClr")?.xmlAttr("lastClr")?.toColorOrNull() - value?.let { colorNode.localTag() to it } - }.toMap() - val fontScheme = document.firstByLocalTag("fontScheme") - val majorTypeface = fontScheme?.firstByLocalTag("majorFont") - ?.firstByLocalTag("latin") - ?.xmlAttr("typeface") - ?.takeIf { it.isNotBlank() } - val minorTypeface = fontScheme?.firstByLocalTag("minorFont") - ?.firstByLocalTag("latin") - ?.xmlAttr("typeface") - ?.takeIf { it.isNotBlank() } - val aliases = buildMap { - putAll(colors) - colors["lt1"]?.let { put("bg1", it) } - colors["dk1"]?.let { put("tx1", it) } - colors["lt2"]?.let { put("bg2", it) } - colors["dk2"]?.let { put("tx2", it) } - } - return PptxTheme( - colors = aliases, - majorTypeface = majorTypeface, - minorTypeface = minorTypeface - ) - } - - private fun Element.presentationTextDefaults(theme: PptxTheme): PptxTextDefaults { - val defaults = firstByLocalTag("defaultTextStyle") - ?.paragraphStyles(theme) - .orEmpty() - if (defaults.isEmpty()) return PptxTextDefaults() - return PptxTextDefaults(title = defaults, body = defaults, other = defaults) - } - - private fun ZipFile.xml(path: String): Element? { - val entry = getEntry(path) ?: return null - return getInputStream(entry).use { input -> - Jsoup.parse(input, null, "", Parser.xmlParser()) - } - } - - private fun ZipFile.relationshipsFor(partPath: String): PptxRelationships { - val relsPath = partPath.relationshipsPath() - val document = xml(relsPath) ?: return PptxRelationships(emptyMap()) - val rels = document.allByLocalTag("Relationship").mapNotNull { rel -> - val id = rel.xmlAttr("Id") ?: return@mapNotNull null - val target = rel.xmlAttr("Target") ?: return@mapNotNull null - val type = rel.xmlAttr("Type").orEmpty() - PptxRelationship( - id = id, - target = target, - resolvedTarget = resolveRelationshipTarget(partPath, target, rel.xmlAttr("TargetMode")), - type = type, - targetMode = rel.xmlAttr("TargetMode") - ) - }.associateBy { it.id } - return PptxRelationships(rels) - } - - private fun ZipFile.tableStyles(theme: PptxTheme): Map { - val document = xml("ppt/tableStyles.xml") ?: return emptyMap() - return document.allByLocalTag("tblStyle").mapNotNull { style -> - val id = style.xmlAttr("styleId") ?: return@mapNotNull null - id to PptxTableStyle( - whole = style.childrenByLocalTag("wholeTbl").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), - firstRow = style.childrenByLocalTag("firstRow").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), - lastRow = style.childrenByLocalTag("lastRow").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), - firstColumn = style.childrenByLocalTag("firstCol").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), - lastColumn = style.childrenByLocalTag("lastCol").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), - band1Horizontal = style.childrenByLocalTag("band1H").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle(), - band2Horizontal = style.childrenByLocalTag("band2H").firstOrNull()?.tableStylePart(theme) ?: PptxTableCellStyle() - ) - }.toMap() - } -} - -private fun PptxTheme.withColorMap(mapping: Element?): PptxTheme { - if (mapping == null) return this - val mappedColors = colors.toMutableMap() - listOf("bg1", "tx1", "bg2", "tx2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink") - .forEach { alias -> - val target = mapping.xmlAttr(alias) ?: return@forEach - color(target)?.let { mappedColors[alias.lowercase(Locale.ROOT)] = it } - } - return copy(colors = mappedColors) -} - -private fun Element.colorMapElement(): Element? { - firstByLocalTag("overrideClrMapping")?.let { return it } - if (firstByLocalTag("masterClrMapping") != null) return null - return firstByLocalTag("clrMap") -} - -private fun inheritPlaceholderProperties( - slideElements: List, - inheritedElements: List -): List { - val inheritedPlaceholders = inheritedElements - .filterIsInstance() - .filter { it.placeholderKey != null && it.bounds.width() > 0f && it.bounds.height() > 0f } - - if (inheritedPlaceholders.isEmpty()) return slideElements - - return slideElements.map { element -> - val shape = element as? PptxShapeElement ?: return@map element - val key = shape.placeholderKey ?: return@map shape - - val inherited = inheritedPlaceholders.lastOrNull { inherited -> - inherited.placeholderKey?.matches(key) == true - } ?: return@map shape - val shouldInheritBounds = shape.bounds.width() <= 0f || shape.bounds.height() <= 0f - - shape.copy( - bounds = if (shouldInheritBounds) RectF(inherited.bounds) else shape.bounds, - preset = if (shouldInheritBounds && shape.preset == "rect") inherited.preset else shape.preset, - fillColor = shape.fillColor ?: inherited.fillColor, - gradientFill = shape.gradientFill ?: inherited.gradientFill, - lineColor = shape.lineColor ?: inherited.lineColor, - lineWidthPoint = if (shape.lineWidthPoint == 0.75f) inherited.lineWidthPoint else shape.lineWidthPoint, - paragraphs = shape.paragraphs.inheritTextStyles(inherited.paragraphs), - textInsets = if (shape.textInsets == PptxTextInsets()) inherited.textInsets else shape.textInsets, - verticalAnchor = if (shape.verticalAnchor == PptxVerticalAnchor.TOP) inherited.verticalAnchor else shape.verticalAnchor, - rotationDegrees = if (shape.rotationDegrees == 0f) inherited.rotationDegrees else shape.rotationDegrees, - fontScale = if (shape.fontScale == 1f) inherited.fontScale else shape.fontScale, - lineSpacingReduction = if (shape.lineSpacingReduction == 0f) { - inherited.lineSpacingReduction - } else { - shape.lineSpacingReduction - }, - autoFitMode = if (shape.autoFitMode == PptxAutoFitMode.NONE) inherited.autoFitMode else shape.autoFitMode, - customGeometry = shape.customGeometry ?: inherited.customGeometry + is SharedPptxTableElement -> PptxTableElement( + bounds = bounds.toRectF(), + rows = rows.map { it.toAndroidPptxTableRow() }, + rotationDegrees = rotationDegrees ) } } -private fun List.inheritTextStyles(fallback: List): List { - if (isEmpty() || fallback.isEmpty()) return this - return mapIndexed { index, paragraph -> - val fallbackParagraph = fallback.firstOrNull { it.level == paragraph.level } - ?: fallback.getOrNull(index) - ?: fallback.first() - paragraph.inheritTextStyle(fallbackParagraph) - } +private fun SharedPptxRect.toRectF(): RectF = RectF(left, top, right, bottom) + +private fun SharedPptxImageCrop.toAndroidPptxImageCrop(): PptxImageCrop { + return PptxImageCrop(left = left, top = top, right = right, bottom = bottom) } -private fun PptxParagraph.inheritTextStyle(fallback: PptxParagraph): PptxParagraph { - val fallbackRun = fallback.runs.firstOrNull() - return copy( - runs = runs.map { run -> run.inheritTextStyle(fallbackRun) }, - alignment = if (alignmentExplicit) alignment else fallback.alignment, - bullet = if (bulletExplicit) bullet else fallback.bullet, - marginLeftPt = marginLeftPt ?: fallback.marginLeftPt, - indentPt = indentPt ?: fallback.indentPt, - spaceBeforePt = if (spaceBeforeExplicit) spaceBeforePt else fallback.spaceBeforePt, - spaceAfterPt = if (spaceAfterExplicit) spaceAfterPt else fallback.spaceAfterPt, - lineSpacingMultiple = if (lineSpacingExplicit) lineSpacingMultiple else fallback.lineSpacingMultiple +private fun SharedPptxTableRow.toAndroidPptxTableRow(): PptxTableRow { + return PptxTableRow( + heightPoint = heightPoint, + cells = cells.map { it.toAndroidPptxTableCell() } ) } -private fun PptxTextRun.inheritTextStyle(fallback: PptxTextRun?): PptxTextRun { - if (fallback == null) return this - return copy( - sizePt = if (sizeExplicit) sizePt else sizePt ?: fallback.sizePt, - color = if (colorExplicit) color else color ?: fallback.color, - bold = if (boldExplicit) bold else fallback.bold || bold, - italic = if (italicExplicit) italic else fallback.italic || italic, - typeface = if (typefaceExplicit) typeface else typeface ?: fallback.typeface, - baseline = if (baselineExplicit) baseline else baseline.takeUnless { it == 0f } ?: fallback.baseline +private fun SharedPptxTableCell.toAndroidPptxTableCell(): PptxTableCell { + return PptxTableCell( + widthPoint = widthPoint, + fillColor = fillColor, + lineColor = lineColor, + paragraphs = paragraphs.map { it.toAndroidPptxParagraph() }, + textInsets = textInsets.toAndroidPptxTextInsets(), + verticalAnchor = verticalAnchor.toAndroidPptxVerticalAnchor() ) } +private fun SharedPptxParagraph.toAndroidPptxParagraph(): PptxParagraph { + return PptxParagraph( + runs = runs.map { it.toAndroidPptxTextRun() }, + alignment = alignment.toAndroidPptxTextAlign(), + bullet = bullet, + level = level, + marginLeftPt = marginLeftPt, + indentPt = indentPt, + spaceBeforePt = spaceBeforePt, + spaceAfterPt = spaceAfterPt, + lineSpacingMultiple = lineSpacingMultiple, + alignmentExplicit = alignmentExplicit, + bulletExplicit = bulletExplicit, + spaceBeforeExplicit = spaceBeforeExplicit, + spaceAfterExplicit = spaceAfterExplicit, + lineSpacingExplicit = lineSpacingExplicit + ) +} + +private fun SharedPptxTextRun.toAndroidPptxTextRun(): PptxTextRun { + return PptxTextRun( + text = text, + sizePt = sizePt, + color = color, + bold = bold, + italic = italic, + typeface = typeface, + baseline = baseline, + sizeExplicit = sizeExplicit, + colorExplicit = colorExplicit, + boldExplicit = boldExplicit, + italicExplicit = italicExplicit, + typefaceExplicit = typefaceExplicit, + baselineExplicit = baselineExplicit + ) +} + +private fun SharedPptxTextAlign.toAndroidPptxTextAlign(): PptxTextAlign { + return when (this) { + SharedPptxTextAlign.START -> PptxTextAlign.START + SharedPptxTextAlign.CENTER -> PptxTextAlign.CENTER + SharedPptxTextAlign.END -> PptxTextAlign.END + } +} + +private fun SharedPptxVerticalAnchor.toAndroidPptxVerticalAnchor(): PptxVerticalAnchor { + return when (this) { + SharedPptxVerticalAnchor.TOP -> PptxVerticalAnchor.TOP + SharedPptxVerticalAnchor.MIDDLE -> PptxVerticalAnchor.MIDDLE + SharedPptxVerticalAnchor.BOTTOM -> PptxVerticalAnchor.BOTTOM + } +} + +private fun SharedPptxAutoFitMode.toAndroidPptxAutoFitMode(): PptxAutoFitMode { + return when (this) { + SharedPptxAutoFitMode.NONE -> PptxAutoFitMode.NONE + SharedPptxAutoFitMode.NORMAL -> PptxAutoFitMode.NORMAL + SharedPptxAutoFitMode.SHAPE -> PptxAutoFitMode.SHAPE + } +} + +private fun SharedPptxTextInsets.toAndroidPptxTextInsets(): PptxTextInsets { + return PptxTextInsets(left = left, top = top, right = right, bottom = bottom) +} + +private fun SharedPptxGradientFill.toAndroidPptxGradientFill(): PptxGradientFill { + return PptxGradientFill(startColor = startColor, endColor = endColor, angleDegrees = angleDegrees) +} + +private fun SharedPptxCustomGeometry.toAndroidPptxCustomGeometry(): PptxCustomGeometry { + return PptxCustomGeometry( + width = width, + height = height, + commands = commands.map { it.toAndroidPptxPathCommand() } + ) +} + +private fun SharedPptxPathCommand.toAndroidPptxPathCommand(): PptxPathCommand { + return when (this) { + is SharedPptxPathCommand.MoveTo -> PptxPathCommand.MoveTo(x, y) + is SharedPptxPathCommand.LineTo -> PptxPathCommand.LineTo(x, y) + is SharedPptxPathCommand.QuadTo -> PptxPathCommand.QuadTo(x1, y1, x2, y2) + is SharedPptxPathCommand.CubicTo -> PptxPathCommand.CubicTo(x1, y1, x2, y2, x3, y3) + SharedPptxPathCommand.Close -> PptxPathCommand.Close + } +} + internal class PptxDocumentWrapper( private val file: File, private val deleteOnClose: Boolean = false @@ -1342,123 +631,6 @@ internal class PptxCoverGenerator(context: Context) { } } -private data class PptxTextIndex( - val text: String, - val charBoxes: List -) - -private object PptxTextIndexer { - fun index(elements: List): PptxTextIndex { - val text = StringBuilder() - val charBoxes = mutableListOf() - elements.forEach { element -> - when (element) { - is PptxShapeElement -> appendShapeText(element, text, charBoxes) - is PptxTableElement -> layoutTableCells(element).forEach { cell -> - appendShapeText(cell.asShape(), text, charBoxes) - } - is PptxImageElement -> Unit - } - } - val indexedText = text.toString().trimEnd() - return PptxTextIndex(indexedText, charBoxes.take(indexedText.length)) - } - - private fun appendShapeText( - shape: PptxShapeElement, - text: StringBuilder, - charBoxes: MutableList - ) { - if (!shape.renderText) return - val textBounds = shape.textBounds() - if (textBounds.width() <= 0f || textBounds.height() <= 0f) return - val paragraphs = shape.paragraphs - .mapNotNull { paragraph -> paragraph.displayText().takeIf { it.isNotBlank() }?.let { paragraph to it } } - if (paragraphs.isEmpty()) return - - val paragraphHeights = paragraphs.map { (paragraph, displayText) -> - paragraph.spaceBeforePt + displayText.lineCount() * paragraph.approximateLineHeight(shape) + paragraph.spaceAfterPt - } - val totalHeight = paragraphHeights.sumOf { it.toDouble() }.toFloat() - val effectiveBounds = if (shape.autoFitMode == PptxAutoFitMode.SHAPE && totalHeight > textBounds.height()) { - RectF(textBounds).apply { bottom = top + totalHeight } - } else { - textBounds - } - var y = when (shape.verticalAnchor) { - PptxVerticalAnchor.TOP -> effectiveBounds.top - PptxVerticalAnchor.MIDDLE -> effectiveBounds.top + ((effectiveBounds.height() - totalHeight) / 2f).coerceAtLeast(0f) - PptxVerticalAnchor.BOTTOM -> effectiveBounds.bottom - totalHeight.coerceAtMost(effectiveBounds.height()) - } - - paragraphs.forEachIndexed { index, (paragraph, displayText) -> - y += paragraph.spaceBeforePt - displayText.lines().forEach { line -> - if (shape.autoFitMode != PptxAutoFitMode.SHAPE && y > effectiveBounds.bottom) return@forEach - appendLine(line, paragraph, shape, effectiveBounds, y, text, charBoxes) - y += paragraph.approximateLineHeight(shape) - } - y += paragraph.spaceAfterPt - if (index < paragraphs.lastIndex && text.lastOrNull() != '\n') { - text.append('\n') - charBoxes += PptxCharBox('\n', RectF(shape.bounds.left, shape.bounds.bottom, shape.bounds.left, shape.bounds.bottom)) - } - } - } - - private fun appendLine( - line: String, - paragraph: PptxParagraph, - shape: PptxShapeElement, - textBounds: RectF, - y: Float, - text: StringBuilder, - charBoxes: MutableList - ) { - if (line.isEmpty()) { - text.append('\n') - charBoxes += PptxCharBox('\n', RectF(textBounds.left, y, textBounds.left, y)) - return - } - val fontSize = scaledTextSize(paragraph.runs.firstOrNull()?.sizePt, shape.fontScale).toFloat() - val estimatedWidth = line.sumOf { char -> - when { - char.isWhitespace() -> 0.33 - char in "ilI.,;:!|" -> 0.3 - char in "MW@#%&" -> 0.85 - else -> 0.55 - } - }.toFloat() * fontSize - val maxLineWidth = textBounds.width().coerceAtLeast(0.5f) - val minLineWidth = min(line.length * 0.5f, maxLineWidth) - val lineWidth = estimatedWidth.coerceIn(minLineWidth, maxLineWidth) - val charAdvance = (lineWidth / line.length.coerceAtLeast(1)).coerceAtLeast(0.5f) - val startX = when (paragraph.alignment) { - PptxTextAlign.START -> textBounds.left - PptxTextAlign.CENTER -> textBounds.left + ((textBounds.width() - lineWidth) / 2f).coerceAtLeast(0f) - PptxTextAlign.END -> textBounds.right - lineWidth - } - val bottom = y + paragraph.approximateLineHeight(shape) - - text.append(line) - line.forEachIndexed { index, char -> - val left = startX + index * charAdvance - val right = if (index == line.lastIndex) startX + lineWidth else left + charAdvance - charBoxes += PptxCharBox( - char = char, - bounds = RectF(left, y, right, bottom).rotatedBounds(shape.bounds, shape.rotationDegrees) - ) - } - } - - private fun String.lineCount(): Int = lines().size.coerceAtLeast(1) - - private fun PptxParagraph.approximateLineHeight(shape: PptxShapeElement): Float { - val fontSize = scaledTextSize(runs.firstOrNull()?.sizePt, shape.fontScale).toFloat() - return (fontSize * 1.2f * effectiveLineSpacing(shape)).coerceAtLeast(1f) - } -} - private data class LaidOutParagraph( val text: String, val layout: StaticLayout, @@ -1488,7 +660,6 @@ private fun LaidOutTableCell.asShape(): PptxShapeElement { lineWidthPoint = 0.75f, paragraphs = cell.paragraphs, hyperlink = null, - placeholderKey = null, textInsets = cell.textInsets, verticalAnchor = cell.verticalAnchor ) @@ -1932,192 +1103,12 @@ private fun PptxGradientFill.toShader(bounds: RectF): Shader { ) } -private fun Element.textDefaults(theme: PptxTheme): PptxTextDefaults { - val txStyles = firstByLocalTag("txStyles") ?: return PptxTextDefaults() - return PptxTextDefaults( - title = txStyles.childrenByLocalTag("titleStyle").firstOrNull()?.paragraphStyles(theme).orEmpty(), - body = txStyles.childrenByLocalTag("bodyStyle").firstOrNull()?.paragraphStyles(theme).orEmpty(), - other = txStyles.childrenByLocalTag("otherStyle").firstOrNull()?.paragraphStyles(theme).orEmpty() - ) -} - -private fun Element.paragraphStyles(theme: PptxTheme): Map { - return children().mapNotNull { child -> - val tag = child.localTag() - val level = when { - tag == "defppr" -> 0 - tag.startsWith("lvl") && tag.endsWith("ppr") -> { - tag.removePrefix("lvl").removeSuffix("ppr").toIntOrNull()?.minus(1) - } - else -> null - } ?: return@mapNotNull null - level.coerceAtLeast(0) to child.paragraphStyle(theme) - }.toMap() -} - -private fun Element.paragraphStyle(theme: PptxTheme): PptxParagraphStyle { - val autoNumber = firstDirectByLocalTag("buAutoNum") - val bulletTypeface = firstDirectByLocalTag("buFont")?.xmlAttr("typeface") - val bullet = when { - firstDirectByLocalTag("buNone") != null -> null - autoNumber != null -> null - else -> firstDirectByLocalTag("buChar")?.xmlAttr("char")?.normalizeBulletGlyph(bulletTypeface) - } - return PptxParagraphStyle( - alignment = when (xmlAttr("algn")) { - "l", "just", "justLow", "dist", "thaiDist" -> PptxTextAlign.START - "ctr" -> PptxTextAlign.CENTER - "r" -> PptxTextAlign.END - else -> null - }, - bullet = bullet, - autoNumberType = autoNumber?.xmlAttr("type"), - autoNumberStartAt = autoNumber?.xmlInt("startAt"), - bulletExplicit = hasBulletDefinition(), - marginLeftPt = xmlFloat("marL")?.emuToPoint(), - indentPt = xmlFloat("indent")?.emuToPoint(), - spaceBeforePt = firstDirectByLocalTag("spcBef")?.spacingPoints(), - spaceAfterPt = firstDirectByLocalTag("spcAft")?.spacingPoints(), - lineSpacingMultiple = firstDirectByLocalTag("lnSpc")?.spacingMultiple(), - run = childrenByLocalTag("defRPr").firstOrNull()?.runStyle(theme) ?: PptxRunStyle() - ) -} - -private fun Element.runStyle(theme: PptxTheme): PptxRunStyle { - return PptxRunStyle( - sizePt = xmlFloat("sz")?.let { it / 100f }, - color = solidFillColor(theme), - bold = xmlAttr("b")?.isTruthyXmlFlag(), - italic = xmlAttr("i")?.isTruthyXmlFlag(), - typeface = typefaceName(theme), - baseline = xmlFloat("baseline")?.let { it / 100_000f } - ) -} - -private fun PptxParagraphStyle.resolvedBullet( - level: Int, - counters: IntArray, - counterStarted: BooleanArray -): String? { - val numberType = autoNumberType - if (numberType != null) { - val index = level.coerceIn(0, MAX_NUMBERING_LEVELS - 1) - if (!counterStarted[index]) { - counters[index] = (autoNumberStartAt ?: 1).coerceAtLeast(1) - counterStarted[index] = true - } else { - counters[index] += 1 - } - for (resetIndex in index + 1 until MAX_NUMBERING_LEVELS) { - counterStarted[resetIndex] = false - counters[resetIndex] = 0 - } - return formatAutoNumber(counters[index], numberType) - } - return bullet -} - -private fun formatAutoNumber(number: Int, type: String): String { - val normalized = type.lowercase(Locale.ROOT) - val value = when { - normalized.startsWith("alphalc") -> number.toAlphabeticLabel().lowercase(Locale.ROOT) - normalized.startsWith("alphauc") -> number.toAlphabeticLabel() - normalized.startsWith("romanlc") -> number.toRomanNumeral().lowercase(Locale.ROOT) - normalized.startsWith("romanuc") -> number.toRomanNumeral() - else -> number.toString() - } - return when { - "parenboth" in normalized -> "($value)" - "parenr" in normalized -> "$value)" - "period" in normalized -> "$value." - else -> value - } -} - -private fun Int.toAlphabeticLabel(): String { - var value = coerceAtLeast(1) - val result = StringBuilder() - while (value > 0) { - value -= 1 - result.insert(0, ('A'.code + (value % 26)).toChar()) - value /= 26 - } - return result.toString() -} - -private fun Int.toRomanNumeral(): String { - var value = coerceIn(1, 3999) - val numerals = listOf( - 1000 to "M", - 900 to "CM", - 500 to "D", - 400 to "CD", - 100 to "C", - 90 to "XC", - 50 to "L", - 40 to "XL", - 10 to "X", - 9 to "IX", - 5 to "V", - 4 to "IV", - 1 to "I" - ) - return buildString { - numerals.forEach { (amount, numeral) -> - while (value >= amount) { - append(numeral) - value -= amount - } - } - } -} - -private fun String.normalizeBulletGlyph(typeface: String?): String { - val family = typeface.orEmpty().lowercase(Locale.ROOT) - if ("wingdings" !in family && "symbol" !in family) return this - return when (this) { - "\u00A7", "\u00D8", "\u00B7", "\uF0B7" -> "\u2022" - "\u00FC", "\uF0FC" -> "\u2713" - "\u00A8", "\uF0A8" -> "\u25E6" - else -> this - } -} - -private fun Map.mergeStyles( - overrides: Map -): Map { - if (isEmpty()) return overrides - if (overrides.isEmpty()) return this - return buildMap { - putAll(this@mergeStyles) - overrides.forEach { (level, style) -> - put(level, this@mergeStyles[level]?.merge(style) ?: style) - } - } -} - private fun scaledTextSize(sizePt: Float?, fontScale: Float): Int { return ((sizePt ?: DEFAULT_TEXT_SIZE_PT) * fontScale.coerceIn(0.4f, 2f)) .roundToInt() .coerceAtLeast(1) } -private fun Element.imageCrop(): PptxImageCrop { - return PptxImageCrop( - left = xmlFloat("l")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f, - top = xmlFloat("t")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f, - right = xmlFloat("r")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f, - bottom = xmlFloat("b")?.let { it / 100_000f }?.coerceIn(0f, 1f) ?: 0f - ) -} - -private fun Element.imageOpacity(): Float { - firstByLocalTag("alphaModFix")?.xmlFloat("amt")?.let { return (it / 100_000f).coerceIn(0f, 1f) } - firstByLocalTag("alphaMod")?.xmlFloat("amt")?.let { return (it / 100_000f).coerceIn(0f, 1f) } - firstByLocalTag("alpha")?.xmlFloat("val")?.let { return (it / 100_000f).coerceIn(0f, 1f) } - return 1f -} - private fun PptxImageCrop.sourceRect(width: Int, height: Int): Rect { val leftPx = (width * left).roundToInt().coerceIn(0, width - 1) val topPx = (height * top).roundToInt().coerceIn(0, height - 1) @@ -2125,420 +1116,3 @@ private fun PptxImageCrop.sourceRect(width: Int, height: Int): Rect { val bottomPx = (height * (1f - bottom)).roundToInt().coerceIn(topPx + 1, height) return Rect(leftPx, topPx, rightPx, bottomPx) } - -private fun Element.customGeometry(): PptxCustomGeometry? { - val paths = firstByLocalTag("pathLst")?.childrenByLocalTag("path").orEmpty() - if (paths.isEmpty()) return null - val width = paths.first().xmlFloat("w")?.takeIf { it > 0f } ?: return null - val height = paths.first().xmlFloat("h")?.takeIf { it > 0f } ?: return null - val commands = paths.flatMap { path -> - path.children().mapNotNull { command -> - when (command.localTag()) { - "moveto" -> command.firstDirectByLocalTag("pt")?.pathPoint()?.let { PptxPathCommand.MoveTo(it.x, it.y) } - "lnto" -> command.firstDirectByLocalTag("pt")?.pathPoint()?.let { PptxPathCommand.LineTo(it.x, it.y) } - "quadbezto" -> { - val points = command.childrenByLocalTag("pt").mapNotNull { it.pathPoint() } - if (points.size >= 2) { - PptxPathCommand.QuadTo(points[0].x, points[0].y, points[1].x, points[1].y) - } else { - null - } - } - "cubicbezto" -> { - val points = command.childrenByLocalTag("pt").mapNotNull { it.pathPoint() } - if (points.size >= 3) { - PptxPathCommand.CubicTo( - points[0].x, - points[0].y, - points[1].x, - points[1].y, - points[2].x, - points[2].y - ) - } else { - null - } - } - "close" -> PptxPathCommand.Close - else -> null - } - } - } - return PptxCustomGeometry(width = width, height = height, commands = commands) - .takeIf { it.commands.isNotEmpty() } -} - -private fun Element.pathPoint(): PointF? { - val x = xmlFloat("x") ?: return null - val y = xmlFloat("y") ?: return null - return PointF(x, y) -} - -private fun Element.gradientFill(theme: PptxTheme): PptxGradientFill? { - val gradFill = childrenByLocalTag("gradFill").firstOrNull() ?: return null - val stops = gradFill.firstByLocalTag("gsLst") - ?.childrenByLocalTag("gs") - ?.mapNotNull { stop -> stop.solidLikeColor(theme)?.let { stop.xmlInt("pos").orZero() to it } } - ?.sortedBy { it.first } - .orEmpty() - if (stops.size < 2) return null - val angle = gradFill.firstByLocalTag("lin")?.xmlFloat("ang")?.let { it / 60_000f } ?: 0f - return PptxGradientFill( - startColor = stops.first().second, - endColor = stops.last().second, - angleDegrees = angle - ) -} - -private fun Element.solidLikeColor(theme: PptxTheme): Int? { - firstByLocalTag("srgbClr")?.let { color -> - return color.xmlAttr("val")?.toColorOrNull()?.applyLuminance(color) - } - firstByLocalTag("schemeClr")?.let { color -> - val scheme = color.xmlAttr("val") ?: return null - return theme.color(scheme)?.applyLuminance(color) - } - firstByLocalTag("prstClr")?.let { color -> - return color.xmlAttr("val")?.presetColorOrNull()?.applyLuminance(color) - } - firstByLocalTag("sysClr")?.let { color -> - return color.xmlAttr("lastClr")?.toColorOrNull()?.applyLuminance(color) - } - return null -} - -private fun Element.textInsets(): PptxTextInsets { - return PptxTextInsets( - left = (xmlFloat("lIns") ?: xmlFloat("marL"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT, - top = (xmlFloat("tIns") ?: xmlFloat("marT"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT, - right = (xmlFloat("rIns") ?: xmlFloat("marR"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT, - bottom = (xmlFloat("bIns") ?: xmlFloat("marB"))?.emuToPoint() ?: DEFAULT_TEXT_MARGIN_PT - ) -} - -private fun Element.verticalAnchor(): PptxVerticalAnchor { - return when (xmlAttr("anchor")) { - "ctr" -> PptxVerticalAnchor.MIDDLE - "b" -> PptxVerticalAnchor.BOTTOM - else -> PptxVerticalAnchor.TOP - } -} - -private fun Element.tableCellLineColor(theme: PptxTheme): Int? { - val line = children().firstNotNullOfOrNull { child -> - when (child.localTag()) { - "lnl", "lnr", "lnt", "lnb", "ln" -> child - "left", "right", "top", "bottom", "insideh", "insidev" -> child.firstDirectByLocalTag("ln") - else -> null - } - } - return line?.solidFillColor(theme) -} - -private fun Element.tableStylePart(theme: PptxTheme): PptxTableCellStyle { - val tcStyle = childrenByLocalTag("tcStyle").firstOrNull() - return PptxTableCellStyle( - fillColor = tcStyle?.firstDirectByLocalTag("fill")?.solidFillColor(theme), - lineColor = tcStyle?.firstDirectByLocalTag("tcBdr")?.tableCellLineColor(theme), - run = childrenByLocalTag("tcTxStyle").firstOrNull()?.tableTextRunStyle(theme) ?: PptxRunStyle() - ) -} - -private fun Element.tableTextRunStyle(theme: PptxTheme): PptxRunStyle { - return PptxRunStyle( - color = solidLikeColor(theme), - bold = xmlAttr("b")?.isTruthyXmlFlag(), - italic = xmlAttr("i")?.isTruthyXmlFlag(), - typeface = typefaceName(theme) - ) -} - -private fun Element.spacingPoints(): Float? { - firstByLocalTag("spcPts")?.xmlFloat("val")?.let { return it / 100f } - firstByLocalTag("spcPct")?.xmlFloat("val")?.let { return DEFAULT_TEXT_SIZE_PT * (it / 100_000f) } - return null -} - -private fun Element.spacingMultiple(): Float? { - firstByLocalTag("spcPct")?.xmlFloat("val")?.let { return it / 100_000f } - return null -} - -private fun Element.autoFitFontScale(): Float? { - return firstDirectByLocalTag("normAutofit") - ?.xmlFloat("fontScale") - ?.let { it / 100_000f } - ?.coerceIn(0.4f, 2f) -} - -private fun Element.autoFitLineSpacingReduction(): Float? { - return firstDirectByLocalTag("normAutofit") - ?.xmlFloat("lnSpcReduction") - ?.let { it / 100_000f } - ?.coerceIn(0f, 0.5f) -} - -private fun Element.autoFitMode(): PptxAutoFitMode { - return when { - firstDirectByLocalTag("normAutofit") != null -> PptxAutoFitMode.NORMAL - firstDirectByLocalTag("spAutoFit") != null -> PptxAutoFitMode.SHAPE - else -> PptxAutoFitMode.NONE - } -} - -private fun Element.typefaceName(theme: PptxTheme): String? { - val raw = firstByLocalTag("latin")?.xmlAttr("typeface") - ?: firstByLocalTag("ea")?.xmlAttr("typeface") - ?: firstByLocalTag("cs")?.xmlAttr("typeface") - val resolved = when { - raw == null -> null - raw.startsWith("+mj") -> theme.majorTypeface ?: raw - raw.startsWith("+mn") -> theme.minorTypeface ?: raw - else -> raw - } - return resolved?.takeIf { it.isNotBlank() } -} - -private fun Element.boundsFromTransform(): RectF { - val xfrm = childrenByLocalTag("xfrm").firstOrNull() ?: firstByLocalTag("xfrm") - val off = xfrm?.childrenByLocalTag("off")?.firstOrNull() - val ext = xfrm?.childrenByLocalTag("ext")?.firstOrNull() - val x = off?.xmlFloat("x")?.emuToPoint() ?: 0f - val y = off?.xmlFloat("y")?.emuToPoint() ?: 0f - val cx = ext?.xmlFloat("cx")?.emuToPoint() ?: 0f - val cy = ext?.xmlFloat("cy")?.emuToPoint() ?: 0f - return RectF(x, y, x + cx, y + cy) -} - -private fun Float.emuToPoint(): Float = this / EMU_PER_POINT - -private fun Float.emuToPointInt(): Int = emuToPoint().roundToInt().coerceAtLeast(1) - -private fun Int?.orZero(): Int = this ?: 0 - -private fun Element.rotationDegreesFromTransform(): Float { - val xfrm = childrenByLocalTag("xfrm").firstOrNull() ?: firstByLocalTag("xfrm") - return xfrm?.xmlFloat("rot")?.let { it / 60_000f } ?: 0f -} - -private fun Element.solidFillColor(theme: PptxTheme): Int? { - val solid = childrenByLocalTag("solidFill").firstOrNull() ?: return null - solid.firstByLocalTag("srgbClr")?.let { color -> - return color.xmlAttr("val")?.toColorOrNull()?.applyLuminance(color) - } - solid.firstByLocalTag("schemeClr")?.let { color -> - val scheme = color.xmlAttr("val") ?: return null - return theme.color(scheme)?.applyLuminance(color) - } - solid.firstByLocalTag("prstClr")?.let { color -> - return color.xmlAttr("val")?.presetColorOrNull()?.applyLuminance(color) - } - solid.firstByLocalTag("sysClr")?.let { color -> - return color.xmlAttr("lastClr")?.toColorOrNull()?.applyLuminance(color) - } - return null -} - -private fun Element.schemeColor(theme: PptxTheme): Int? { - firstByLocalTag("schemeClr")?.let { color -> - val scheme = color.xmlAttr("val") ?: return null - return theme.color(scheme)?.applyLuminance(color) - } - firstByLocalTag("prstClr")?.let { color -> - return color.xmlAttr("val")?.presetColorOrNull()?.applyLuminance(color) - } - return xmlAttr("idx")?.let { theme.color(it) } -} - -private fun Int.applyLuminance(colorElement: Element): Int { - val shade = colorElement.firstByLocalTag("shade")?.xmlFloat("val")?.let { it / 100_000f } - val tint = colorElement.firstByLocalTag("tint")?.xmlFloat("val")?.let { it / 100_000f } - val mod = colorElement.firstByLocalTag("lumMod")?.xmlFloat("val")?.let { it / 100_000f } ?: 1f - val off = colorElement.firstByLocalTag("lumOff")?.xmlFloat("val")?.let { it / 100_000f } ?: 0f - val alpha = colorElement.firstByLocalTag("alpha")?.xmlFloat("val")?.let { it / 100_000f } ?: 1f - fun channel(value: Int): Int { - var next = value.toFloat() - shade?.let { next *= it } - tint?.let { next += (255f - next) * it } - next = (next * mod) + (255f * off) - return next.roundToInt().coerceIn(0, 255) - } - return Color.argb( - (Color.alpha(this) * alpha).roundToInt().coerceIn(0, 255), - channel(Color.red(this)), - channel(Color.green(this)), - channel(Color.blue(this)) - ) -} - -private fun String.toColorOrNull(): Int? { - val clean = trim().removePrefix("#") - if (clean.length != 6) return null - return runCatching { Color.rgb(clean.substring(0, 2).toInt(16), clean.substring(2, 4).toInt(16), clean.substring(4, 6).toInt(16)) }.getOrNull() -} - -private fun String.presetColorOrNull(): Int? { - return when (lowercase(Locale.ROOT)) { - "black" -> Color.BLACK - "white" -> Color.WHITE - "red" -> Color.RED - "green" -> Color.GREEN - "blue" -> Color.BLUE - "yellow" -> Color.YELLOW - "cyan" -> Color.CYAN - "magenta" -> Color.MAGENTA - "gray", "grey" -> Color.GRAY - "dkgray", "dkgrey" -> Color.DKGRAY - "ltgray", "ltgrey" -> Color.LTGRAY - "orange" -> Color.rgb(255, 165, 0) - "purple" -> Color.rgb(128, 0, 128) - "brown" -> Color.rgb(165, 42, 42) - else -> null - } -} - -private fun File.contentHash(): String { - return runCatching { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().use { input -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read <= 0) break - digest.update(buffer, 0, read) - } - } - digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } - }.getOrElse { - Timber.w(it, "Falling back to path-based PPTX cache key") - "${canonicalPath}:${lastModified()}" - } -} - -private fun String?.isTruthyXmlFlag(): Boolean { - return this == "1" || equals("true", ignoreCase = true) || equals("on", ignoreCase = true) -} - -private fun String.placeholderFamily(): String { - return when (this.lowercase(Locale.ROOT)) { - "ctrtitle" -> "title" - "subttl" -> "subtitle" - else -> this.lowercase(Locale.ROOT) - } -} - -private fun Element.hasBulletDefinition(): Boolean { - return firstDirectByLocalTag("buNone") != null || - firstDirectByLocalTag("buChar") != null || - firstDirectByLocalTag("buAutoNum") != null -} - -private fun Element.hasTextColor(): Boolean { - return firstDirectByLocalTag("solidFill") != null || - firstDirectByLocalTag("gradFill") != null || - firstDirectByLocalTag("noFill") != null -} - -private fun Element.hasTypeface(): Boolean { - return firstByLocalTag("latin") != null || - firstByLocalTag("ea") != null || - firstByLocalTag("cs") != null -} - -private fun Element.localTag(): String = tagName().substringAfter(':').lowercase(Locale.ROOT) - -private fun Element.xmlAttr(name: String): String? { - if (":" in name) { - return attributes().asList() - .firstOrNull { it.key.equals(name, ignoreCase = true) } - ?.value - ?.takeIf { it.isNotBlank() } - } - val expectedLocal = name.substringAfter(':') - for (attribute in attributes().asList()) { - val key = attribute.key - if (key.equals(name, ignoreCase = true) || key.substringAfter(':').equals(expectedLocal, ignoreCase = true)) { - return attribute.value.takeIf { it.isNotBlank() } - } - } - return null -} - -private fun Element.xmlInt(name: String): Int? = xmlAttr(name)?.toIntOrNull() -private fun Element.xmlFloat(name: String): Float? = xmlAttr(name)?.toFloatOrNull() - -private fun Element.placeholderKey(): PptxPlaceholderKey { - return PptxPlaceholderKey( - type = xmlAttr("type")?.lowercase(Locale.ROOT), - index = xmlAttr("idx") - ) -} - -private fun PptxPlaceholderKey.matches(other: PptxPlaceholderKey): Boolean { - if (index != null && other.index != null && index == other.index) return true - if (type != null && other.type != null && type.placeholderFamily() == other.type.placeholderFamily()) return true - return index == null && other.index == null && type == null && other.type == null -} - -private fun Element.childrenByLocalTag(tag: String): List { - val local = tag.lowercase(Locale.ROOT) - return children().filter { it.localTag() == local } -} - -private fun Element.firstDirectByLocalTag(tag: String): Element? = childrenByLocalTag(tag).firstOrNull() - -private fun Element.firstByLocalTag(tag: String): Element? { - val local = tag.lowercase(Locale.ROOT) - return allElements.firstOrNull { it.localTag() == local } -} - -private fun Element.allByLocalTag(tag: String): List { - val local = tag.lowercase(Locale.ROOT) - return allElements.filter { it.localTag() == local } -} - -private fun String.relationshipsPath(): String { - val dir = substringBeforeLast('/', missingDelimiterValue = "") - val name = substringAfterLast('/') - return if (dir.isBlank()) "_rels/$name.rels" else "$dir/_rels/$name.rels" -} - -private fun resolveRelationshipTarget(partPath: String, target: String, targetMode: String?): String { - if (targetMode.equals("External", ignoreCase = true)) return target - val cleanTarget = target.substringBefore('#').removePrefix("/") - val base = partPath.substringBeforeLast('/', missingDelimiterValue = "") - return normalizePartPath(if (target.startsWith("/")) cleanTarget else "$base/$cleanTarget") -} - -private fun normalizePartPath(path: String): String { - val clean = path.removePrefix("/") - val parts = ArrayDeque() - clean.split('/').forEach { part -> - when (part) { - "", "." -> Unit - ".." -> { - if (parts.isNotEmpty()) parts.removeLast() - } - else -> parts.addLast(part) - } - } - return parts.joinToString("/") -} - -private fun String.imageContentType(): String { - return when (substringAfterLast('.', "").lowercase(Locale.ROOT)) { - "jpg", "jpeg" -> "image/jpeg" - "png" -> "image/png" - "gif" -> "image/gif" - "webp" -> "image/webp" - "bmp" -> "image/bmp" - "svg" -> "image/svg+xml" - else -> "application/octet-stream" - } -} - -private fun naturalSlidePathComparator(): Comparator { - return compareBy { path -> - Regex("""slide(\d+)\.xml""").find(path)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: Int.MAX_VALUE - } -} diff --git a/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt b/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt new file mode 100644 index 0000000..4d522bf --- /dev/null +++ b/app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt @@ -0,0 +1,198 @@ +package com.aryan.reader.tts + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import com.aryan.reader.R +import com.aryan.reader.tts.TtsPlaybackManager.TtsState + +private const val TTS_MINI_BAR_EDGE_PADDING_DP = 16 +private const val TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP = 96 + +internal fun shouldShowReaderTtsMiniBar( + ttsState: TtsState, + isOnReaderRoute: Boolean +): Boolean { + if (isOnReaderRoute) return false + if (ttsState.playbackSource != "READER") return false + if (ttsState.sessionEndedByStop || ttsState.sessionFinished) return false + return ttsState.isLoading || !ttsState.currentText.isNullOrBlank() +} + +internal fun readerTtsMiniBarBottomPaddingDp(isOnMainRoute: Boolean): Int { + return if (isOnMainRoute) { + TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP + } else { + TTS_MINI_BAR_EDGE_PADDING_DP + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun ReaderTtsMiniBar( + ttsController: TtsController, + ttsState: TtsState, + onOpenReader: () -> Unit, + modifier: Modifier = Modifier +) { + val canOpenReader = !ttsState.bookId.isNullOrBlank() + val canSkipPreviousChunk = !ttsState.isLoading && + ttsState.currentChunkIndex > 0 && + ttsState.totalChunks > 0 + val canSkipNextChunk = !ttsState.isLoading && + ttsState.currentChunkIndex >= 0 && + ttsState.currentChunkIndex < ttsState.totalChunks - 1 + val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) { + if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) { + "Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}" + } else { + null + } + } + val title = ttsState.bookTitle + ?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.action_read_aloud) + val subtitle = remember(title, ttsState.chapterTitle, chunkLabel) { + listOfNotNull( + ttsState.chapterTitle + ?.takeIf { it.isNotBlank() && it != title }, + chunkLabel + ).joinToString(" - ") + } + + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 8.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = canOpenReader, onClick = onOpenReader) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(Modifier.width(4.dp)) + + IconButton( + enabled = canSkipPreviousChunk, + onClick = ttsController::skipToPreviousChunk, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = stringResource(R.string.content_desc_tts_previous_chunk), + modifier = Modifier.size(24.dp) + ) + } + + Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { + FilledIconButton( + onClick = { + if (ttsState.isPlaying) { + ttsController.pause() + } else { + ttsController.resume() + } + }, + modifier = Modifier.size(44.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) { + Icon( + painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), + contentDescription = stringResource( + if (ttsState.isPlaying) { + R.string.content_desc_pause_tts + } else { + R.string.content_desc_resume_tts + } + ), + modifier = Modifier.size(22.dp) + ) + } + if (ttsState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp + ) + } + } + + IconButton( + enabled = canSkipNextChunk, + onClick = ttsController::skipToNextChunk, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = stringResource(R.string.content_desc_tts_next_chunk), + modifier = Modifier.size(24.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt index ca694ba..21910ba 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -153,8 +153,11 @@ class TtsController(context: Context) : Player.Listener { bookTitle: String, chapterTitle: String?, coverImageUri: String?, + bookId: String? = null, chapterIndex: Int? = null, totalChapters: Int? = null, + pageIndex: Int? = null, + startChunkIndex: Int = 0, continueSession: Boolean = false, ttsMode: TtsPlaybackManager.TtsMode, playbackSource: String = "READER", @@ -184,8 +187,11 @@ class TtsController(context: Context) : Player.Listener { putString(KEY_BOOK_TITLE, bookTitle) putString(KEY_CHAPTER_TITLE, chapterTitle) putString(KEY_COVER_IMAGE_URI, coverImageUri) + bookId?.let { putString(KEY_BOOK_ID, it) } chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) } totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) } + pageIndex?.let { putInt(KEY_PAGE_INDEX, it) } + putInt(KEY_START_CHUNK_INDEX, startChunkIndex) putBoolean(KEY_CONTINUE_SESSION, continueSession) putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_PLAYBACK_SOURCE, playbackSource) @@ -254,6 +260,16 @@ class TtsController(context: Context) : Player.Listener { mediaController?.sendCustomCommand(SLICE_CURRENT_AND_RELOAD_COMMAND, Bundle.EMPTY) } + fun skipToPreviousChunk() { + Timber.d("UI sending SKIP_TO_PREVIOUS_TTS_CHUNK command.") + mediaController?.sendCustomCommand(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND, Bundle.EMPTY) + } + + fun skipToNextChunk() { + Timber.d("UI sending SKIP_TO_NEXT_TTS_CHUNK command.") + mediaController?.sendCustomCommand(SKIP_TO_NEXT_TTS_CHUNK_COMMAND, Bundle.EMPTY) + } + 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" @@ -266,6 +282,7 @@ class TtsController(context: Context) : Player.Listener { val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY val currentMediaItem = controller.currentMediaItem val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras + val currentMediaBookTitle = currentMediaItem?.mediaMetadata?.title?.toString() val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText") ?: currentMediaItem?.mediaMetadata?.subtitle?.toString() val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING @@ -278,12 +295,17 @@ class TtsController(context: Context) : Player.Listener { val serviceChapterTitle = customState.getString("chapterTitle") val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 } val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 } + val serviceBookId = customState.getString("bookId") ?: mediaItemExtras?.getString("bookId") + val servicePageIndex = customState.getInt("pageIndex", -1) + .takeIf { it >= 0 } + ?: mediaItemExtras?.getInt("pageIndex", -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 sourceCfi = mediaItemExtras?.getString("sourceCfi") - val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1 + val sourceCfi = mediaItemExtras?.getString("sourceCfi") ?: customState.getString("sourceCfi") + val startOffset = mediaItemExtras?.getInt("startOffset", -1) + ?: customState.getInt("startOffset", -1) val currentWordSourceCfi = customState.getString("currentWordSourceCfi") val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1) val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode) @@ -298,8 +320,13 @@ class TtsController(context: Context) : Player.Listener { if (isLoading) currentState.currentText else null }, errorMessage = customState.getString("errorMessage"), + bookId = if (isPlaybackActive || isLoading) { + serviceBookId ?: currentState.bookId + } else { + serviceBookId + }, bookTitle = if (isPlaybackActive) { - currentMediaItem?.mediaMetadata?.artist?.toString() ?: serviceBookTitle + currentMediaBookTitle ?: serviceBookTitle } else { if (isLoading) currentState.bookTitle else serviceBookTitle }, @@ -318,6 +345,11 @@ class TtsController(context: Context) : Player.Listener { } else { serviceTotalChapters }, + pageIndex = if (isPlaybackActive || isLoading) { + servicePageIndex ?: currentState.pageIndex + } else { + servicePageIndex + }, currentChunkIndex = serviceCurrentChunkIndex, totalChunks = serviceTotalChunks, bookProgressPercent = serviceBookProgressPercent, diff --git a/app/src/main/java/com/aryan/reader/tts/TtsNotificationIntent.kt b/app/src/main/java/com/aryan/reader/tts/TtsNotificationIntent.kt new file mode 100644 index 0000000..9e828c5 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/tts/TtsNotificationIntent.kt @@ -0,0 +1,8 @@ +package com.aryan.reader.tts + +const val ACTION_OPEN_TTS_SESSION = "com.aryan.reader.tts.OPEN_SESSION" +const val EXTRA_TTS_BOOK_ID = "com.aryan.reader.tts.extra.BOOK_ID" +const val EXTRA_TTS_CHAPTER_INDEX = "com.aryan.reader.tts.extra.CHAPTER_INDEX" +const val EXTRA_TTS_SOURCE_CFI = "com.aryan.reader.tts.extra.SOURCE_CFI" +const val EXTRA_TTS_START_OFFSET = "com.aryan.reader.tts.extra.START_OFFSET" +const val EXTRA_TTS_PAGE_INDEX = "com.aryan.reader.tts.extra.PAGE_INDEX" diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index 09179fb..a69a796 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -19,7 +19,9 @@ */ package com.aryan.reader.tts +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.net.Uri import android.os.Bundle import timber.log.Timber @@ -32,6 +34,7 @@ import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult +import com.aryan.reader.MainActivity import com.aryan.reader.R import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture @@ -45,21 +48,35 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File +import java.util.concurrent.atomic.AtomicInteger 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) -val CHANGE_SPEAKER_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER", Bundle.EMPTY) -val FLUSH_PREFETCH_COMMAND = SessionCommand("com.aryan.reader.tts.FLUSH_PREFETCH", Bundle.EMPTY) -private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UPDATE", Bundle.EMPTY) -val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY) -val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY) -val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY) +val START_TTS_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.START") +val STOP_TTS_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.STOP") +val CHANGE_SPEAKER_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER") +val FLUSH_PREFETCH_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.FLUSH_PREFETCH") +private val STATE_UPDATE_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.STATE_UPDATE") +val CHANGE_TTS_MODE_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.CHANGE_MODE") +val SLICE_CURRENT_AND_RELOAD_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD") +val SET_PLAYBACK_PARAMS_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS") +val SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.SKIP_TO_PREVIOUS_CHUNK") +val SKIP_TO_NEXT_TTS_CHUNK_COMMAND: SessionCommand + get() = ttsSessionCommand("com.aryan.reader.tts.SKIP_TO_NEXT_CHUNK") const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG" +const val TTS_CHUNK_NAV_DIAG_TAG = "TTS_CHUNK_NAV_DIAG" const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS" const val KEY_SPOKEN_TEXT_CHUNKS = "KEY_SPOKEN_TEXT_CHUNKS" @@ -77,8 +94,119 @@ 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" +const val KEY_BOOK_ID = "KEY_BOOK_ID" +const val KEY_PAGE_INDEX = "KEY_PAGE_INDEX" +const val KEY_START_CHUNK_INDEX = "KEY_START_CHUNK_INDEX" private const val PREFETCH_LOOKAHEAD = 3 +private const val TTS_SESSION_ACTIVITY_REQUEST_CODE = 4207 +private const val TTS_STREAM_WAV_HEADER_BYTES = 44L +private const val TTS_STREAM_PCM_BYTES_PER_MS = 48L +private const val TTS_NOTIFICATION_MIN_DURATION_MS = 1_500L +private const val TTS_NOTIFICATION_TRAILING_BUFFER_MS = 2_000L +private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L +private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L +private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1 +private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""") + +private fun ttsSessionCommand(action: String): SessionCommand { + return SessionCommand(action, Bundle.EMPTY) +} + +internal fun resolveTtsChunkSkipTarget( + currentChunkIndex: Int, + totalChunks: Int, + direction: Int +): Int? { + if (totalChunks <= 0) return null + if (currentChunkIndex !in 0 until totalChunks) return null + if (direction != -1 && direction != 1) return null + val targetIndex = currentChunkIndex + direction + return targetIndex.takeIf { it in 0 until totalChunks } +} + +internal fun resolveTtsStartChunkIndex( + requestedChunkIndex: Int, + totalChunks: Int +): Int { + if (totalChunks <= 0) return 0 + return requestedChunkIndex.coerceIn(0, totalChunks - 1) +} + +internal fun resolveReusableTtsPlaylistIndex( + playlistIndex: Int?, + direction: Int +): Int? { + if (direction != 1) return null + return playlistIndex?.takeIf { it >= 0 } +} + +internal fun shouldAdvanceToTtsPlaylistChunk( + currentChunkIndex: Int, + playlistChunkIndex: Int? +): Boolean { + return playlistChunkIndex == currentChunkIndex + 1 +} + +internal fun shouldStartTtsTransitionPrefetch( + currentGeneration: Int, + deferredGeneration: Int +): Boolean { + return currentGeneration != deferredGeneration +} + +internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? { + if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null + return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS) + .coerceAtLeast(1L) +} + +internal fun estimateTtsNotificationDurationMs( + text: String, + currentPositionMs: Long = 0L +): Long? { + val words = TTS_NOTIFICATION_WORD_PATTERN.findAll(text).count() + if (words == 0) return null + val punctuationPauses = text.count { it == '.' || it == '?' || it == '!' || it == ';' || it == ':' } + val estimatedDurationMs = words * TTS_NOTIFICATION_AVERAGE_WORD_MS + + punctuationPauses * TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS + val playbackPositionMinimumMs = if (currentPositionMs > 0L) { + currentPositionMs + TTS_NOTIFICATION_TRAILING_BUFFER_MS + } else { + TTS_NOTIFICATION_MIN_DURATION_MS + } + val minimumDurationMs = maxOf(TTS_NOTIFICATION_MIN_DURATION_MS, playbackPositionMinimumMs) + return estimatedDurationMs.coerceAtLeast(minimumDurationMs) +} + +internal fun resolveWavFileDurationMs(file: File): Long? { + if (!file.exists() || file.length() <= TTS_STREAM_WAV_HEADER_BYTES) return null + return try { + val header = ByteArray(TTS_STREAM_WAV_HEADER_BYTES.toInt()) + val bytesRead = file.inputStream().use { it.read(header) } + if (bytesRead < header.size) return null + + val riff = String(header, 0, 4, Charsets.US_ASCII) + val wave = String(header, 8, 4, Charsets.US_ASCII) + if (riff != "RIFF" || wave != "WAVE") return null + + val byteRate = java.nio.ByteBuffer.wrap(header, 28, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .int + if (byteRate <= 0) return null + + val headerDataSize = java.nio.ByteBuffer.wrap(header, 40, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .int + .toLong() + .takeIf { it > 0L && it < file.length() } + val audioBytes = headerDataSize ?: (file.length() - TTS_STREAM_WAV_HEADER_BYTES) + ((audioBytes * 1_000L) / byteRate).coerceAtLeast(1L) + } catch (e: Exception) { + Timber.tag("TTS_CLOUD_DIAG").w(e, "Failed to read WAV duration for ${file.name}") + null + } +} @UnstableApi class TtsPlaybackManager( @@ -97,6 +225,11 @@ class TtsPlaybackManager( private var wordTrackingJob: Job? = null private var preparationJob: Job? = null private var prefetchLoopJob: Job? = null + private val playbackGeneration = AtomicInteger(0) + private val chunkNavLogSequence = AtomicInteger(0) + private val deferredTransitionPrefetchGeneration = AtomicInteger( + NO_DEFERRED_TRANSITION_PREFETCH_GENERATION + ) private var lastPrefetchIndex = -1 private var currentAuthToken: String? = null private val loadedChunks: MutableSet = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) @@ -111,10 +244,12 @@ class TtsPlaybackManager( val isLoading: Boolean = false, val currentText: String? = null, val errorMessage: String? = null, + val bookId: String? = null, val bookTitle: String? = null, val chapterTitle: String? = null, val chapterIndex: Int? = null, val totalChapters: Int? = null, + val pageIndex: Int? = null, val currentChunkIndex: Int = -1, val totalChunks: Int = 0, val bookProgressPercent: Int? = null, @@ -142,36 +277,111 @@ class TtsPlaybackManager( private var textChunks: List = emptyList() private val audioFiles = java.util.concurrent.ConcurrentHashMap() private var currentSpeakerId = initialSpeakerId + private var bookId: String? = null private var bookTitle: String? = null private var chapterTitle: String? = null private var coverImageUri: String? = null private var currentTtsMode = initialTtsMode private var chapterIndex: Int? = null private var totalChapters: Int? = null + private var pageIndex: Int? = null init { player.addListener(this) _ttsState.onEach { newState -> - mediaSession?.let { session -> - val layout = listOf( - createStateButton(newState), - createStopCommandButton() - ) - session.setCustomLayout(layout) - } + updateSessionControls(newState) }.launchIn(scope) } fun setMediaSession(session: MediaSession) { this.mediaSession = session - session.setCustomLayout( - listOf( - createStateButton(_ttsState.value), - createStopCommandButton() - ) + updateSessionControls(_ttsState.value) + } + + private fun advancePlaybackGeneration(): Int { + return playbackGeneration.incrementAndGet() + } + + private fun currentPlaybackGeneration(): Int { + return playbackGeneration.get() + } + + private fun isPlaybackGenerationActive(generation: Int): Boolean { + return playbackGeneration.get() == generation + } + + private fun deferTransitionPrefetchForGeneration(generation: Int) { + deferredTransitionPrefetchGeneration.set(generation) + logChunkNav( + "transition-prefetch-defer-set", + "generation=$generation" ) } + private fun releaseTransitionPrefetchForGeneration(generation: Int) { + if (deferredTransitionPrefetchGeneration.compareAndSet( + generation, + NO_DEFERRED_TRANSITION_PREFETCH_GENERATION + ) + ) { + logChunkNav( + "transition-prefetch-defer-clear", + "generation=$generation" + ) + } + } + + private fun canStartTransitionPrefetch(): Boolean { + return shouldStartTtsTransitionPrefetch( + currentGeneration = currentPlaybackGeneration(), + deferredGeneration = deferredTransitionPrefetchGeneration.get() + ) + } + + private fun cancelPrefetchWork() { + logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex") + prefetchLoopJob?.cancel() + prefetchingJobs.values.forEach { it.cancel() } + prefetchingJobs.clear() + lastPrefetchIndex = -1 + } + + private fun logChunkNav(stage: String, details: String) { + Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i( + "navEvent=${chunkNavLogSequence.incrementAndGet()} stage=$stage $details ${cacheSnapshot()}" + ) + } + + private fun logChunkNavMain(stage: String, details: String) { + Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i( + "navEvent=${chunkNavLogSequence.incrementAndGet()} stage=$stage $details ${playerSnapshot()} ${stateSnapshot()} ${cacheSnapshot()}" + ) + } + + private fun logChunkNavWarnMain(stage: String, details: String) { + Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).w( + "navEvent=${chunkNavLogSequence.incrementAndGet()} stage=$stage $details ${playerSnapshot()} ${stateSnapshot()} ${cacheSnapshot()}" + ) + } + + private fun playerSnapshot(): String { + val itemIds = buildList { + for (index in 0 until player.mediaItemCount) { + add("$index:${player.getMediaItemAt(index).mediaId}") + } + }.joinToString(prefix = "[", postfix = "]") + return "playerIndex=${player.currentMediaItemIndex} currentMediaId=${player.currentMediaItem?.mediaId} mediaItems=${player.mediaItemCount} playbackState=${player.playbackState} playWhenReady=${player.playWhenReady} isPlaying=${player.isPlaying} items=$itemIds" + } + + private fun stateSnapshot(): String { + val state = _ttsState.value + return "stateChunk=${state.currentChunkIndex}/${state.totalChunks} stateLoading=${state.isLoading} statePlaying=${state.isPlaying} stateFinished=${state.sessionFinished}" + } + + private fun cacheSnapshot(): String { + return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${loadedChunks.sorted()} audio=${audioFiles.keys.sorted()} streams=${chunkStreamIds.keys.sorted()} prefetching=${prefetchingJobs.keys.sorted()}" + } + override fun onConnect( session: MediaSession, controller: MediaSession.ControllerInfo @@ -187,17 +397,15 @@ class TtsPlaybackManager( .add(FLUSH_PREFETCH_COMMAND) .add(SLICE_CURRENT_AND_RELOAD_COMMAND) .add(SET_PLAYBACK_PARAMS_COMMAND) + .add(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND) + .add(SKIP_TO_NEXT_TTS_CHUNK_COMMAND) .build() - val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() - .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) - .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) - .remove(Player.COMMAND_SEEK_TO_NEXT) - .remove(Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - return MediaSession.ConnectionResult.AcceptedResultBuilder(session) .setAvailableSessionCommands(availableSessionCommands) - .setAvailablePlayerCommands(availablePlayerCommands) + .setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS) + .setCustomLayout(createCustomLayout(_ttsState.value)) + .setMediaButtonPreferences(createNotificationButtons()) + .setSessionActivity(createSessionActivity(_ttsState.value)) .build() } @@ -231,6 +439,9 @@ class TtsPlaybackManager( 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 bookId = args.getString(KEY_BOOK_ID) + val pageIndex = args.getInt(KEY_PAGE_INDEX, -1).takeIf { it >= 0 } + val startChunkIndex = args.getInt(KEY_START_CHUNK_INDEX, 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 } @@ -259,7 +470,7 @@ 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, totalChapters, ttsMode, playbackSource, args) + handleStartTts(richChunks, speakerId, bookId, bookTitle, chapterTitle, coverImageUri, chapterIndex, totalChapters, pageIndex, startChunkIndex, ttsMode, playbackSource, args) } STOP_TTS_COMMAND -> { Timber.d("Received STOP command.") @@ -277,14 +488,12 @@ class TtsPlaybackManager( } FLUSH_PREFETCH_COMMAND -> { Timber.d("Flushing prefetched TTS chunks for new parameters.") + advancePlaybackGeneration() onResetContext() - lastPrefetchIndex = -1 - prefetchLoopJob?.cancel() - prefetchingJobs.values.forEach { it.cancel() } - prefetchingJobs.clear() + cancelPrefetchWork() scope.launch(Dispatchers.Main) { - val currentIdx = player.currentMediaItemIndex + val currentIdx = currentChunkIndexFromPlayer() if (currentIdx == C.INDEX_UNSET) return@launch val keysToRemove = loadedChunks.filter { it > currentIdx } @@ -326,17 +535,173 @@ class TtsPlaybackManager( } } } + SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND -> { + handleSkipTtsChunk(direction = -1) + } + SKIP_TO_NEXT_TTS_CHUNK_COMMAND -> { + handleSkipTtsChunk(direction = 1) + } } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + fun canSkipToPreviousChunk(): Boolean { + return canSkipTtsChunk(direction = -1) + } + + fun canSkipToNextChunk(): Boolean { + return canSkipTtsChunk(direction = 1) + } + + fun skipToPreviousChunkFromTransport() { + handleSkipTtsChunk(direction = -1) + } + + fun skipToNextChunkFromTransport() { + handleSkipTtsChunk(direction = 1) + } + + private fun canSkipTtsChunk(direction: Int): Boolean { + val currentIndex = currentChunkIndexFromPlayer() + .takeIf { it != C.INDEX_UNSET } + ?: _ttsState.value.currentChunkIndex + return resolveTtsChunkSkipTarget(currentIndex, textChunks.size, direction) != null + } + + private fun handleSkipTtsChunk(direction: Int) { + val currentIndex = currentChunkIndexFromPlayer() + .takeIf { it != C.INDEX_UNSET } + ?: _ttsState.value.currentChunkIndex + val targetIndex = resolveTtsChunkSkipTarget(currentIndex, textChunks.size, direction) + + if (targetIndex == null) { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( + "Ignoring chunk skip. direction=$direction, current=$currentIndex, total=${textChunks.size}" + ) + logChunkNavMain( + "skip-ignored-boundary", + "direction=$direction currentChunk=$currentIndex totalChunks=${textChunks.size}" + ) + return + } + + val shouldResumePlayback = player.playWhenReady || _ttsState.value.isPlaying + val targetChunk = textChunks[targetIndex] + val newGeneration = advancePlaybackGeneration() + cancelPrefetchWork() + preparationJob?.cancel() + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( + "Skipping TTS chunk. direction=$direction, from=$currentIndex, to=$targetIndex, playWhenReady=$shouldResumePlayback" + ) + logChunkNavMain( + "skip-request", + "direction=$direction fromChunk=$currentIndex targetChunk=$targetIndex resume=$shouldResumePlayback newGeneration=$newGeneration" + ) + + val targetPlaylistIndex = findReusablePlaylistIndexForChunk(targetIndex, direction) + if (targetPlaylistIndex != null) { + player.pause() + logChunkNavMain( + "skip-reuse-before-seek", + "direction=$direction targetChunk=$targetIndex targetPlaylistIndex=$targetPlaylistIndex" + ) + _ttsState.value = _ttsState.value.copy( + isLoading = false, + isPlaying = false, + currentText = targetChunk.text, + errorMessage = null, + currentChunkIndex = targetIndex, + totalChunks = textChunks.size, + bookProgressPercent = calculateBookProgressPercent(targetIndex), + sourceCfi = targetChunk.sourceCfi, + startOffsetInSource = targetChunk.startOffsetInSource, + currentWordSourceCfi = null, + currentWordStartOffset = -1, + sessionFinished = false + ) + wordTrackingJob?.cancel() + player.seekTo(targetPlaylistIndex, 0L) + prefetchNextChunkAudio(targetIndex) + player.playWhenReady = shouldResumePlayback + logChunkNavMain( + "skip-reuse-after-seek", + "direction=$direction targetChunk=$targetIndex targetPlaylistIndex=$targetPlaylistIndex resume=$shouldResumePlayback" + ) + return + } + + player.pause() + onResetContext() + wordTrackingJob?.cancel() + logChunkNavMain( + "skip-rebuild-start", + "direction=$direction targetChunk=$targetIndex resume=$shouldResumePlayback" + ) + + _ttsState.value = _ttsState.value.copy( + isLoading = true, + isPlaying = false, + currentText = targetChunk.text, + errorMessage = null, + currentChunkIndex = targetIndex, + totalChunks = textChunks.size, + bookProgressPercent = calculateBookProgressPercent(targetIndex), + sourceCfi = targetChunk.sourceCfi, + startOffsetInSource = targetChunk.startOffsetInSource, + currentWordSourceCfi = null, + currentWordStartOffset = -1, + sessionFinished = false + ) + + deferTransitionPrefetchForGeneration(newGeneration) + preparationJob = scope.launch { + try { + logChunkNav( + "skip-rebuild-prepare-job-start", + "targetChunk=$targetIndex resume=$shouldResumePlayback" + ) + prepareAndPlayFirstChunk( + startAtIndex = targetIndex, + playWhenReady = shouldResumePlayback, + prefetchAfterPrepare = false + ) + if (!isPlaybackGenerationActive(newGeneration)) { + logChunkNav( + "skip-rebuild-stale-after-prepare", + "targetChunk=$targetIndex generation=$newGeneration currentGeneration=${currentPlaybackGeneration()}" + ) + return@launch + } + + clearAudioFilesExcept(retainedChunkIndices = setOf(targetIndex)) + if (!isPlaybackGenerationActive(newGeneration)) { + logChunkNav( + "skip-rebuild-stale-after-cleanup", + "targetChunk=$targetIndex generation=$newGeneration currentGeneration=${currentPlaybackGeneration()}" + ) + return@launch + } + + logChunkNav( + "skip-rebuild-cleanup-complete", + "targetChunk=$targetIndex retained=[$targetIndex]" + ) + releaseTransitionPrefetchForGeneration(newGeneration) + prefetchNextChunkAudio(targetIndex) + } finally { + releaseTransitionPrefetchForGeneration(newGeneration) + } + } + } + private fun handleSliceAndReload() { - val currentIdx = player.currentMediaItemIndex + val currentIdx = currentChunkIndexFromPlayer() if (currentIdx == C.INDEX_UNSET) return player.pause() _ttsState.value = _ttsState.value.copy(isLoading = true) + advancePlaybackGeneration() onResetContext() val offset = _ttsState.value.currentWordStartOffset @@ -346,10 +711,7 @@ class TtsPlaybackManager( wordTrackingJob?.cancel() player.stop() player.clearMediaItems() - lastPrefetchIndex = -1 - prefetchLoopJob?.cancel() - prefetchingJobs.values.forEach { it.cancel() } - prefetchingJobs.clear() + cancelPrefetchWork() preparationJob = scope.launch { clearAudioFiles() @@ -394,11 +756,14 @@ class TtsPlaybackManager( private fun handleStartTts( chunks: List, speakerId: String, + bookId: String?, bookTitle: String?, chapterTitle: String?, coverImageUri: String?, chapterIndex: Int?, totalChapters: Int?, + pageIndex: Int?, + startChunkIndex: Int, ttsMode: TtsMode, playbackSource: String?, args: Bundle // Added this parameter @@ -425,7 +790,7 @@ 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" + "handleStartTts. continueSession=$continueSession, chunks=${chunks.size}, startChunkIndex=$startChunkIndex, book='${bookTitle.orEmpty().take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters, mode=$ttsMode, playbackSource=$playbackSource" ) if (!continueSession) { @@ -433,6 +798,11 @@ class TtsPlaybackManager( handleStopTts(clearState = false) } + val startGeneration = advancePlaybackGeneration() + logChunkNav( + "start-session", + "chunks=${chunks.size} startChunk=$startChunkIndex resolvedStart=${resolveTtsStartChunkIndex(startChunkIndex, chunks.size)} continueSession=$continueSession generation=$startGeneration" + ) onPlaybackSessionPreparing(bookTitle, chapterTitle) val effectiveSpeakerId = normalizeTtsSpeakerId(speakerId) @@ -440,21 +810,25 @@ class TtsPlaybackManager( textChunks = chunks currentSpeakerId = effectiveSpeakerId currentTtsMode = ttsMode + this.bookId = bookId this.bookTitle = bookTitle this.chapterTitle = chapterTitle this.coverImageUri = coverImageUri this.chapterIndex = chapterIndex this.totalChapters = totalChapters + this.pageIndex = pageIndex loadedChunks.clear() lastPrefetchIndex = -1 _ttsState.value = TtsState( isLoading = true, + bookId = bookId, bookTitle = bookTitle, chapterTitle = chapterTitle, chapterIndex = chapterIndex, totalChapters = totalChapters, + pageIndex = pageIndex, currentChunkIndex = -1, totalChunks = chunks.size, bookProgressPercent = calculateBookProgressPercent(-1), @@ -478,8 +852,9 @@ class TtsPlaybackManager( } currentAuthToken = authToken + val resolvedStartChunkIndex = resolveTtsStartChunkIndex(startChunkIndex, chunks.size) preparationJob = scope.launch { - prepareAndPlayFirstChunk() + prepareAndPlayFirstChunk(startAtIndex = resolvedStartChunkIndex) } } @@ -508,6 +883,94 @@ class TtsPlaybackManager( ?: player.currentMediaItemIndex } + private fun findPlaylistIndexForChunk(chunkIndex: Int): Int? { + for (index in 0 until player.mediaItemCount) { + if (player.getMediaItemAt(index).mediaId.toIntOrNull() == chunkIndex) { + return index + } + } + return null + } + + private fun findReusablePlaylistIndexForChunk(chunkIndex: Int, direction: Int): Int? { + return resolveReusableTtsPlaylistIndex(findPlaylistIndexForChunk(chunkIndex), direction) + } + + private fun seekToChunkMediaItem(chunkIndex: Int): Boolean { + val playlistIndex = findPlaylistIndexForChunk(chunkIndex) ?: return false + logChunkNavMain( + "seek-to-chunk", + "chunk=$chunkIndex playlistIndex=$playlistIndex" + ) + player.seekTo(playlistIndex, 0L) + return true + } + + private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean { + val nextChunkIndex = resolveTtsChunkSkipTarget(currentChunkIndex, textChunks.size, direction = 1) + ?: run { + logChunkNavMain( + "advance-next-no-target", + "currentChunk=$currentChunkIndex totalChunks=${textChunks.size}" + ) + return false + } + val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex) + ?: run { + logChunkNavMain( + "advance-next-missing-playlist-item", + "currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex" + ) + return false + } + val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull() + if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) { + logChunkNavWarnMain( + "advance-next-refused-non-contiguous", + "Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex" + ) + return false + } + logChunkNavMain( + "advance-next-seek", + "currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex" + ) + player.seekTo(nextPlaylistIndex, 0L) + return true + } + + fun isCurrentChunkStreaming(): Boolean { + return player.currentMediaItem?.localConfiguration?.uri?.scheme == "ttsstream" + } + + fun currentChunkDurationForNotification(currentPositionMs: Long): Long { + val mediaItem = player.currentMediaItem ?: return C.TIME_UNSET + if (mediaItem.localConfiguration?.uri?.scheme != "ttsstream") { + return C.TIME_UNSET + } + + val streamId = mediaItem.localConfiguration?.uri?.host + ?: mediaItem.localConfiguration?.uri?.lastPathSegment + if (streamId != null) { + val (isFinished, totalBytes) = StreamRegistry.getStreamMetadata(streamId) + if (isFinished) { + val durationMs = resolveTtsStreamPcmDurationMs(totalBytes) + if (durationMs != null) { + return durationMs.coerceAtLeast(currentPositionMs.coerceAtLeast(0L)) + } + } + } + + val chunkIndex = mediaItem.mediaId.toIntOrNull() ?: currentChunkIndexFromPlayer() + val chunk = textChunks.getOrNull(chunkIndex) + val text = chunk?.spokenText?.ifBlank { chunk.text } + ?: mediaItem.mediaMetadata.extras?.getString("ttsText") + ?: mediaItem.mediaMetadata.subtitle?.toString() + ?: return C.TIME_UNSET + + return estimateTtsNotificationDurationMs(text, currentPositionMs) ?: C.TIME_UNSET + } + private fun calculateBookProgressPercent(chunkIndex: Int): Int? { val chapter = chapterIndex ?: return null val chapterCount = totalChapters?.takeIf { it > 0 } ?: return null @@ -570,7 +1033,13 @@ class TtsPlaybackManager( } } - private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) { + private suspend fun prepareAndPlayFirstChunk( + startAtIndex: Int = 0, + playWhenReady: Boolean = true, + startAtPosition: Long = 0L, + prefetchAfterPrepare: Boolean = true + ) { + val generation = currentPlaybackGeneration() val firstChunk = textChunks.getOrNull(startAtIndex) if (firstChunk == null) { _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = appContext.getString(R.string.tts_error_starting_playback)) @@ -583,13 +1052,33 @@ class TtsPlaybackManager( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( "Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady" ) + logChunkNav( + "prepare-first-start", + "chunk=$startAtIndex resume=$playWhenReady startPosition=$startAtPosition prefetchAfterPrepare=$prefetchAfterPrepare generation=$generation" + ) val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text } val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms") + logChunkNav( + "prepare-first-generated", + "chunk=$startAtIndex elapsedMs=${System.currentTimeMillis() - chunkStartTime} audioFile=${ttsAudioData.audioFile?.name} streamUri=${ttsAudioData.streamUri} error=${ttsAudioData.error}" + ) + if (!isPlaybackGenerationActive(generation)) { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( + "Ignoring stale prepared TTS chunk. chunk=$startAtIndex, generation=$generation, currentGeneration=${currentPlaybackGeneration()}" + ) + logChunkNav( + "prepare-first-stale", + "chunk=$startAtIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}" + ) + cleanupGeneratedAudioData(ttsAudioData) + return + } if (ttsAudioData.error == "INSUFFICIENT_CREDITS") { withContext(Dispatchers.Main) { + if (!isPlaybackGenerationActive(generation)) return@withContext _ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS") handleStopTts(clearState = false) } @@ -620,7 +1109,19 @@ class TtsPlaybackManager( val mediaItem = createMediaItem(updatedChunk.text, pathToUse, startAtIndex, updatedChunk) withContext(Dispatchers.Main) { + if (!isPlaybackGenerationActive(generation)) { + logChunkNavMain( + "prepare-first-stale-main", + "chunk=$startAtIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}" + ) + cleanupGeneratedAudioData(ttsAudioData) + return@withContext + } val prepStartTime = System.currentTimeMillis() + logChunkNavMain( + "prepare-first-set-media-before", + "chunk=$startAtIndex mediaId=${mediaItem.mediaId} resume=$playWhenReady" + ) player.setMediaItem(mediaItem) player.prepare() if (startAtPosition > 0) { @@ -631,6 +1132,10 @@ class TtsPlaybackManager( 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}" ) + logChunkNavMain( + "prepare-first-set-media-after", + "chunk=$startAtIndex mediaId=${mediaItem.mediaId} resume=$playWhenReady prepMs=${System.currentTimeMillis() - prepStartTime}" + ) _ttsState.value = _ttsState.value.copy( isLoading = false, isPlaying = playWhenReady, @@ -646,8 +1151,19 @@ class TtsPlaybackManager( startOffsetInSource = updatedChunk.startOffsetInSource ) } - prefetchNextChunkAudio(startAtIndex) + if (!isPlaybackGenerationActive(generation)) return + if (prefetchAfterPrepare) { + logChunkNav( + "prepare-first-prefetch-request", + "chunk=$startAtIndex" + ) + prefetchNextChunkAudio(startAtIndex) + } } else { + logChunkNav( + "prepare-first-failed", + "chunk=$startAtIndex error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" + ) _ttsState.value = _ttsState.value.copy( isLoading = false, errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio) @@ -693,6 +1209,7 @@ class TtsPlaybackManager( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( "handleStopTts. clearState=$clearState, userInitiated=$userInitiated" ) + advancePlaybackGeneration() onPlaybackSessionStopped() onResetContext() preparationJob?.cancel() @@ -704,27 +1221,20 @@ class TtsPlaybackManager( ttsMode = currentTtsMode.name ) _ttsState.value = finalState - mediaSession?.let { session -> - val layout = listOf( - createStateButton(finalState), - createStopCommandButton() - ) - session.setCustomLayout(layout) - } + updateSessionControls(finalState) } player.stop() player.clearMediaItems() textChunks = emptyList() + bookId = null bookTitle = null chapterTitle = null coverImageUri = null chapterIndex = null totalChapters = null - lastPrefetchIndex = -1 - prefetchLoopJob?.cancel() - prefetchingJobs.values.forEach { it.cancel() } - prefetchingJobs.clear() + pageIndex = null + cancelPrefetchWork() loadedChunks.clear() scope.launch { @@ -738,6 +1248,10 @@ class TtsPlaybackManager( 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}" ) + logChunkNavMain( + "media-transition", + "reason=$reason playlistIndex=$newPlaylistIndex mediaId=${mediaItem?.mediaId}" + ) if (newPlaylistIndex == C.INDEX_UNSET) return val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return @@ -771,6 +1285,10 @@ class TtsPlaybackManager( val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull() if (previousChunkIndex != null) { + logChunkNavMain( + "media-transition-clean-previous", + "currentChunk=$currentChunkIndex previousChunk=$previousChunkIndex previousPlaylistIndex=${newPlaylistIndex - 1}" + ) scope.launch(Dispatchers.IO) { val file = audioFiles.remove(previousChunkIndex) deleteTempFile(file) @@ -782,6 +1300,13 @@ class TtsPlaybackManager( } } } + if (!canStartTransitionPrefetch()) { + logChunkNavMain( + "media-transition-prefetch-deferred", + "currentChunk=$currentChunkIndex generation=${currentPlaybackGeneration()} deferredGeneration=${deferredTransitionPrefetchGeneration.get()}" + ) + return + } prefetchNextChunkAudio(currentChunkIndex) } @@ -789,6 +1314,10 @@ class TtsPlaybackManager( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( "onIsPlayingChanged. isPlaying=$isPlaying, playbackState=${player.playbackState}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}" ) + logChunkNavMain( + "is-playing-changed", + "isPlaying=$isPlaying" + ) var nextState = _ttsState.value.copy(isPlaying = isPlaying) if (isPlaying) { @@ -814,8 +1343,16 @@ class TtsPlaybackManager( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( "Player reached ENDED. currentChunkIndex=$currentChunkIndex, isLastChunk=$isLastChunkInSession, totalChunks=${textChunks.size}, sessionFinishedWillBeSet=${isLastChunkInSession || textChunks.isEmpty()}" ) + logChunkNavMain( + "player-state-ended", + "currentChunk=$currentChunkIndex isLast=$isLastChunkInSession totalChunks=${textChunks.size}" + ) if (isLastChunkInSession || textChunks.isEmpty()) { Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true") + logChunkNavMain( + "player-ended-session-finished", + "currentChunk=$currentChunkIndex totalChunks=${textChunks.size}" + ) nextState = nextState.copy( currentChunkIndex = currentChunkIndex, totalChunks = textChunks.size, @@ -828,8 +1365,16 @@ class TtsPlaybackManager( if (!isPrefetching) { Timber.w("BUFFERING: Stalled at chunk $currentChunkIndex. Restarting prefetch for $nextIdx.") + logChunkNavMain( + "player-ended-prefetch-restart", + "currentChunk=$currentChunkIndex expectedNext=$nextIdx isPrefetching=$isPrefetching" + ) prefetchNextChunkAudio(currentChunkIndex) } + logChunkNavMain( + "player-ended-waiting-next", + "currentChunk=$currentChunkIndex expectedNext=$nextIdx isPrefetching=$isPrefetching" + ) nextState = nextState.copy(isLoading = true) } } @@ -857,33 +1402,82 @@ class TtsPlaybackManager( private fun prefetchNextChunkAudio(currentIndex: Int) { if (currentIndex == lastPrefetchIndex && prefetchLoopJob?.isActive == true) { + logChunkNav( + "prefetch-skip-existing-loop", + "currentChunk=$currentIndex generation=${currentPlaybackGeneration()}" + ) return } lastPrefetchIndex = currentIndex + val generation = currentPlaybackGeneration() + logChunkNav( + "prefetch-loop-start", + "currentChunk=$currentIndex generation=$generation" + ) prefetchLoopJob?.cancel() prefetchLoopJob = scope.launch { for (i in 1..PREFETCH_LOOKAHEAD) { + if (!isPlaybackGenerationActive(generation)) { + logChunkNav( + "prefetch-loop-stale", + "currentChunk=$currentIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}" + ) + return@launch + } val targetIndex = currentIndex + i if (targetIndex < textChunks.size) { - if (prefetchingJobs.containsKey(targetIndex)) continue - if (audioFiles.containsKey(targetIndex)) continue - if (loadedChunks.contains(targetIndex)) continue + if (prefetchingJobs.containsKey(targetIndex)) { + logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") + continue + } + if (audioFiles.containsKey(targetIndex)) { + logChunkNav("prefetch-target-skip-audio-cache", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") + continue + } + if (loadedChunks.contains(targetIndex)) { + logChunkNav("prefetch-target-skip-loaded", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation") + continue + } Timber.d("PlaybackManager: Scheduling prefetch for chunk $targetIndex") + logChunkNav( + "prefetch-target-schedule", + "currentChunk=$currentIndex targetChunk=$targetIndex lookahead=$i generation=$generation" + ) val job = launch { val nextChunk = textChunks[targetIndex] val prefetchStartTime = System.currentTimeMillis() Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex") + logChunkNav( + "prefetch-generate-start", + "targetChunk=$targetIndex generation=$generation" + ) val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text } val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms") + logChunkNav( + "prefetch-generate-complete", + "targetChunk=$targetIndex generation=$generation elapsedMs=${System.currentTimeMillis() - prefetchStartTime} audioFile=${ttsAudioData.audioFile?.name} streamUri=${ttsAudioData.streamUri} error=${ttsAudioData.error}" + ) + if (!isPlaybackGenerationActive(generation)) { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( + "Ignoring stale prefetched TTS chunk. chunk=$targetIndex, generation=$generation, currentGeneration=${currentPlaybackGeneration()}" + ) + logChunkNav( + "prefetch-generate-stale", + "targetChunk=$targetIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}" + ) + cleanupGeneratedAudioData(ttsAudioData) + return@launch + } if (ttsAudioData.error == "INSUFFICIENT_CREDITS") { withContext(Dispatchers.Main) { + if (!isPlaybackGenerationActive(generation)) return@withContext _ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS") handleStopTts(clearState = false) } @@ -900,6 +1494,14 @@ class TtsPlaybackManager( val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk) withContext(Dispatchers.Main) { + if (!isPlaybackGenerationActive(generation)) { + logChunkNavMain( + "prefetch-add-stale-main", + "targetChunk=$targetIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}" + ) + cleanupGeneratedAudioData(ttsAudioData) + return@withContext + } if (audioFile != null) { audioFiles[targetIndex] = audioFile } @@ -934,31 +1536,75 @@ class TtsPlaybackManager( break } } + logChunkNavMain( + "prefetch-add-before", + "targetChunk=$targetIndex insertPosition=$insertPosition exists=false generation=$generation" + ) player.addMediaItem(insertPosition, nextMediaItem) + logChunkNavMain( + "prefetch-add-after", + "targetChunk=$targetIndex insertPosition=$insertPosition generation=$generation" + ) + } else { + logChunkNavMain( + "prefetch-add-skip-existing-playlist", + "targetChunk=$targetIndex generation=$generation" + ) } val currentChunkIndex = currentChunkIndexFromPlayer() val isImmediateNextChunk = targetIndex == currentChunkIndex + 1 if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) { - player.seekToNextMediaItem() - player.play() + logChunkNavMain( + "prefetch-ended-immediate-next", + "currentChunk=$currentChunkIndex targetChunk=$targetIndex generation=$generation" + ) + if (seekToChunkMediaItem(targetIndex)) { + player.play() + } } else if (wasLoading && isImmediateNextChunk) { + logChunkNavMain( + "prefetch-loading-resolved", + "currentChunk=$currentChunkIndex targetChunk=$targetIndex generation=$generation" + ) _ttsState.value = _ttsState.value.copy(isLoading = false) } } } else { Timber.e("Prefetch: Failed to download chunk $targetIndex") + logChunkNav( + "prefetch-generate-failed", + "targetChunk=$targetIndex generation=$generation error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}" + ) } } prefetchingJobs[targetIndex] = job job.invokeOnCompletion { - prefetchingJobs.remove(targetIndex) + prefetchingJobs.remove(targetIndex, job) } job.join() + if (!isPlaybackGenerationActive(generation)) { + logChunkNav( + "prefetch-after-join-stale", + "targetChunk=$targetIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}" + ) + return@launch + } + if (!loadedChunks.contains(targetIndex) && findPlaylistIndexForChunk(targetIndex) == null) { + logChunkNavWarnMain( + "prefetch-stop-after-missing-chunk", + "Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous." + ) + return@launch + } } } + logChunkNav( + "prefetch-loop-complete", + "currentChunk=$currentIndex generation=$generation" + ) } } @@ -986,14 +1632,38 @@ class TtsPlaybackManager( Timber.tag("TTS_CLOUD_DIAG").i("Stream finished naturally: pos=$playbackPosition, expected=$expectedDurationMs. Transitioning.") withContext(Dispatchers.Main) { if (player.currentMediaItemIndex == currentIdx) { - if (player.hasNextMediaItem()) { - player.seekToNextMediaItem() + val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull() + ?: currentChunkIndexFromPlayer() + logChunkNavMain( + "stream-finished", + "finishedChunk=$finishedChunkIndex playlistIndex=$currentIdx playbackPosition=$playbackPosition expectedDuration=$expectedDurationMs streamId=$streamId" + ) + if (advanceToNextChunkMediaItem(finishedChunkIndex)) { + logChunkNavMain( + "stream-finished-advanced", + "finishedChunk=$finishedChunkIndex" + ) + player.play() + } else if (resolveTtsChunkSkipTarget(finishedChunkIndex, textChunks.size, direction = 1) != null) { + logChunkNavMain( + "stream-finished-next-missing-prefetch", + "finishedChunk=$finishedChunkIndex expectedNext=${finishedChunkIndex + 1}" + ) + _ttsState.value = _ttsState.value.copy(isLoading = true) + prefetchNextChunkAudio(finishedChunkIndex) } else { - val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull() - ?: currentChunkIndexFromPlayer() + logChunkNavMain( + "stream-finished-session-finished", + "finishedChunk=$finishedChunkIndex totalChunks=${textChunks.size}" + ) markSessionFinishedNaturally(finishedChunkIndex) player.pause() } + } else { + logChunkNavMain( + "stream-finished-stale-playlist-index", + "observedPlaylistIndex=$currentIdx currentPlaylistIndex=${player.currentMediaItemIndex} playbackPosition=$playbackPosition expectedDuration=$expectedDurationMs streamId=$streamId" + ) } } break @@ -1035,6 +1705,8 @@ class TtsPlaybackManager( } private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem { + val isStreaming = path.startsWith("ttsstream://") + val localAudioFile = if (isStreaming) null else File(path) val progress = calculateBookProgressPercent(index) val chunkLabel = if (textChunks.isNotEmpty()) { "Chunk ${index + 1}/${textChunks.size}" @@ -1068,6 +1740,8 @@ class TtsPlaybackManager( putString("ttsText", text) putString("sourceCfi", chunk.sourceCfi) putInt("startOffset", chunk.startOffsetInSource) + putString("bookId", bookId) + putInt("pageIndex", pageIndex ?: -1) if (chunk.timedWords.isNotEmpty()) { val timestamps = chunk.timedWords.map { it.startTime }.toDoubleArray() val offsets = chunk.timedWords.map { it.startOffset }.toIntArray() @@ -1076,7 +1750,7 @@ class TtsPlaybackManager( } } - val metadata = MediaMetadata.Builder() + val metadataBuilder = MediaMetadata.Builder() .setTitle(bookTitle ?: chapterLabel) .setDisplayTitle(bookTitle ?: chapterLabel) .setArtist(chapterLabel) @@ -1086,9 +1760,17 @@ class TtsPlaybackManager( .setTrackNumber(index + 1) .setTotalTrackCount(textChunks.size) .setExtras(extras) - .build() - val uri = if (path.startsWith("ttsstream://")) path.toUri() else Uri.fromFile(File(path)) + val durationMs = if (isStreaming) { + estimateTtsNotificationDurationMs(text) + } else { + localAudioFile?.let(::resolveWavFileDurationMs) + } + durationMs?.let { metadataBuilder.setDurationMs(it) } + + val metadata = metadataBuilder.build() + + val uri = if (isStreaming) path.toUri() else Uri.fromFile(localAudioFile!!) return MediaItem.Builder() .setUri(uri) @@ -1105,6 +1787,16 @@ class TtsPlaybackManager( } } + private fun cleanupGeneratedAudioData(ttsAudioData: TtsAudioData) { + deleteTempFile(ttsAudioData.audioFile) + val streamId = ttsAudioData.streamUri + ?.toUri() + ?.let { it.host ?: it.lastPathSegment } + if (streamId != null) { + StreamRegistry.remove(streamId) + } + } + private suspend fun clearAudioFiles() { withContext(Dispatchers.IO) { audioFiles.values.forEach { deleteTempFile(it) } @@ -1115,19 +1807,132 @@ class TtsPlaybackManager( } } + private suspend fun clearAudioFilesExcept(retainedChunkIndices: Set) { + withContext(Dispatchers.IO) { + val audioKeysToRemove = audioFiles.keys + .filter { it !in retainedChunkIndices } + .toList() + logChunkNav( + "clear-audio-except", + "retained=${retainedChunkIndices.sorted()} removeAudio=$audioKeysToRemove" + ) + audioKeysToRemove.forEach { chunkIndex -> + val file = audioFiles.remove(chunkIndex) + deleteTempFile(file) + loadedChunks.remove(chunkIndex) + } + + val streamKeysToRemove = chunkStreamIds.keys + .filter { it !in retainedChunkIndices } + .toList() + streamKeysToRemove.forEach { chunkIndex -> + val streamId = chunkStreamIds.remove(chunkIndex) + if (streamId != null) { + StreamRegistry.remove(streamId) + } + } + } + } + + private fun updateSessionControls(state: TtsState) { + mediaSession?.let { session -> + session.setCustomLayout(createCustomLayout(state)) + session.setMediaButtonPreferences(createNotificationButtons()) + session.setSessionActivity(createSessionActivity(state)) + } + } + + private fun createCustomLayout(state: TtsState): List { + return listOf( + createStateButton(state), + createPreviousChunkCommandButton(state), + createNextChunkCommandButton(state), + createStopCommandButton() + ) + } + + private fun createNotificationButtons(): List { + return listOf( + createStopCommandButton() + ) + } + + @Suppress("Deprecation") + private fun createPreviousChunkCommandButton(state: TtsState): CommandButton { + val canSkip = resolveTtsChunkSkipTarget( + currentChunkIndex = state.currentChunkIndex, + totalChunks = state.totalChunks, + direction = -1 + ) != null + return CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setDisplayName(appContext.getString(R.string.content_desc_tts_previous_chunk)) + .setSessionCommand(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND) + .setIconResId(R.drawable.skip_previous) + .setEnabled(canSkip) + .build() + } + + @Suppress("Deprecation") + private fun createNextChunkCommandButton(state: TtsState): CommandButton { + val canSkip = resolveTtsChunkSkipTarget( + currentChunkIndex = state.currentChunkIndex, + totalChunks = state.totalChunks, + direction = 1 + ) != null + return CommandButton.Builder(CommandButton.ICON_NEXT) + .setDisplayName(appContext.getString(R.string.content_desc_tts_next_chunk)) + .setSessionCommand(SKIP_TO_NEXT_TTS_CHUNK_COMMAND) + .setIconResId(R.drawable.skip_next) + .setEnabled(canSkip) + .build() + } + + private fun createSessionActivity(state: TtsState): PendingIntent { + val targetCfi = state.currentWordSourceCfi?.takeIf { it.isNotBlank() } + ?: state.sourceCfi?.takeIf { it.isNotBlank() } + val targetOffset = state.currentWordStartOffset.takeIf { it >= 0 } + ?: state.startOffsetInSource.takeIf { it >= 0 } + + val intent = Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (!state.bookId.isNullOrBlank()) { + action = ACTION_OPEN_TTS_SESSION + putExtra(EXTRA_TTS_BOOK_ID, state.bookId) + state.chapterIndex?.let { putExtra(EXTRA_TTS_CHAPTER_INDEX, it) } + state.pageIndex?.let { putExtra(EXTRA_TTS_PAGE_INDEX, it) } + targetCfi?.let { putExtra(EXTRA_TTS_SOURCE_CFI, it) } + targetOffset?.let { putExtra(EXTRA_TTS_START_OFFSET, it) } + } else { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + } + } + + return PendingIntent.getActivity( + appContext, + TTS_SESSION_ACTIVITY_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + @Suppress("Deprecation") private fun createStateButton(state: TtsState): CommandButton { val bundle = Bundle().apply { putBoolean("isLoading", state.isLoading) putString("errorMessage", state.errorMessage) + putString("bookId", state.bookId) putString("bookTitle", state.bookTitle) putString("chapterTitle", state.chapterTitle) putInt("chapterIndex", state.chapterIndex ?: -1) putInt("totalChapters", state.totalChapters ?: -1) + putInt("pageIndex", state.pageIndex ?: -1) putInt("currentChunkIndex", state.currentChunkIndex) putInt("totalChunks", state.totalChunks) putInt("bookProgressPercent", state.bookProgressPercent ?: -1) putString("speakerId", state.speakerId) + putString("sourceCfi", state.sourceCfi) + putInt("startOffset", state.startOffsetInSource) putBoolean("sessionEndedByStop", state.sessionEndedByStop) putString("currentWordSourceCfi", state.currentWordSourceCfi) putInt("currentWordStartOffset", state.currentWordStartOffset) @@ -1144,7 +1949,7 @@ class TtsPlaybackManager( @Suppress("Deprecation") private fun createStopCommandButton(): CommandButton { - return CommandButton.Builder() + return CommandButton.Builder(CommandButton.ICON_STOP) .setDisplayName("Stop TTS") .setSessionCommand(STOP_TTS_COMMAND) .setIconResId(R.drawable.close) diff --git a/app/src/main/java/com/aryan/reader/tts/TtsService.kt b/app/src/main/java/com/aryan/reader/tts/TtsService.kt index 5b0cba8..3531ed0 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsService.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsService.kt @@ -30,10 +30,16 @@ import android.content.pm.ServiceInfo import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaNotification import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.aryan.reader.R @@ -61,6 +67,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import com.google.common.collect.ImmutableList data class WordTimingInfo(val word: String, val startTime: Double) @@ -242,6 +249,227 @@ private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback" // Keep this aligned with Media3's default notification ID so playback updates replace the fallback. private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001 private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L +private const val ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK = "com.aryan.reader.tts.NOTIFICATION_PREVIOUS_CHUNK" +private const val ACTION_TTS_NOTIFICATION_NEXT_CHUNK = "com.aryan.reader.tts.NOTIFICATION_NEXT_CHUNK" +private const val TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE = 4208 +private const val TTS_NOTIFICATION_NEXT_REQUEST_CODE = 4209 + +@UnstableApi +private class TtsMediaNotificationProvider( + context: android.content.Context +) : DefaultMediaNotificationProvider( + context, + { _ -> TTS_FOREGROUND_NOTIFICATION_ID }, + TTS_FOREGROUND_CHANNEL_ID, + R.string.tts_notification_channel_name +) { + private val appContext = context.applicationContext + + override fun addNotificationActions( + mediaSession: MediaSession, + mediaButtons: ImmutableList, + builder: NotificationCompat.Builder, + actionFactory: MediaNotification.ActionFactory + ): IntArray { + return super.addNotificationActions( + mediaSession, + mediaButtons, + builder, + TtsNotificationActionFactory(appContext, actionFactory) + ) + } +} + +@UnstableApi +private class TtsNotificationActionFactory( + private val context: android.content.Context, + private val delegate: MediaNotification.ActionFactory +) : MediaNotification.ActionFactory { + override fun createMediaAction( + mediaSession: MediaSession, + icon: IconCompat, + title: CharSequence, + command: Int + ): NotificationCompat.Action { + return when (command) { + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> createChunkSkipAction( + iconResId = R.drawable.skip_previous, + title = title, + action = ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK, + requestCode = TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE + ) + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> createChunkSkipAction( + iconResId = R.drawable.skip_next, + title = title, + action = ACTION_TTS_NOTIFICATION_NEXT_CHUNK, + requestCode = TTS_NOTIFICATION_NEXT_REQUEST_CODE + ) + else -> delegate.createMediaAction(mediaSession, icon, title, command) + } + } + + override fun createCustomAction( + mediaSession: MediaSession, + icon: IconCompat, + title: CharSequence, + customAction: String, + extras: android.os.Bundle + ): NotificationCompat.Action { + return delegate.createCustomAction(mediaSession, icon, title, customAction, extras) + } + + override fun createCustomActionFromCustomCommandButton( + mediaSession: MediaSession, + customCommandButton: CommandButton + ): NotificationCompat.Action { + return delegate.createCustomActionFromCustomCommandButton(mediaSession, customCommandButton) + } + + override fun createMediaActionPendingIntent(mediaSession: MediaSession, command: Long): PendingIntent { + return delegate.createMediaActionPendingIntent(mediaSession, command) + } + + override fun createNotificationDismissalIntent(mediaSession: MediaSession): PendingIntent { + return delegate.createNotificationDismissalIntent(mediaSession) + } + + private fun createChunkSkipAction( + iconResId: Int, + title: CharSequence, + action: String, + requestCode: Int + ): NotificationCompat.Action { + val intent = Intent(context, TtsService::class.java).apply { + this.action = action + setPackage(context.packageName) + } + val pendingIntent = PendingIntent.getService( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action.Builder( + IconCompat.createWithResource(context, iconResId), + title, + pendingIntent + ) + .setShowsUserInterface(false) + .build() + } +} + +@UnstableApi +private class TtsSessionPlayer( + player: Player, + private val canSkipToPreviousChunk: () -> Boolean, + private val canSkipToNextChunk: () -> Boolean, + private val skipToPreviousChunk: () -> Unit, + private val skipToNextChunk: () -> Unit, + private val isCurrentChunkStreaming: () -> Boolean, + private val currentChunkDurationForNotification: (Long) -> Long +) : ForwardingPlayer(player) { + override fun getAvailableCommands(): Player.Commands { + val builder = super.getAvailableCommands().buildUpon() + if (canSkipToPreviousChunk()) { + builder + .add(Player.COMMAND_SEEK_TO_PREVIOUS) + .add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + } else { + builder + .remove(Player.COMMAND_SEEK_TO_PREVIOUS) + .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + } + if (canSkipToNextChunk()) { + builder + .add(Player.COMMAND_SEEK_TO_NEXT) + .add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + } else { + builder + .remove(Player.COMMAND_SEEK_TO_NEXT) + .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + } + return builder.build() + } + + override fun isCommandAvailable(command: Int): Boolean { + return getAvailableCommands().contains(command) + } + + override fun hasPreviousMediaItem(): Boolean { + return canSkipToPreviousChunk() || super.hasPreviousMediaItem() + } + + override fun hasNextMediaItem(): Boolean { + return canSkipToNextChunk() || super.hasNextMediaItem() + } + + override fun seekToPrevious() { + skipToPreviousChunk() + } + + override fun seekToPreviousMediaItem() { + skipToPreviousChunk() + } + + override fun seekToNext() { + skipToNextChunk() + } + + override fun seekToNextMediaItem() { + skipToNextChunk() + } + + override fun getDuration(): Long { + return notificationDurationMs().takeIf { it != C.TIME_UNSET } ?: super.getDuration() + } + + override fun getContentDuration(): Long { + return getDuration() + } + + override fun getBufferedPosition(): Long { + return adjustedStreamingBufferedPosition(super.getBufferedPosition()) + } + + override fun getContentBufferedPosition(): Long { + return getBufferedPosition() + } + + override fun getBufferedPercentage(): Int { + val duration = notificationDurationMs() + if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) { + return super.getBufferedPercentage() + } + val bufferedPosition = getBufferedPosition().coerceIn(0L, duration) + return ((bufferedPosition * 100L) / duration).toInt().coerceIn(0, 100) + } + + override fun isCurrentMediaItemDynamic(): Boolean { + return if (isCurrentChunkStreaming()) false else super.isCurrentMediaItemDynamic() + } + + private fun notificationDurationMs(): Long { + val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L) + return currentChunkDurationForNotification(currentPositionMs) + } + + private fun adjustedStreamingBufferedPosition(delegatePositionMs: Long): Long { + val duration = notificationDurationMs() + if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) { + return delegatePositionMs + } + val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L) + val bufferedPositionMs = if (delegatePositionMs == C.TIME_UNSET || delegatePositionMs < currentPositionMs) { + currentPositionMs + } else { + delegatePositionMs + } + return bufferedPositionMs.coerceIn(0L, duration) + } +} @UnstableApi class TtsService : MediaSessionService() { @@ -258,6 +486,29 @@ class TtsService : MediaSessionService() { private var foregroundChapterTitle: String? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK -> { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification previous chunk action received.") + Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i( + "serviceAction=notification-previous hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}" + ) + if (::playbackManager.isInitialized) { + playbackManager.skipToPreviousChunkFromTransport() + } + return START_STICKY + } + ACTION_TTS_NOTIFICATION_NEXT_CHUNK -> { + Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification next chunk action received.") + Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i( + "serviceAction=notification-next hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}" + ) + if (::playbackManager.isInitialized) { + playbackManager.skipToNextChunkFromTransport() + } + return START_STICKY + } + } + val result = super.onStartCommand(intent, flags, startId) Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( "onStartCommand. action=${intent?.action}, startId=$startId, result=$result" @@ -747,6 +998,7 @@ class TtsService : MediaSessionService() { override fun onCreate() { super.onCreate() Timber.d("TtsService created.") + setMediaNotificationProvider(TtsMediaNotificationProvider(this)) 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( @@ -818,7 +1070,17 @@ class TtsService : MediaSessionService() { onPlaybackSessionStopped = ::onPlaybackSessionStopped ) - mediaSession = MediaSession.Builder(this, player) + val sessionPlayer = TtsSessionPlayer( + player = player, + canSkipToPreviousChunk = playbackManager::canSkipToPreviousChunk, + canSkipToNextChunk = playbackManager::canSkipToNextChunk, + skipToPreviousChunk = playbackManager::skipToPreviousChunkFromTransport, + skipToNextChunk = playbackManager::skipToNextChunkFromTransport, + isCurrentChunkStreaming = playbackManager::isCurrentChunkStreaming, + currentChunkDurationForNotification = playbackManager::currentChunkDurationForNotification + ) + + mediaSession = MediaSession.Builder(this, sessionPlayer) .setCallback(playbackManager) .build() diff --git a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt b/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt index a283137..4ed63a3 100644 --- a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt +++ b/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt @@ -29,8 +29,10 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily import com.materialkolor.PaletteStyle import androidx.compose.ui.platform.LocalContext +import com.aryan.reader.shared.ui.withAppFontFamily import com.materialkolor.dynamicColorScheme private val lightScheme = lightColorScheme( @@ -116,6 +118,7 @@ fun AppTheme( seedColor: Color? = null, contrastLevel: Double = 0.0, textDimFactor: Float = 1.0f, + appFontFamily: FontFamily? = null, content: @Composable () -> Unit ) { val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @@ -174,7 +177,7 @@ fun AppTheme( MaterialTheme( colorScheme = finalColorScheme, - typography = AppTypography, + typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography, content = content ) -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable-nodpi/contrast.xml b/app/src/main/res/drawable-nodpi/contrast.xml new file mode 100644 index 0000000..f7a2104 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/contrast.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/skip_next.xml b/app/src/main/res/drawable-nodpi/skip_next.xml new file mode 100644 index 0000000..3beb9c7 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/skip_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/skip_previous.xml b/app/src/main/res/drawable-nodpi/skip_previous.xml new file mode 100644 index 0000000..222c235 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/skip_previous.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index faee490..b883ca1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -807,7 +807,7 @@ Сгенерировать пересказ сюжета Пересказ сюжета Загружено из кэша (Бесплатно) - Сгенерировано • Бесплатно (1%$d/10) + Сгенерировано • Бесплатно (%1$d/10) Сгенерировано • Стоимость: %1$s кредитов Генерация... • Расчет стоимости... Результат ИИ @@ -816,7 +816,7 @@ Кредиты Баланс кредитов Доступно кредитов - %1$ кредитов + %1$d кредитов Примерный расчет стоимости Облачный синтез речи (TTS) Стоимость: ~3-4 кредита за минуту сгенерированного аудио.\n\nЧтобы включить : Читатель экрана> Дополнительно > Настройки голоса TTS. diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 8d007e4..81381ea 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -59,18 +59,144 @@ %1$d books removed from library. + + + Importing %1$d book… It will appear in your Library shortly. + Importing %1$d books… They will appear in your Library shortly. + + + + + Imported %1$d book. You can find it in the Library tab. + Imported %1$d books. You can find them in the Library tab. + + + + + %1$d book added to shelf. + %1$d books added to shelf. + + + + + %1$d book tagged with "%2$s". + %1$d books tagged with "%2$s". + + + + + Removed folder "%1$s" and %2$d book from the app. + Removed folder "%1$s" and %2$d books from the app. + + %1$d folder %1$d folders + + + %1$d file + %1$d files + + + + + Drop to import %1$d file + Drop to import %1$d files + + + + + %1$d unsupported file will be skipped. + %1$d unsupported files will be skipped. + + + + + Importing %1$d file… + Importing %1$d files… + + + + + Imported %1$d file. + Imported %1$d files. + + + + + Imported %1$d file. Reader support comes later. + Imported %1$d files. Reader support comes later. + + + + + Could not import %1$d file. + Could not import %1$d files. + + + + + Skipped %1$d file. + Skipped %1$d files. + + + + + Remove "%1$s" and its %2$d book from the app? Files on disk will not be deleted. + Remove "%1$s" and its %2$d books from the app? Files on disk will not be deleted. + + + + + Folder sync failed for %1$d folder. + Folder sync failed for %1$d folders. + + + + + Folder sync finished with %1$d folder skipped. + Folder sync finished with %1$d folders skipped. + + + + + Removed %1$d streamed OPDS book from that catalog. + Removed %1$d streamed OPDS books from that catalog. + + %1$d tag %1$d tags + + + All Books %1$d + All Books %1$d + + + + + Shelves %1$d + Shelves %1$d + + + + + Tags %1$d + Tags %1$d + + + + + Folders %1$d + Folders %1$d + + (%1$d chunk) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c37c09..8e56792 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,7 @@ Free Active Tabs + Show tabs in top app bar Close Tab Close All Tabs Close All Tabs? @@ -733,6 +734,10 @@ Pause TTS Resume TTS + + Previous TTS chunk + + Next TTS chunk Exit slider navigation Start page thumbnail Expand @@ -769,10 +774,13 @@ Bookmarks Highlights Pages + Images Expand All Collapse All Locate You haven\'t added any bookmarks yet. + No images found. + Download image More options for bookmark Rename Bookmark New Name @@ -786,6 +794,8 @@ Are you sure you want to permanently delete this highlight? + Saved %1$s + Could not save image. Original PDF not found. @@ -833,8 +843,13 @@ No imported fonts yet. Visual Options Page layout + PDF page spread + Single page + Two pages + First page alone + Starts facing-page spreads after the cover page. Remove gap between pages - Applies to vertical reading mode. + Applies to vertical reading and two-page spreads. Hide page number overlay Removes the small page count label from each page. @@ -850,6 +865,12 @@ Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation. Remove Edge Padding Removes the horizontal gap on the left and right edges. + Brightness + Use system brightness + Follows the device brightness setting. + Custom brightness + Applies while a reader screen is open. + %1$d%% @@ -877,6 +898,20 @@ Open source libraries used. Importing %1$d books… They will appear in your Library shortly. + + Created shelf "%1$s". + + Created smart shelf "%1$s". + + Renamed shelf to "%1$s". + + Deleted shelf "%1$s". + + Updated "%1$s". + + Those files are already in the library. + + %1$s - %2$s External Link @@ -887,13 +922,21 @@ Save Note + Save + Save Comment + Add Comment + Reply Add a note… + Add a comment… Dict Speak Note + Comments + Editing comment + Replying to %1$s Edit @@ -1097,6 +1140,8 @@ Enable Multi-Tab Reading Use Strict File Filter + + Use PDF Filenames Language Test Panel ML Detection @@ -1175,8 +1220,8 @@ Recent Title A-Z Author A-Z - Percent complete 0-100 - Percent complete 100-0 + Percent complete 0–100 + Percent complete 100–0 Size (Smallest) Size (Biggest) All @@ -1243,9 +1288,9 @@ %1$d Credits Estimated Cost Breakdown Cloud TTS - Cost: ~3-4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings. + Cost: ~3–4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings. AI Summaries & Recap - Cost: ~1-4 credits per request based on chapter length.\nPro Users get 10 free summaries daily. + Cost: ~1–4 credits per request based on chapter length.\nPro Users get 10 free summaries daily. By purchasing, Out of Credits You don\'t have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap. @@ -1511,6 +1556,7 @@ Drag to reorder External Apps Navigation Slider + Brightness Sidebar Highlight selectable text Edit Mode @@ -1561,4 +1607,403 @@ Nederlands (Dutch) Українська (Ukrainian) Bahasa Indonesia (Indonesian) + + About + Desktop reader + Desktop access + Account + AI hub + Used for EPUB summaries and PDF page summaries. + Author text + + Cache: %1$s + Cached + Cached summary + Choose the Gemini voice used for cloud read aloud. + Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened. + Clear voice cache + Close tools + Cloud TTS needs Gemini + Cloud TTS needs signed-in credits + Cloud TTS ready + Cloud TTS settings + Cloud TTS unavailable + Cloud TTS voice + Contains + Cost calculating + Create a recap up to your current position. + Create smart shelf + + %1$d credits available + + %1$s credits + Imported fonts for the reader + Delete font + + Delete %1$s? Books using it will fall back to the default font. + + Delete \"%1$s\"? Books stay in your library. + Delete summary + Disabled + Drop files to import + Drop supported files to import + Contact us directly by email for anything else. + Equals + Extras + Feedback + Field + Folder path + From here + Full scan + + Free, %1$d left + Generate recap + Generate summary + Report bugs, request features, or contact support directly. + GitHub Sponsors + Support development through GitHub Sponsors. + Google sign-in is not configured for this desktop build. + Greater than + Bug reports, feature requests, and support + Hide + Import files + Issues + Open the issue tracker for bugs and feature requests. + Less than + Library and reader + Any + No cached summaries for this book yet. + Import TTF, OTF, or WOFF2 files to use them in books. + + No fonts found matching \"%1$s\" + No Google account is connected. + No summary cached for this section. + Open readers + + Opening %1$s + Opening your library + Operator + Page + Password protected PDF + Patreon + Support the project on Patreon. + Paused + + %1$s requires a password before it can be opened. + Password is required or incorrect. + + That password did not open %1$s. Enter the PDF password and try again. + Percent + Preparing audio + Pro + Pro and credits + Pro is not unlocked for this account. + Pro and credits can only be purchased from the Android app. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI features. + Sign in to check your account status on desktop. + Pro is unlocked for this account. + Progress + Project + Reader + Refresh + Release to add to your library. + Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted. + Settings hub + Matches the Android hide toggle for smart dictionary, summaries, and recaps. + Signed in + Source code + Browse the project source on GitHub. + Stop reading to change voices. + Support + Support Episteme + Contributions help keep the reader improving across Android and desktop. + Ways to support Episteme development + Sync folders + Sync metadata + Tag name + Tag selected books + Title text + Tools + Import, sync, and app settings + Type, e.g. PDF + View + Voice cache + Preparing embedded webview… + + Preparing bundled embedded webview %1$d%% + Embedded webview installed. Restart Episteme to finish setup. + + Embedded webview could not start: %1$s + Working… + Workspace + + Add to shelf + Create a shelf first, then add selected books to it. + Create theme + + Existing: %1$s + You clicked an external link. + Edit EPUB metadata + Less + …more + No custom themes yet + Rename in app + Tags, comma separated + Unknown + Define + Annotation + Annotation options + Annotation tools + Assist + Choose which PDF to save. + Clear jump history + Cloud TTS failed. + Add a Gemini key and select Gemini cloud TTS in AI keys and models. + Cloud TTS is not configured for this desktop build. + Sign in with Google to use cloud TTS. + Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app. + Color + Comment options + Custom + This removes the annotation from this PDF. + Delete annotation? + Document text + Embedded PDF comment + Failed to render page. + Feature unavailable + Finished + Fountain pen + Hide search results + + Highlight color %1$d + Highlighter palette + Interaction + + Indexing %1$d/%2$d pages + Markup + + %1$d matches + + %1$d matches so far + Next page + Next search result + No annotations yet + No bookmarks yet + No comment + No matches + No matches in indexed pages yet + No table of contents + There is no text here to read. + There is no text on this page to read. + There is no text to summarize. + Open comment + Out of credits. Pro and credits can only be purchased from the Android app. + Using cloud TTS needs credits on desktop. Pro and credits can only be purchased from the Android app. + Using this feature needs credits on desktop. Pro and credits can only be purchased from the Android app. + Using recaps needs credits on desktop. Pro and credits can only be purchased from the Android app. + Using summaries needs credits on desktop. Pro and credits can only be purchased from the Android app. + Pan + PDF action failed + The PDF action could not be completed. + PDF comment + + p. %1$d + + PDF page %1$d + + Page %1$d - %2$s + + Page %1$s of %2$d + + Pages %1$s of %2$d + PDF saved + PDF tools + Pencil + Preparing selection + + Preparing %1$s + Previous page + Previous search result + The print dialog has finished. + Pro required + This feature requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in. + Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in. + Reader AI features are hidden. + Desktop AI is not configured for this build. + Applies to vertical reading mode. + Round highlighter + + Saved to %1$s + Scroll + Search in PDF + Select text + + Selected %1$s + Show search results + Sign in with Google to use this feature on desktop. + Sign in with Google to use multi-word smart dictionary on desktop. + Sign in with Google to use recaps on desktop. + Sign in with Google to use summaries on desktop. + Stopped + Text note + text note + Text style + + Thickness %1$s + TOC + Type to search this PDF + Untitled + View Pro and credits + Voice cache cleared + Zoom + Zoom in + Zoom out + Choose + Continue reading + Dismiss + Down + Up + AI + Center + Authors + Back to library + Book actions + + Folder + Browse + Categories + + Ch. %1$d + Chapter Turns + Choose font + Choose reader texture + Clear file types + Clear page annotations + Clear sources + Clear status + Clear tags + Close reader + Continuous + Covers + Custom colors + Custom theme preview + + Decrease %1$s + Define page + This removes the highlight and its note. + Enter full screen + Exit full screen + External lookup + + Books + Comics + Documents + Other + Text and web + Fill + Fixed-layout appearance + Folder is empty + No supported files or subfolders are available here. + + %1$s, %2$s + + %1$s - %2$s + Hide filters + Hide reader tools + Tap a slot, then pick a color. + Continue reading and recent books + Import books + Import folder + Imported fonts + + %1$s %2$s + + Increase %1$s + Jump history + Layout and Spacing + Import files into app storage or add a folder to read files in place. + Browse your collection + + Smart %1$d + + Unread %1$d + + In progress %1$d + + Complete %1$d + List + Navigation + No book open + Add a folder to read files from that folder in place. + No folders yet + No navigation items + No page content + No settings found + Manual shelves and series collections will appear here. + No shelves yet + Create smart shelves to collect books by rules. + No smart shelves yet + Tags added to books will appear here. + No tags yet + + No supported files were imported. + Catalog + + Delete "%1$s"? Streamed books from this catalog may stop opening if credentials change later. + No catalogs + Add an OPDS catalog to browse remote books. + Browse catalogs, streams, and downloads + Open Book + Open folder + Open PDF + Page and text colors + Page Info + Page width + These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader. + PDF file actions + PDF highlighter + Saved with reader highlight transparency. + Pin + Reader-managed PDF tools + Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader. + + %1$s %2$s of %3$d (%4$d%%) + Reader toolbar defaults are managed from the reader on this platform. + Reader tools + Save image + + Search: %1$s + Search in reader + Search settings + Selection + Selection end handle + Selection start handle + + Add shelves, tags, or folder metadata to organize your library. + Collections, series, tags, and folders + Show reader tools + + Folder + Smart + Solid + Speed + Start auto scroll + Stop auto scroll + Stop read aloud + Texture strength + Type to search this book + Typography + Undo annotation + Unpin + Use dark theme + Use light theme + Mono + Sans + Serif + Search books, authors, or tags + No tools + Visible + Replace only what is spoken + Reader text, highlights, and locations stay unchanged. + + %1$s -> %2$s diff --git a/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt b/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt index 8facafd..d50c511 100644 --- a/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt +++ b/app/src/test/java/com/aryan/reader/AndroidSettingsHubModelsTest.kt @@ -123,6 +123,7 @@ class AndroidSettingsHubModelsTest { uiState = ReaderScreenState( isTabsEnabled = true, useStrictFileFilter = true, + usePdfFileNameAsDisplayName = true, isScreenCaptureProtectionEnabled = true ), isOssBuild = false, @@ -134,6 +135,7 @@ class AndroidSettingsHubModelsTest { assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true) assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true) + assertTrue(toggles.getValue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME).checked == true) assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true) } @@ -153,6 +155,7 @@ class AndroidSettingsHubModelsTest { assertTrue(SharedSettingsAction.LANGUAGE in extraActions) assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions) assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions) + assertTrue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME in extraActions) assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions) assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions) assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions) diff --git a/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt b/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt index da1a270..67b77ac 100644 --- a/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt +++ b/app/src/test/java/com/aryan/reader/AndroidSharedStateBridgeTest.kt @@ -4,6 +4,7 @@ import com.aryan.reader.data.BookTagCrossRef import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.TagEntity import com.aryan.reader.shared.AppAction as SharedAppAction +import com.aryan.reader.shared.AppFontPreference as SharedAppFontPreference import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode import com.aryan.reader.shared.LibraryAction as SharedLibraryAction import org.junit.Assert.assertEquals @@ -78,6 +79,19 @@ class AndroidSharedStateBridgeTest { assertEquals(AppThemeMode.DARK, result.appThemeMode) } + @Test + fun `reduceAppAction applies shared app font preference back to Android fields`() { + val preference = SharedAppFontPreference.custom("font") + + val result = AndroidSharedStateBridge.reduceAppAction( + current = ReaderScreenState(), + projectedState = ReaderScreenState(), + action = SharedAppAction.AppFontPreferenceChanged(preference) + ) + + assertEquals(preference, result.appFontPreference) + } + @Test fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() { val result = AndroidSharedStateBridge.setTabsEnabled( diff --git a/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt b/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt new file mode 100644 index 0000000..2375e83 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/AndroidStringFormatResourcesTest.kt @@ -0,0 +1,143 @@ +package com.aryan.reader + +import java.io.File +import java.util.Date +import java.util.IllegalFormatException +import java.util.Locale +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidStringFormatResourcesTest { + + @Test + fun `localized formatted strings use valid formatter syntax`() { + val resDirectory = findResDirectory() + val baseStrings = readStringResources(File(resDirectory, "values/strings.xml")) + val formattedBaseStrings = baseStrings + .mapValues { (_, value) -> value.formatArguments() } + .filterValues { it.isNotEmpty() } + + val failures = resDirectory + .listFiles() + .orEmpty() + .filter { it.isDirectory && it.name.startsWith("values") } + .map { File(it, "strings.xml") } + .filter { it.isFile } + .flatMap { stringsFile -> + val strings = readStringResources(stringsFile) + formattedBaseStrings.mapNotNull { (name, arguments) -> + val value = strings[name] ?: return@mapNotNull null + val sampleArguments = arguments.toSampleArguments() + try { + String.format(Locale.ROOT, value, *sampleArguments) + null + } catch (exception: IllegalFormatException) { + "${stringsFile.invariantSeparatorsPath}:$name -> ${exception.javaClass.simpleName}: ${exception.message}" + } + } + } + + assertTrue(failures.joinToString(separator = "\n"), failures.isEmpty()) + } + + private fun findResDirectory(): File { + return listOf( + File("src/main/res"), + File("app/src/main/res") + ).first { it.isDirectory } + } + + private fun readStringResources(stringsFile: File): Map { + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(stringsFile) + val nodes = document.getElementsByTagName("string") + + return buildMap { + for (index in 0 until nodes.length) { + val node = nodes.item(index) + val name = node.attributes + ?.getNamedItem("name") + ?.nodeValue + ?: continue + put(name, node.textContent) + } + } + } + + private fun String.formatArguments(): List { + val arguments = mutableListOf() + var nextImplicitIndex = 0 + var previousIndex = -1 + + for (match in formatterPattern.findAll(this)) { + val conversion = (match.groups[4] ?: match.groups[5])?.value?.singleOrNull() ?: continue + val dateTimePrefix = match.groups[3]?.value + if (conversion == '%' || conversion == 'n') continue + + val index = when { + match.groups[2] != null -> previousIndex + match.groups[1] != null -> match.groups[1]!!.value.toInt() - 1 + else -> nextImplicitIndex++ + } + if (index < 0) continue + + previousIndex = index + val argument = FormatArgument(index, conversion.sampleKind(dateTimePrefix != null)) + val existingIndex = arguments.indexOfFirst { it.index == index } + if (existingIndex >= 0) { + arguments[existingIndex] = arguments[existingIndex].merge(argument) + } else { + arguments += argument + } + } + + return arguments + } + + private fun Char.sampleKind(isDateTime: Boolean): SampleKind { + if (isDateTime) return SampleKind.DateTime + return when (lowercaseChar()) { + 'd', 'o', 'x' -> SampleKind.Integer + 'e', 'f', 'g', 'a' -> SampleKind.Decimal + 'c' -> SampleKind.Character + 'b' -> SampleKind.Boolean + 'h', 's' -> SampleKind.Text + else -> SampleKind.Text + } + } + + private fun List.toSampleArguments(): Array { + val maxIndex = maxOf { it.index } + val samples = Array(maxIndex + 1) { "sample" } + forEach { argument -> + samples[argument.index] = argument.kind.sample + } + return samples + } + + private data class FormatArgument( + val index: Int, + val kind: SampleKind + ) { + fun merge(other: FormatArgument): FormatArgument { + return if (kind == other.kind) this else copy(kind = SampleKind.Text) + } + } + + private enum class SampleKind(val sample: Any) { + Text("sample"), + Integer(7), + Decimal(1.5), + Character('x'), + Boolean(true), + DateTime(Date(0L)) + } + + private companion object { + private val formatterPattern = Regex( + "%(?:([1-9]\\d*)\\$|(<))?[-#+ 0,(]*\\d*(?:\\.\\d+)?(?:(?:([tT])([a-zA-Z]))|([bBhHsScCdoxXeEfgGaA%n]))" + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/AppFontResolverTest.kt b/app/src/test/java/com/aryan/reader/AppFontResolverTest.kt new file mode 100644 index 0000000..906df99 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/AppFontResolverTest.kt @@ -0,0 +1,34 @@ +package com.aryan.reader + +import com.aryan.reader.data.CustomFontEntity +import org.junit.Assert.assertNull +import org.junit.Test + +class AppFontResolverTest { + @Test + fun `custom app font falls back to system when imported font is missing`() { + val resolved = AppFontPreference.custom("missing") + .toAndroidAppFontFamily(customFonts = emptyList()) + + assertNull(resolved) + } + + @Test + fun `custom app font falls back to system when imported font file is gone`() { + val resolved = AppFontPreference.custom("font") + .toAndroidAppFontFamily( + customFonts = listOf( + CustomFontEntity( + id = "font", + displayName = "Missing", + fileName = "missing.ttf", + fileExtension = "ttf", + path = "build/test-tmp/AppFontResolverTest/missing-${System.nanoTime()}.ttf", + timestamp = 1L + ) + ) + ) + + assertNull(resolved) + } +} diff --git a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt index 7fef6e8..a69cce7 100644 --- a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt +++ b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt @@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.w3c.dom.Element class AppLanguageOptionsTest { @@ -79,6 +80,25 @@ class AppLanguageOptionsTest { assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag }) } + @Test + fun `android manifest enables AppCompat language persistence`() { + val manifest = readAndroidManifest() + val service = manifest.getElementsByTagName("service").asElements() + .singleOrNull { + it.androidAttribute("name") == "androidx.appcompat.app.AppLocalesMetadataHolderService" + } + + assertTrue(service != null) + assertEquals("false", service!!.androidAttribute("enabled")) + assertEquals("false", service.androidAttribute("exported")) + + val autoStoreLocales = service.getElementsByTagName("meta-data").asElements() + .singleOrNull { it.androidAttribute("name") == "autoStoreLocales" } + + assertTrue(autoStoreLocales != null) + assertEquals("true", autoStoreLocales!!.androidAttribute("value")) + } + private fun readLocaleConfigTags(): List { val localeConfig = listOf( File("src/main/res/xml/locales_config.xml"), @@ -101,4 +121,28 @@ class AppLanguageOptionsTest { } } } + + private fun readAndroidManifest(): org.w3c.dom.Document { + val manifest = listOf( + File("src/main/AndroidManifest.xml"), + File("app/src/main/AndroidManifest.xml") + ).first { it.isFile } + return DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = true } + .newDocumentBuilder() + .parse(manifest) + } + + private fun org.w3c.dom.NodeList.asElements(): List = + buildList { + for (index in 0 until length) { + val element = item(index) as? Element + if (element != null) add(element) + } + } + + private fun Element.androidAttribute(name: String): String? = + attributes + ?.getNamedItemNS("http://schemas.android.com/apk/res/android", name) + ?.nodeValue } diff --git a/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt b/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt index 21c5753..9a97319 100644 --- a/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt +++ b/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt @@ -499,6 +499,70 @@ class LibraryStateProjectorTest { assertEquals(listOf("folder_book"), folderShelf.directBooks.ids()) } + @Test + fun `project preserves app font preference when reusing cached library projection`() { + val book = recentFile("book") + val projector = LibraryStateProjector() + val input = LibraryProjectionInput( + state = ReaderScreenState(), + recentFilesFromDb = listOf(book), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = emptyList(), + tagRefs = emptyList() + ) + + projector.project(input) + val result = projector.project( + input.copy(state = input.state.copy(appFontPreference = AppFontPreference.Monospace)) + ) + + assertEquals(AppFontPreference.Monospace, result.appFontPreference) + } + + @Test + fun `project preserves pdf filename display preference when reusing cached library projection`() { + val book = recentFile("book") + val projector = LibraryStateProjector() + val input = LibraryProjectionInput( + state = ReaderScreenState(), + recentFilesFromDb = listOf(book), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = emptyList(), + tagRefs = emptyList() + ) + + projector.project(input) + val result = projector.project( + input.copy(state = input.state.copy(usePdfFileNameAsDisplayName = true)) + ) + + assertTrue(result.usePdfFileNameAsDisplayName) + } + + @Test + fun `cardTitle can prefer PDF filename over embedded metadata title`() { + val pdf = recentFile( + id = "pdf", + type = FileType.PDF, + displayName = "file-name.pdf", + title = "Metadata title" + ) + val renamedPdf = pdf.copy(customName = "Manual name") + val epub = recentFile( + id = "epub", + type = FileType.EPUB, + displayName = "book.epub", + title = "EPUB title" + ) + + assertEquals("Metadata title", pdf.cardTitle()) + assertEquals("file-name.pdf", pdf.cardTitle(usePdfFileNameAsDisplayName = true)) + assertEquals("Manual name", renamedPdf.cardTitle(usePdfFileNameAsDisplayName = true)) + assertEquals("EPUB title", epub.cardTitle(usePdfFileNameAsDisplayName = true)) + } + private fun recentFile( id: String, uriString: String? = "content://$id", diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt index 7234ef0..3eab3fe 100644 --- a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt +++ b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt @@ -145,6 +145,7 @@ class MainViewModelTest { coEvery { anyConstructed().deleteShelf(any()) } just Runs every { anyConstructed().getAllFonts() } returns customFontsFlow + coEvery { anyConstructed().deleteFont(any()) } just Runs viewModel = MainViewModel(mockApplication) } @@ -210,6 +211,37 @@ class MainViewModelTest { verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) } } + @Test + fun `setAppFontPreference persists app font preference`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + val preference = AppFontPreference.custom("font") + viewModel.setAppFontPreference(preference) + + val state = viewModel.uiState.first { it.appFontPreference == preference } + assertEquals(preference, state.appFontPreference) + verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.CUSTOM.name) } + verify { mockEditor.putString("app_font_custom_id", "font") } + } + + @Test + fun `deleteFont resets matching app custom font preference`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setAppFontPreference(AppFontPreference.custom("font")) + viewModel.deleteFont("font") + advanceUntilIdle() + + assertEquals(AppFontPreference.System, viewModel.uiState.value.appFontPreference) + coVerify { anyConstructed().deleteFont("font") } + verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.SYSTEM.name) } + verify { mockEditor.remove("app_font_custom_id") } + } + @Test fun `setTabsEnabled persists to shared preferences`() = runTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { @@ -286,20 +318,23 @@ class MainViewModelTest { } @Test - fun `strict file filter and external file behavior persist preferences`() = runTest { + fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} } viewModel.setStrictFileFilter(true) + viewModel.setUsePdfFileNameAsDisplayName(true) viewModel.setExternalFileBehavior("KEEP") val state = viewModel.uiState.first { - it.useStrictFileFilter && it.externalFileBehavior == "KEEP" + it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP" } assertTrue(state.useStrictFileFilter) + assertTrue(state.usePdfFileNameAsDisplayName) assertEquals("KEEP", state.externalFileBehavior) verify { mockEditor.putBoolean("use_strict_file_filter", true) } + verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) } verify { mockEditor.putString("external_file_behavior", "KEEP") } } diff --git a/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt b/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt new file mode 100644 index 0000000..e6339e5 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/ReaderBrightnessSettingsTest.kt @@ -0,0 +1,18 @@ +package com.aryan.reader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderBrightnessSettingsTest { + + @Test + fun `brightness settings default to system and clamp custom values`() { + val defaults = ReaderBrightnessSettings() + + assertTrue(defaults.useSystemBrightness) + assertEquals(0.75f, defaults.safeCustomBrightness, 0.0001f) + assertEquals(0.05f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f) + assertEquals(1f, defaults.copy(customBrightness = 2f).safeCustomBrightness, 0.0001f) + } +} diff --git a/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt b/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt new file mode 100644 index 0000000..1283ade --- /dev/null +++ b/app/src/test/java/com/aryan/reader/ReaderSliderChromeStateTest.kt @@ -0,0 +1,106 @@ +package com.aryan.reader + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderSliderChromeStateTest { + + @Test + fun `toggle opens slider anchored to current page`() { + val state = readerSliderToggleState( + isCurrentlyToggledOn = false, + currentPage = 12 + ) + + assertTrue(state.isToggledOn) + assertEquals(12, state.bookmarkPosition.startPage) + assertEquals(12f, state.bookmarkPosition.currentPage) + } + + @Test + fun `toggle closes slider and resets bookmark anchor`() { + val state = readerSliderToggleState( + isCurrentlyToggledOn = true, + currentPage = 4 + ) + + assertFalse(state.isToggledOn) + assertEquals(4, state.bookmarkPosition.startPage) + assertEquals(4f, state.bookmarkPosition.currentPage) + } + + @Test + fun `slider only renders while toggled on and chrome is visible`() { + assertTrue( + shouldRenderReaderSlider( + isToggledOn = true, + isBottomChromeVisible = true, + isSearchActive = false + ) + ) + assertFalse( + shouldRenderReaderSlider( + isToggledOn = true, + isBottomChromeVisible = false, + isSearchActive = false + ) + ) + assertFalse( + shouldRenderReaderSlider( + isToggledOn = true, + isBottomChromeVisible = true, + isSearchActive = true + ) + ) + assertFalse( + shouldRenderReaderSlider( + isToggledOn = false, + isBottomChromeVisible = true, + isSearchActive = false + ) + ) + } + + @Test + fun `bookmark position clamps invalid page to start`() { + val position = readerSliderBookmarkPosition(currentPage = -3) + + assertEquals(0, position.startPage) + assertEquals(0f, position.currentPage) + } + + @Test + fun `toggle preference key is scoped to book id`() { + assertEquals( + "reader_slider_toggle_book-123", + readerSliderTogglePreferenceKey("book-123") + ) + } + + @Test + fun `slider content color falls back on light page when theme text is low contrast`() { + val colors = readerSliderChromeColors( + pageBackground = Color.White, + pageText = Color.White, + themePrimary = Color(0xFF6750A4) + ) + + assertEquals(Color.Black, colors.contentColor) + } + + @Test + fun `slider accent falls back when primary is low contrast against page`() { + val colors = readerSliderChromeColors( + pageBackground = Color.Black, + pageText = Color.White, + themePrimary = Color(0xFF050505) + ) + + assertEquals(Color.White, colors.activeTrackColor) + assertEquals(Color.White, colors.thumbColor) + assertEquals(Color.White, colors.bookmarkColor) + } +} diff --git a/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt b/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt index 098ad1d..b55a261 100644 --- a/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt +++ b/app/src/test/java/com/aryan/reader/SharedModelMappersTest.kt @@ -3,6 +3,7 @@ package com.aryan.reader import com.aryan.reader.data.BookTagCrossRef import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.TagEntity +import com.aryan.reader.shared.ReaderFeatureSurface import com.aryan.reader.shared.FileType as SharedFileType import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.Shelf as SharedShelf @@ -10,6 +11,7 @@ import com.aryan.reader.shared.ShelfType as SharedShelfType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test class SharedModelMappersTest { @@ -112,6 +114,8 @@ class SharedModelMappersTest { assertSame(folder, folder.toSharedSyncedFolder()) assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters()) assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder()) + assertTrue(FileType.PPTX in PDF_VIEWER_FILE_TYPES) + assertEquals(ReaderFeatureSurface.PDF_VIEWER, FileType.PPTX.readerSurfaceOnAndroid()) assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES) assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES) } diff --git a/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt b/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt index 9e4e145..7a03a90 100644 --- a/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt +++ b/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt @@ -120,6 +120,27 @@ class SingleFileImporterTest { assertTrue(File(book.extractionBasePath, "page_1.html").readText().contains("p { color: red; }")) } + @Test + fun `html import chunks very long lines into bounded chapters`() = runTest { + val importer = SingleFileImporter(contextWithCache(temp.newFolder("html-long-line-cache"))) + val longParagraph = "word ".repeat(260_000) + val html = "

$longParagraph

" + + val book = importer.importSingleFile( + inputStream = ByteArrayInputStream(html.toByteArray()), + type = FileType.HTML, + originalBookNameHint = "long.html", + bookId = "long-html-book" + ) + + assertTrue(book.chapters.size > 1) + book.chapters.forEach { chapter -> + val chapterFile = File(book.extractionBasePath, chapter.htmlFilePath) + assertTrue(chapterFile.isFile) + assertTrue(chapterFile.length() < 1_200_000L) + } + } + @Test fun `csv txt wrapper imports as html table`() = runTest { val importer = SingleFileImporter(contextWithCache(temp.newFolder("csv-cache"))) diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt index 9a51d7c..833efee 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt @@ -10,6 +10,7 @@ import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -178,6 +179,24 @@ class EpubReaderBridgeAndControlsTest { verify { webView.evaluateJavascript("javascript:window.autoScroll.stop();", null) } } + @Test + fun `web view hit test guard treats chromium null state as unknown tap`() { + val type = readWebViewHitTestTypeOrNull { + throw NullPointerException("chromium hit test result missing") + } + + assertNull(type) + assertFalse(isWebViewAnchorHitTestType(type)) + } + + @Test + fun `web view hit test helper detects anchor result types`() { + assertTrue(isWebViewAnchorHitTestType(WebView.HitTestResult.SRC_ANCHOR_TYPE)) + assertTrue(isWebViewAnchorHitTestType(WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE)) + assertFalse(isWebViewAnchorHitTestType(null)) + assertFalse(isWebViewAnchorHitTestType(WebView.HitTestResult.IMAGE_TYPE)) + } + @Test fun `initiateTtsPlayback chooses web extraction for vertical mode and callback for paginated mode`() { val webView = mockk(relaxed = true) @@ -197,11 +216,15 @@ class EpubReaderBridgeAndControlsTest { assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" }) assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" }) assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category) + assertEquals("Top Bar", ReaderTool.BRIGHTNESS.category) } @Test fun `reader toolbar reset defaults match first-run toolbar defaults`() { - assertEquals(setOf(ReaderTool.SCREEN_ORIENTATION.name), defaultReaderHiddenTools()) + assertEquals( + setOf(ReaderTool.SCREEN_ORIENTATION.name, ReaderTool.BRIGHTNESS.name), + defaultReaderHiddenTools() + ) assertEquals(ReaderTool.entries.toList(), defaultReaderToolOrder()) assertEquals( ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(), @@ -218,9 +241,61 @@ class EpubReaderBridgeAndControlsTest { ToolbarSection.HIDDEN, defaultItems.single { it.tool == ReaderTool.SCREEN_ORIENTATION }.section ) + assertEquals( + ToolbarSection.HIDDEN, + defaultItems.single { it.tool == ReaderTool.BRIGHTNESS }.section + ) assertEquals( ToolbarSection.BOTTOM, defaultItems.single { it.tool == ReaderTool.SLIDER }.section ) + assertTrue(defaultItems.any { it.type == FlatItemType.MORE_TOOL && it.tool == ReaderTool.FILE_INFO }) + } + + @Test + fun `epub overflow sections end at auto scroll when tts submenu and file info are hidden`() { + val sections = epubOverflowMenuSections( + hiddenTools = setOf( + ReaderTool.TTS_SETTINGS.name, + ReaderTool.TTS_REPLACEMENTS.name + ), + hasHiddenToolbarTools = false, + hasToggleReflow = false, + hasDeleteReflow = false, + hasFileInfo = false + ) + + assertEquals(EpubOverflowMenuSection.AUTO_SCROLL, sections.last()) + assertTrue(EpubOverflowMenuSection.TTS_SETTINGS !in sections) + } + + @Test + fun `epub overflow sections expose file info only when available and visible`() { + val visibleSections = epubOverflowMenuSections( + hiddenTools = emptySet(), + hasHiddenToolbarTools = false, + hasToggleReflow = false, + hasDeleteReflow = false, + hasFileInfo = true + ) + val missingItemSections = epubOverflowMenuSections( + hiddenTools = emptySet(), + hasHiddenToolbarTools = false, + hasToggleReflow = false, + hasDeleteReflow = false, + hasFileInfo = false + ) + val hiddenSections = epubOverflowMenuSections( + hiddenTools = setOf(ReaderTool.FILE_INFO.name), + hasHiddenToolbarTools = false, + hasToggleReflow = false, + hasDeleteReflow = false, + hasFileInfo = true + ) + + assertTrue(EpubOverflowMenuSection.FILE_INFO in visibleSections) + assertEquals(EpubOverflowMenuSection.FILE_INFO, visibleSections.last()) + assertFalse(EpubOverflowMenuSection.FILE_INFO in missingItemSections) + assertFalse(EpubOverflowMenuSection.FILE_INFO in hiddenSections) } } diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt index 6dab48b..c44196c 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt @@ -4,6 +4,7 @@ import android.content.Context import com.aryan.reader.R import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubChapter +import com.aryan.reader.epub.hasReadableExtractedContent import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.LocatorConverter import io.mockk.coEvery @@ -47,9 +48,38 @@ class EpubReaderContentTest { assertFalse(result.chunks.joinToString().contains("