diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f040f9..4174b4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,7 +19,7 @@ if (localPropertiesFile.exists()) { android { namespace = "com.aryan.reader" - compileSdk = 35 + compileSdk = 36 ndkVersion = "29.0.14206865" defaultConfig { @@ -211,7 +211,7 @@ dependencies { implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.browser:browser:1.8.0") - implementation("io.legere:pdfiumandroid:1.0.35") + implementation("io.legere:pdfiumandroid:2.0.0") } spotless { diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index e278bd0..33e643d 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #define LOG_TAG "PdfiumAnnotation" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) @@ -29,7 +31,21 @@ typedef int (*FPDFBitmap_GetStride_t)(void* bitmap); typedef void* (*FPDFBitmap_GetBuffer_t)(void* bitmap); typedef void (*FPDFBitmap_Destroy_t)(void* bitmap); typedef int (*FPDFPageObj_GetBounds_t)(void* page_object, float* left, float* bottom, float* right, float* top); +typedef int (*FPDF_DoAnnotAction_t)(void* annot, int action_type); +typedef void* (*FPDFAnnot_GetWidgetAtPoint_t)(void* page, double page_x, double page_y); +typedef void* (*FPDFLink_GetAction_t)(void* link); +typedef unsigned long (*FPDFAction_GetType_t)(void* action); +typedef void* (*FPDFLink_GetAnnot_t)(void* link); +typedef int (*FPDFAnnot_GetFlags_t)(void* annot); +typedef int (*FPDFAnnot_SetFlags_t)(void* annot, int flags); +typedef unsigned long (*FPDFAnnot_GetFormFieldName_t)(void* hFPDFTextPage, void* annot, void* buffer, unsigned long buflen); +static std::mutex g_pdfium_mutex; +static FPDFLink_GetAnnot_t get_link_annot_func = nullptr; +static FPDFLink_GetAction_t get_link_action_func = nullptr; +static FPDFAction_GetType_t get_action_type_func = nullptr; +static FPDF_DoAnnotAction_t do_annot_action_func = nullptr; +static FPDFAnnot_GetWidgetAtPoint_t get_widget_at_point_func = nullptr; static FPDFPage_CountObjects_t count_objects_func = nullptr; static FPDFPage_GetObject_t get_object_func = nullptr; static FPDFPageObj_GetType_t get_object_type_func = nullptr; @@ -51,6 +67,9 @@ static FPDFText_GetFontSize_t get_font_size_func = nullptr; static FPDFText_GetFontWeight_t get_font_weight_func = nullptr; static FPDFText_GetFontInfo_t get_font_info_func = nullptr; static FPDFText_GetCharBox_t get_char_box_func = nullptr; +static FPDFAnnot_GetFlags_t get_annot_flags_func = nullptr; +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 void (*FPDFPage_CloseAnnot_t)(void* annot); @@ -67,41 +86,66 @@ static bool init_pdfium() { return false; } - get_font_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize"); + // --- Text Functions --- + get_font_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize"); get_font_weight_func = (FPDFText_GetFontWeight_t) dlsym(pdfium_handle, "FPDFText_GetFontWeight"); - get_font_info_func = (FPDFText_GetFontInfo_t) dlsym(pdfium_handle, "FPDFText_GetFontInfo"); - get_char_box_func = (FPDFText_GetCharBox_t) dlsym(pdfium_handle, "FPDFText_GetCharBox"); + get_font_info_func = (FPDFText_GetFontInfo_t) dlsym(pdfium_handle, "FPDFText_GetFontInfo"); + get_char_box_func = (FPDFText_GetCharBox_t) dlsym(pdfium_handle, "FPDFText_GetCharBox"); - get_annot_count_func = (FPDFPage_GetAnnotCount_t) dlsym(pdfium_handle, "FPDFPage_GetAnnotCount"); - get_annot_func = (FPDFPage_GetAnnot_t) dlsym(pdfium_handle, "FPDFPage_GetAnnot"); - get_annot_subtype_func = (FPDFAnnot_GetSubtype_t) dlsym(pdfium_handle, "FPDFAnnot_GetSubtype"); - get_annot_rect_func = (FPDFAnnot_GetRect_t) dlsym(pdfium_handle, "FPDFAnnot_GetRect"); - 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"); - close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot"); + // --- Annotation Functions --- + get_annot_count_func = (FPDFPage_GetAnnotCount_t) dlsym(pdfium_handle, "FPDFPage_GetAnnotCount"); + get_annot_func = (FPDFPage_GetAnnot_t) dlsym(pdfium_handle, "FPDFPage_GetAnnot"); + get_annot_subtype_func = (FPDFAnnot_GetSubtype_t) dlsym(pdfium_handle, "FPDFAnnot_GetSubtype"); + get_annot_rect_func = (FPDFAnnot_GetRect_t) dlsym(pdfium_handle, "FPDFAnnot_GetRect"); + 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"); + 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"); - count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects"); - get_object_func = (FPDFPage_GetObject_t) dlsym(pdfium_handle, "FPDFPage_GetObject"); - get_object_type_func = (FPDFPageObj_GetType_t) dlsym(pdfium_handle, "FPDFPageObj_GetType"); - get_image_bitmap_func = (FPDFImageObj_GetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_GetBitmap"); - bitmap_get_width_func = (FPDFBitmap_GetWidth_t) dlsym(pdfium_handle, "FPDFBitmap_GetWidth"); - bitmap_get_height_func = (FPDFBitmap_GetHeight_t) dlsym(pdfium_handle, "FPDFBitmap_GetHeight"); - bitmap_get_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride"); - bitmap_get_buffer_func = (FPDFBitmap_GetBuffer_t) dlsym(pdfium_handle, "FPDFBitmap_GetBuffer"); - bitmap_destroy_func = (FPDFBitmap_Destroy_t) dlsym(pdfium_handle, "FPDFBitmap_Destroy"); - get_object_bounds_func = (FPDFPageObj_GetBounds_t) dlsym(pdfium_handle, "FPDFPageObj_GetBounds"); + // --- Object & Bitmap Functions --- + count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects"); + get_object_func = (FPDFPage_GetObject_t) dlsym(pdfium_handle, "FPDFPage_GetObject"); + get_object_type_func = (FPDFPageObj_GetType_t) dlsym(pdfium_handle, "FPDFPageObj_GetType"); + get_object_bounds_func = (FPDFPageObj_GetBounds_t) dlsym(pdfium_handle, "FPDFPageObj_GetBounds"); + get_image_bitmap_func = (FPDFImageObj_GetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_GetBitmap"); + bitmap_get_width_func = (FPDFBitmap_GetWidth_t) dlsym(pdfium_handle, "FPDFBitmap_GetWidth"); + bitmap_get_height_func = (FPDFBitmap_GetHeight_t) dlsym(pdfium_handle, "FPDFBitmap_GetHeight"); + bitmap_get_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride"); + bitmap_get_buffer_func = (FPDFBitmap_GetBuffer_t) dlsym(pdfium_handle, "FPDFBitmap_GetBuffer"); + bitmap_destroy_func = (FPDFBitmap_Destroy_t) dlsym(pdfium_handle, "FPDFBitmap_Destroy"); + // --- Interaction, Links & Form Functions --- + do_annot_action_func = (FPDF_DoAnnotAction_t) dlsym(pdfium_handle, "FPDF_DoAnnotAction"); + get_widget_at_point_func = (FPDFAnnot_GetWidgetAtPoint_t) dlsym(pdfium_handle, "FPDFAnnot_GetWidgetAtPoint"); + get_link_action_func = (FPDFLink_GetAction_t) dlsym(pdfium_handle, "FPDFLink_GetAction"); + get_action_type_func = (FPDFAction_GetType_t) dlsym(pdfium_handle, "FPDFAction_GetType"); + get_link_annot_func = (FPDFLink_GetAnnot_t) dlsym(pdfium_handle, "FPDFLink_GetAnnot"); + get_form_field_name_func = (FPDFAnnot_GetFormFieldName_t) dlsym(pdfium_handle, "FPDFAnnot_GetFormFieldName"); + + // --- Validation & Logging --- bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func && get_annot_rect_func && get_annot_string_func; if (!success) { - LOGE("Failed to find one or more annotation functions in libpdfium.so"); + LOGE("Failed to find one or more core annotation functions in libpdfium.so"); } else { LOGI("Pdfium Annotation Bridge initialized successfully."); } - return success; + LOGD("PdfInteraction: Flags -> Get:%p Set:%p, FormField -> %p", + get_annot_flags_func, set_annot_flags_func, get_form_field_name_func); + + if (!get_link_action_func || !do_annot_action_func || !get_widget_at_point_func) { + LOGE("PdfInteraction: Missing one or more action/widget functions. LinkAction=%p, DoAction=%p, GetWidget=%p", + get_link_action_func, do_annot_action_func, get_widget_at_point_func); + } else { + LOGI("PdfInteraction: Initialization complete. Summary: LinkAction=%p, DoAction=%p, GetWidget=%p", + get_link_action_func, do_annot_action_func, get_widget_at_point_func); + } + + return get_annot_count_func != nullptr; } extern "C" JNIEXPORT jdouble JNICALL @@ -182,55 +226,35 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas return result; } -extern "C" JNIEXPORT jint JNICALL -Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { - if (!init_pdfium() || !get_annot_count_func) return 0; - return get_annot_count_func(reinterpret_cast(pagePtr)); -} - -extern "C" JNIEXPORT jint JNICALL -Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { - if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func) return 0; - void* annot = get_annot_func(reinterpret_cast(pagePtr), index); - return annot ? get_annot_subtype_func(annot) : 0; -} - -extern "C" JNIEXPORT jfloatArray JNICALL -Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { - if (!init_pdfium() || !get_annot_func || !get_annot_rect_func) return nullptr; - void* annot = get_annot_func(reinterpret_cast(pagePtr), index); - if (!annot) return nullptr; - - float rect[4]; - if (!get_annot_rect_func(annot, rect)) return nullptr; - - jfloatArray result = env->NewFloatArray(4); - env->SetFloatArrayRegion(result, 0, 4, rect); - return result; -} - extern "C" JNIEXPORT jstring JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) { - if (!init_pdfium() || !get_annot_func || !get_annot_string_func) return nullptr; - void* annot = get_annot_func(reinterpret_cast(pagePtr), index); + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr; + + void* page = reinterpret_cast(pagePtr); + void* annot = get_annot_func(page, index); if (!annot) return nullptr; const char* nativeKey = env->GetStringUTFChars(key, nullptr); - if (strcmp(nativeKey, "IRT") == 0 && get_linked_annot_func && close_annot_func) { - void* parentAnnot = get_linked_annot_func(annot, "IRT"); - if (parentAnnot) { - unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0); - jstring result = nullptr; - if (len > 2) { - std::vector buffer(len / 2); - get_annot_string_func(parentAnnot, "NM", buffer.data(), len); - result = env->NewString(reinterpret_cast(buffer.data()), (jsize)(buffer.size() - 1)); + if (strcmp(nativeKey, "IRT") == 0) { + if (get_linked_annot_func && close_annot_func) { + void* parentAnnot = get_linked_annot_func(annot, "IRT"); + if (parentAnnot) { + unsigned long len = get_annot_string_func(parentAnnot, "NM", nullptr, 0); + jstring result = nullptr; + if (len > 2) { + std::vector buffer(len / 2); + get_annot_string_func(parentAnnot, "NM", buffer.data(), len); + result = env->NewString(reinterpret_cast(buffer.data()), (jsize)(buffer.size() - 1)); + } + close_annot_func(parentAnnot); + env->ReleaseStringUTFChars(key, nativeKey); + return result; } - close_annot_func(parentAnnot); - env->ReleaseStringUTFChars(key, nativeKey); - return result; } + env->ReleaseStringUTFChars(key, nativeKey); + return nullptr; } unsigned long len = get_annot_string_func(annot, nativeKey, nullptr, 0); @@ -326,4 +350,145 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl env->SetIntArrayRegion(dimens, 0, 2, dims); return result; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) { + init_pdfium(); + // Return true if we have ANY way to handle actions + return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { + if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1; + + void* page = reinterpret_cast(pagePtr); + int count = get_annot_count_func(page); + + for (int i = 0; i < count; i++) { + void* annot = get_annot_func(page, i); + float r[4]; // L, B, R, T + if (get_annot_rect_func(annot, r)) { + // FIX: Use min/max to handle inverted PDF rectangles + float minX = fmin(r[0], r[2]); + float maxX = fmax(r[0], r[2]); + float minY = fmin(r[1], r[3]); + float maxY = fmax(r[1], r[3]); + + if (x >= minX && x <= maxX && y >= minY && y <= maxY) { + LOGI("PdfInteraction: MATCH FOUND! Index=%d, Type=%d", i, get_annot_subtype_func(annot)); + return get_annot_subtype_func(annot); + } + } + } + return -1; +} + +extern "C" JNIEXPORT jfloatArray JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { + if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; + + void* page = reinterpret_cast(pagePtr); + int count = get_annot_count_func(page); + for (int i = 0; i < count; i++) { + void* annot = get_annot_func(page, i); + float rect[4]; + if (get_annot_rect_func(annot, rect)) { + if (x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]) { + jfloatArray result = env->NewFloatArray(4); + env->SetFloatArrayRegion(result, 0, 4, rect); + return result; + } + } + } + return nullptr; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0; + return get_annot_count_func(reinterpret_cast(pagePtr)); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { + if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0; + void* annot = get_annot_func(reinterpret_cast(pagePtr), index); + return annot ? get_annot_subtype_func(annot) : 0; +} + +extern "C" JNIEXPORT jfloatArray JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { + if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; + void* annot = get_annot_func(reinterpret_cast(pagePtr), index); + if (!annot) return nullptr; + + float rect[4]; + if (!get_annot_rect_func(annot, rect)) return nullptr; + + jfloatArray result = env->NewFloatArray(4); + env->SetFloatArrayRegion(result, 0, 4, rect); + return result; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || pagePtr == 0) return JNI_FALSE; + + void* page = reinterpret_cast(pagePtr); + int count = get_annot_count_func(page); + void* hitAnnot = nullptr; + + // 1. Find which annotation was clicked + for (int i = 0; i < count; i++) { + void* annot = get_annot_func(page, i); + if (!annot) continue; + + float r[4]; + if (get_annot_rect_func(annot, r)) { + float minX = fminf(r[0], r[2]); + float maxX = fmaxf(r[0], r[2]); + float minY = fminf(r[1], r[3]); + float maxY = fmaxf(r[1], r[3]); + + if (x >= minX && x <= maxX && y >= minY && y <= maxY) { + hitAnnot = annot; + break; + } + } + } + + if (hitAnnot) { + int subtype = get_annot_subtype_func(hitAnnot); + + if (subtype == 19 || subtype == 20) { + LOGI("PdfInteraction: Button clicked (Subtype %d). Performing Blanket Reveal.", subtype); + bool anyChanged = false; + + for (int j = 0; j < count; j++) { + void* target = get_annot_func(page, j); + if (!target || !get_annot_flags_func || !set_annot_flags_func) continue; + + int flags = get_annot_flags_func(target); + + // We check for: Invisible (1), Hidden (2), or NoView (32) + if (flags & (1 | 2 | 32)) { + LOGD("PdfInteraction: Unhiding element at index %d (Flags were 0x%X)", j, flags); + + // Clear bits 1, 2, and 6 (1 + 2 + 32 = 35) + set_annot_flags_func(target, flags & ~35); + anyChanged = true; + } + } + + if (anyChanged) { + return JNI_TRUE; + } + } + } + + return JNI_FALSE; } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index b621ed1..c849b6b 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -20,8 +20,6 @@ // LibraryScreen.kt package com.aryan.reader -import android.content.Context -import android.provider.DocumentsContract import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -64,7 +62,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -99,8 +96,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import coil.request.ImageRequest @@ -146,7 +141,7 @@ fun LibraryScreen( val scope = rememberCoroutineScope() - var isSearchActive by remember { mutableStateOf(false) } + val isSearchActive = uiState.isSearchActive val searchQuery = uiState.searchQuery val pickFolderLauncher = rememberLauncherForActivityResult( @@ -222,8 +217,7 @@ fun LibraryScreen( } BackHandler(enabled = isSearchActive) { - isSearchActive = false - viewModel.onSearchQueryChange("") + viewModel.setSearchActive(false) } Box(modifier = Modifier.fillMaxSize()) { @@ -238,10 +232,7 @@ fun LibraryScreen( searchQuery = searchQuery, isSearchActive = isSearchActive, onSearchQueryChange = viewModel::onSearchQueryChange, - onSearchActiveChange = { active -> - isSearchActive = active - if (!active) viewModel.onSearchQueryChange("") - }, + onSearchActiveChange = viewModel::setSearchActive, onSortOrderChange = viewModel::setSortOrder, onClearSelection = { viewModel.clearContextualAction() }, onItemClick = viewModel::onRecentFileClicked, @@ -474,6 +465,19 @@ fun LibraryScreenContent( val tabTitles = listOf("All Books", "Shelves", "Folders") val searchFocusRequester = remember { FocusRequester() } + var textFieldValue by remember(isSearchActive) { + mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length))) + } + + LaunchedEffect(searchQuery) { + if (textFieldValue.text != searchQuery) { + textFieldValue = textFieldValue.copy( + text = searchQuery, + selection = TextRange(searchQuery.length) + ) + } + } + LaunchedEffect(isSearchActive) { if (isSearchActive) { searchFocusRequester.requestFocus() @@ -501,7 +505,7 @@ fun LibraryScreenContent( } else if (isSearchActive) { Surface( shadowElevation = 4.dp, - modifier = Modifier.fillMaxWidth().statusBarsPadding() + modifier = Modifier.fillMaxWidth() ) { Row( modifier = Modifier @@ -513,8 +517,11 @@ fun LibraryScreenContent( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search") } OutlinedTextField( - value = searchQuery, - onValueChange = onSearchQueryChange, + value = textFieldValue, + onValueChange = { + textFieldValue = it + onSearchQueryChange(it.text) + }, placeholder = { Text("Search title or author...") }, modifier = Modifier .weight(1f) @@ -866,9 +873,11 @@ private fun ShelfDetailScreen( }, floatingActionButton = { if (shelf.name != "Unshelved" && !isContextualModeActive) { - FloatingActionButton(onClick = onAddBooksClick) { - Icon(Icons.Default.Add, contentDescription = "Add books") - } + ExtendedFloatingActionButton( + onClick = onAddBooksClick, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("Add books") } + ) } } ) { paddingValues -> @@ -1362,25 +1371,6 @@ private fun DeleteShelvesConfirmationDialog( ) } -private fun getDisplayPathFromUri(context: Context, uriString: String): String { - val uri = uriString.toUri() - val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder" - - if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri).isNotEmpty()) { - val documentId = DocumentsContract.getTreeDocumentId(uri) - val split = documentId.split(":") - if (split.size > 1) { - val type = split[0] - val path = split[1] - return when (type) { - "primary" -> "Internal Storage ▸ $path" - else -> path - } - } - } - return fallbackName -} - @Composable private fun FolderSyncScreen( syncedFolders: List, diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 6f23d46..6a3c8e5 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -203,6 +203,7 @@ data class ReaderScreenState( val lastFolderScanTime: Long? = null, val hasUnreadFeedback: Boolean = false, val searchQuery: String = "", + val isSearchActive: Boolean = false, val showFolderMigrationDialog: Boolean = false, val isRefreshing: Boolean = false, val reflowProgress: Float? = null, @@ -369,7 +370,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) fun onSearchQueryChange(newQuery: String) { - _internalState.update { it.copy(searchQuery = newQuery) } + _internalState.update { + if (it.isSearchActive) { + it.copy(searchQuery = newQuery) + } else { + it + } + } + } + + fun setSearchActive(active: Boolean) { + _internalState.update { + if (active) { + it.copy(isSearchActive = true) + } else { + it.copy(isSearchActive = false, searchQuery = "") + } + } } private val _reviewRequestEvent = Channel(Channel.BUFFERED) 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 1cf6c30..18b9899 100644 --- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt +++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt @@ -24,8 +24,15 @@ object NativePdfiumBridge { @JvmStatic external fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean @JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? + @JvmStatic external fun performClick(pagePtr: Long, x: Double, y: Double): Boolean + + @JvmStatic external fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int + @JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? + @JvmStatic external fun checkActionSupport(): Boolean + const val ANNOT_TEXT = 1 // Sticky Note const val ANNOT_LINK = 2 // Link const val ANNOT_HIGHLIGHT = 8 // Highlight const val ANNOT_INK = 12 // Freehand drawing + const val ANNOT_WIDGET = 19 } \ No newline at end of file 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 8c1b463..9eefa8c 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt @@ -55,7 +55,7 @@ class PdfCoverGenerator(context: Context) { Timber.w("PDF has no pages, cannot generate cover: $pdfUri") return@withContext null } - doc.openPage(0).use { page -> + doc.openPage(0)?.use { page -> val originalWidth = page.getPageWidthPoint() val originalHeight = page.getPageHeightPoint() if (originalWidth <= 0 || originalHeight <= 0) { 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 057c6ab..a2bc33f 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -73,6 +73,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.translate @@ -438,7 +439,6 @@ internal fun PdfPageComposable( onHighlightDelete: (String) -> Unit = {}, onTts: (Int, Int) -> Unit = { _, _ -> }, ) { - SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") } val pdfDocumentItem = pdfDocument.item var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) } var currentRenderedPageId by remember { mutableStateOf(null) } @@ -677,7 +677,7 @@ internal fun PdfPageComposable( } withContext(Dispatchers.IO) { try { - pdfDocumentItem.openPage(pdfPageIndex).use { page -> + pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> val mapped = userHighlights.map { highlight -> val screenRects = highlight.bounds.mapNotNull { pdfRectF -> page.mapRectToDevice( @@ -750,7 +750,7 @@ internal fun PdfPageComposable( try { withContext(Dispatchers.IO) { tempPage = pdfDocumentItem.openPage(pdfPageIndex) - tempPage.openTextPage().use { textPage -> + tempPage?.openTextPage()?.use { textPage -> val charCount = textPage.textPageCountChars() if (charCount > 0) { val pdfRectsF = @@ -760,14 +760,14 @@ internal fun PdfPageComposable( if (pdfRectsF.isNotEmpty()) { val mappedScreenRects = pdfRectsF.mapNotNull { pdfRectF -> - tempPage.mapRectToDevice( + tempPage?.mapRectToDevice( startX = 0, startY = 0, sizeX = actualBitmapWidthPx, sizeY = actualBitmapHeightPx, rotate = currentPageRotation, coords = pdfRectF - ).takeIf { it.width() > 0 && it.height() > 0 } + )?.takeIf { it.width() > 0 && it.height() > 0 } } if (mappedScreenRects.isNotEmpty()) { rects = mappedScreenRects @@ -841,7 +841,7 @@ internal fun PdfPageComposable( val annotLink = 2 try { - pdfDocumentItem.openPage(pdfPageIndex).use { pageWrapper -> + pdfDocumentItem.openPage(pdfPageIndex)?.use { pageWrapper -> // 1. Extract Links (Method 1: Annotations) try { @@ -876,7 +876,7 @@ internal fun PdfPageComposable( // 2. Extract Links (Method 2: Text) try { pageWrapper.openTextPage().use { textPage -> - textPage.loadWebLink().use { webLinks -> + textPage.loadWebLink()?.use { webLinks -> val webLinkCount = webLinks.countWebLinks() for (linkIndex in 0 until webLinkCount) { val rawUrl = webLinks.getURL(linkIndex, 2048) @@ -907,87 +907,86 @@ internal fun PdfPageComposable( // 3. Extract Embedded Annotations try { - val unlockedPage = pageWrapper.page - val pagePtr = try { - val field = unlockedPage.javaClass.getDeclaredField("pagePtr") - field.isAccessible = true - field.get(unlockedPage) as Long - } catch (e: Exception) { - val field = unlockedPage.javaClass.getDeclaredField("mNativePage") - field.isAccessible = true - field.get(unlockedPage) as Long - } + val pagePtr = getNativePointer(pageWrapper) - val count = NativePdfiumBridge.getAnnotCount(pagePtr) - Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") - if (count > 0) { - val allAnnots = (0 until count).mapNotNull { i -> - val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i) - if (subtype == annotLink) return@mapNotNull null // skip links here + if (pagePtr != 0L) { + val count = NativePdfiumBridge.getAnnotCount(pagePtr) + Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") + if (count > 0) { + val count = NativePdfiumBridge.getAnnotCount(pagePtr) + Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count") + if (count > 0) { + val allAnnots = (0 until count).mapNotNull { i -> + val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i) + if (subtype == annotLink) return@mapNotNull null // skip links here - val contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents") - val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM") - val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT") - val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T") + val contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents") + val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM") + val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT") + val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T") - val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i) - val pdfRectF = if (pdfRectArray != null) { - android.graphics.RectF(pdfRectArray[0], pdfRectArray[3], pdfRectArray[2], pdfRectArray[1]) - } else android.graphics.RectF() + val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i) + val pdfRectF = if (pdfRectArray != null) { + android.graphics.RectF(pdfRectArray[0], pdfRectArray[3], pdfRectArray[2], pdfRectArray[1]) + } else android.graphics.RectF() - Timber.tag("PdfCommentDebug").v("Extracted Annot[$i]: Name=$name, IRT=$irt, Subtype=$subtype, Text=${contents?.take(10)}...") + Timber.tag("PdfCommentDebug").v("Extracted Annot[$i]: Name=$name, IRT=$irt, Subtype=$subtype, Text=${contents?.take(10)}...") - EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt) - } + EmbeddedAnnotation(i, subtype, pdfRectF, contents, author, name, irt) + } - val annotMap = allAnnots.associateBy { it.name } - val orphans = mutableListOf() + 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) + 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 + } } } - - Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}") - - val groupedRoots = mutableListOf>() - orphans.forEach { annot -> - val match = groupedRoots.find { group -> - val root = group.first() - val inflatedRoot = android.graphics.RectF(root.rect).apply { inset(-10f, -10f) } - android.graphics.RectF.intersects(inflatedRoot, annot.rect) - } - if (match != null) { - Timber.tag("PdfCommentDebug").w("Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!") - match.add(annot) - } else { - groupedRoots.add(mutableListOf(annot)) - } - } - - val rootsWithReplies = groupedRoots.map { group -> - val root = group.first() - if (group.size > 1) { - root.replies.addAll(group.drop(1)) - } - root - } - - finalDisplayList = rootsWithReplies.filter { - !it.contents.isNullOrBlank() || it.replies.any { r -> !r.contents.isNullOrBlank() } - } - - mappedAnnots = finalDisplayList.map { annot -> - val screenRect = pageWrapper.mapRectToDevice( - 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, - currentPageRotation, annot.rect - ) - annot to screenRect - } + } else { + Timber.tag("PdfCommentDebug").w("Page $pageIndex: Failed to resolve native page pointer.") } } catch (e: Exception) { Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations") @@ -1007,141 +1006,6 @@ internal fun PdfPageComposable( } } - LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) { - if (!isPdfPage) { - if (pageLinks.isNotEmpty()) pageLinks = emptyList() - return@LaunchedEffect - } - - if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) { - if (pageLinks.isNotEmpty()) pageLinks = emptyList() - return@LaunchedEffect - } - Timber.d( - "LaunchedEffect: Starting link fetch for page $pageIndex. Bitmap size: ${actualBitmapWidthPx}x${actualBitmapHeightPx}" - ) - - withContext(Dispatchers.IO) { - val allLinks = mutableListOf() - try { - pdfDocumentItem.openPage(pdfPageIndex).use { page -> - try { - val annotationLinks = page.getPageLinks() - Timber.d("Method 1 (getPageLinks) returned ${annotationLinks.size} links.") - if (annotationLinks.isNotEmpty()) { - val mappedAnnotationLinks = annotationLinks.mapNotNull { link -> - val uri = link.uri - val destPageIdx = link.destPageIdx - val bounds = link.bounds - - if (uri != null || (destPageIdx != null && destPageIdx >= 0)) { - val deviceRect = page.mapRectToDevice( - startX = 0, - startY = 0, - sizeX = actualBitmapWidthPx, - sizeY = actualBitmapHeightPx, - rotate = currentPageRotation, - coords = bounds - ) - - Timber.d( - "[Method 1] Link '$uri' | PDF Bounds: $bounds | Mapped Device Rect: $deviceRect" - ) - - if (deviceRect.width() > 0 && deviceRect.height() > 0) { - val tapRect = Rect( - deviceRect.left, - deviceRect.top - linkVerticalPaddingPx, - deviceRect.right, - deviceRect.bottom + linkVerticalPaddingPx - ) - Timber.d( - "[Method 1] Padded Tap Rect: $tapRect (Padding: $linkVerticalPaddingPx)" - ) - PageLink( - highlightBounds = deviceRect, - tapBounds = tapRect, - url = uri, - destPageIdx = destPageIdx, - source = LinkSource.ANNOTATION - ) - } else null - } else null - } - allLinks.addAll(mappedAnnotationLinks) - } - } catch (e: Exception) { - Timber.e(e, "Error fetching annotation links") - } - - // --- METHOD 2: Get links detected within the text content - // --- - try { - page.openTextPage().use { textPage -> - textPage.loadWebLink().use { webLinks -> - val webLinkCount = webLinks.countWebLinks() - Timber.d("Method 2 (loadWebLink) returned $webLinkCount links.") - for (linkIndex in 0 until webLinkCount) { - val rawUrl = webLinks.getURL(linkIndex, 2048) - val url = rawUrl?.substringBefore('\u0000') - - if (url.isNullOrBlank()) continue - - val rectCount = webLinks.countRects(linkIndex) - for (rectIndex in 0 until rectCount) { - val pdfRect = webLinks.getRect(linkIndex, rectIndex) - val deviceRect = page.mapRectToDevice( - startX = 0, - startY = 0, - sizeX = actualBitmapWidthPx, - sizeY = actualBitmapHeightPx, - rotate = currentPageRotation, - coords = pdfRect - ) - Timber.d( - "[Method 2] Link '$url' | PDF Rect: $pdfRect | Mapped Device Rect: $deviceRect" - ) - - if (deviceRect.width() > 0 && deviceRect.height() > 0) { - val tapRect = Rect( - deviceRect.left, - deviceRect.top - linkVerticalPaddingPx, - deviceRect.right, - deviceRect.bottom + linkVerticalPaddingPx - ) - Timber.d( - "[Method 2] Padded Tap Rect: $tapRect (Padding: $linkVerticalPaddingPx)" - ) - allLinks.add( - PageLink( - highlightBounds = deviceRect, - tapBounds = tapRect, - url = url, - destPageIdx = null, - source = LinkSource.TEXT_CONTENT - ) - ) - } - } - } - } - } - } catch (e: Exception) { - Timber.e(e, "Error fetching web links from text page") - } - - pageLinks = allLinks - Timber.d( - "Finished fetching. Stored a total of ${allLinks.size} links in state." - ) - } - } catch (e: Exception) { - Timber.e(e, "Failed to open page $pdfPageIndex for link fetching") - pageLinks = emptyList() - } - } - } - LaunchedEffect(isVisible, pageIndex) { if (!isVisible && !isVerticalScroll) { if (bitmapState != null) { @@ -1153,6 +1017,21 @@ internal fun PdfPageComposable( } } + SideEffect { + if (effectiveScale > 1f) { + Timber.tag("PdfZoomDiagnostics").v( + """ + Page $pageIndex Stats: + - Internal Scale: $scale | Effective Scale: $effectiveScale + - Offset: $offset + - Bitmap Dims: ${actualBitmapWidthPx}x${actualBitmapHeightPx} + - Canvas Dims: ${canvasWidthPx.floatValue}x${canvasHeightPx.floatValue} + - Centering: X=$centeringOffsetX, Y=$centeringOffsetY + """.trimIndent() + ) + } + } + LaunchedEffect( effectiveScale, effectiveOffset, @@ -1309,7 +1188,7 @@ internal fun PdfPageComposable( val tileRenderX = (col * tileSizePx * effectiveScale).toInt() val tileRenderY = (row * tileSizePx * effectiveScale).toInt() - page.renderPageBitmap( + page?.renderPageBitmap( bitmap = tileBitmap, startX = -tileRenderX, startY = -tileRenderY, @@ -1384,7 +1263,7 @@ internal fun PdfPageComposable( withContext(Dispatchers.IO) { try { - pdfDocumentItem.openPage(pdfPageIndex).use { page -> + pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> page.openTextPage().use { textPage -> val charCount = textPage.textPageCountChars() if (charCount > 0) { @@ -1559,7 +1438,15 @@ internal fun PdfPageComposable( textPageToUse = providedTextPage } else { localPage = doc.openPage(pageIdx) - localTextPage = localPage.openTextPage() + localTextPage = localPage?.openTextPage() + if (localPage == null || localTextPage == null) { + withContext(Dispatchers.Main) { + selectedWordScreenRects = emptyList() + startHandleContentPosition.value = null + endHandleContentPosition.value = null + } + return@withContext + } pageToUse = localPage textPageToUse = localTextPage } @@ -1788,7 +1675,7 @@ internal fun PdfPageComposable( pageForDrag = pdfDocumentItem.openPage( pdfPageIndex ) - textPageForDrag = pageForDrag.openTextPage() + textPageForDrag = pageForDrag?.openTextPage() } } @@ -1886,13 +1773,15 @@ internal fun PdfPageComposable( } } else { // PDFIUM Logic - if (pageForDrag != null && textPageForDrag != null) { + val pDrag = pageForDrag + val tDrag = textPageForDrag + if (pDrag != null && tDrag != null) { val touchInContentCoords = screenToContentCoordinates( dragPosition ) - val pdfCoords = pageForDrag.mapDeviceCoordsToPage( + val pdfCoords = pDrag.mapDeviceCoordsToPage( startX = 0, startY = 0, sizeX = actualBitmapWidthPx, @@ -1904,7 +1793,7 @@ internal fun PdfPageComposable( val charTolerance = 10.0 var charIndexForUpdate = - textPageForDrag.textPageGetCharIndexAtPos( + tDrag.textPageGetCharIndexAtPos( x = pdfCoords.x.toDouble(), y = pdfCoords.y.toDouble(), xTolerance = charTolerance, @@ -1913,7 +1802,7 @@ internal fun PdfPageComposable( if (charIndexForUpdate == -1 && activeDraggingHandle != null) { val pageWidthPdfUnits = - pageForDrag.getPageWidthPoint() + pDrag.getPageWidthPoint() val wideSearchXTolerance = pageWidthPdfUnits.toDouble() var ySearchCoordinate = pdfCoords.y.toDouble() @@ -1921,7 +1810,7 @@ internal fun PdfPageComposable( val currentRange = selectionCharRange.value if (currentRange != null) { val pageTotalChars = - textPageForDrag.textPageCountChars() + tDrag.textPageCountChars() if (pageTotalChars > 0) { val anchorCharIndex = if (activeDraggingHandle == Handle.START) { @@ -1933,7 +1822,7 @@ internal fun PdfPageComposable( } if (anchorCharIndex in 0.. 0) { charIndexForUpdate = - textPageForDrag.textPageGetCharIndexAtPos( + tDrag.textPageGetCharIndexAtPos( x = pdfCoords.x.toDouble(), y = ySearchCoordinate, xTolerance = wideSearchXTolerance, @@ -1958,7 +1847,7 @@ internal fun PdfPageComposable( if (charIndexForUpdate != -1) { val pageCharCount = - textPageForDrag.textPageCountChars() + tDrag.textPageCountChars() val currentRange = selectionCharRange.value if (currentRange != null) { @@ -2120,8 +2009,8 @@ internal fun PdfPageComposable( try { val text = withContext(Dispatchers.IO) { pageForMenu = pdfDocumentItem.openPage(pdfPageIndex) - textPageForMenu = pageForMenu.openTextPage() - textPageForMenu.textPageGetText( + textPageForMenu = pageForMenu?.openTextPage() + textPageForMenu?.textPageGetText( currentRange.first, currentRange.second - currentRange.first ) @@ -2244,68 +2133,72 @@ internal fun PdfPageComposable( var pdfiumSelectionSuccessful = false withContext(Dispatchers.IO) { tempPage = pdfDocumentItem.openPage(pdfPageIndex) - tempTextPage = tempPage.openTextPage() + tempTextPage = tempPage?.openTextPage() - val pdfCoords = tempPage.mapDeviceCoordsToPage( - startX = 0, - startY = 0, - sizeX = actualBitmapWidthPx, - sizeY = actualBitmapHeightPx, - rotate = currentPageRotation, - deviceX = touchInContentCoords.x.toInt(), - deviceY = touchInContentCoords.y.toInt() - ) - val charTolerance = 5.0 - val charIndex = tempTextPage.textPageGetCharIndexAtPos( - x = pdfCoords.x.toDouble(), - y = pdfCoords.y.toDouble(), - xTolerance = charTolerance, - yTolerance = charTolerance - ) - - if (charIndex != -1) { - val pageCharCount = tempTextPage.textPageCountChars() - val wordBoundaries = findWordBoundaries( - tempTextPage, charIndex, pageCharCount + val tPage = tempPage + val tTextPage = tempTextPage + if (tPage != null && tTextPage != null) { + val pdfCoords = tPage.mapDeviceCoordsToPage( + startX = 0, + startY = 0, + sizeX = actualBitmapWidthPx, + sizeY = actualBitmapHeightPx, + rotate = currentPageRotation, + deviceX = touchInContentCoords.x.toInt(), + deviceY = touchInContentCoords.y.toInt() + ) + val charTolerance = 5.0 + val charIndex = tTextPage.textPageGetCharIndexAtPos( + x = pdfCoords.x.toDouble(), + y = pdfCoords.y.toDouble(), + xTolerance = charTolerance, + yTolerance = charTolerance ) - if (wordBoundaries != null) { - withContext(Dispatchers.Main) { - selectionMethodUsed = PdfSelectionMethod.PDFIUM - selectionCharRange.value = wordBoundaries - } - updateSelectionVisuals( - pdfDocumentItem, - pdfPageIndex, - wordBoundaries, - actualBitmapWidthPx, - actualBitmapHeightPx, - currentPageRotation, - providedPage = tempPage, - providedTextPage = tempTextPage + if (charIndex != -1) { + val pageCharCount = tTextPage.textPageCountChars() + val wordBoundaries = findWordBoundaries( + tTextPage, charIndex, pageCharCount ) - withContext(Dispatchers.Main) { - if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { - val currentRange = selectionCharRange.value!! - val text = withContext(Dispatchers.IO) { - tempTextPage.textPageGetText( - currentRange.first, - currentRange.second - currentRange.first - ) - } - if (!text.isNullOrBlank()) { - val combinedRect = Rect(selectedWordScreenRects.first()) - selectedWordScreenRects.forEach { combinedRect.union(it) } - customMenuState = CustomPdfMenuState( - selectedText = text, - anchorRect = combinedRect, - charRange = currentRange - ) - pdfiumSelectionSuccessful = true - Timber.d( - "Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}" - ) + if (wordBoundaries != null) { + withContext(Dispatchers.Main) { + selectionMethodUsed = PdfSelectionMethod.PDFIUM + selectionCharRange.value = wordBoundaries + } + updateSelectionVisuals( + pdfDocumentItem, + pdfPageIndex, + wordBoundaries, + actualBitmapWidthPx, + actualBitmapHeightPx, + currentPageRotation, + providedPage = tPage, + providedTextPage = tTextPage + ) + withContext(Dispatchers.Main) { + if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { + val currentRange = selectionCharRange.value!! + val text = withContext(Dispatchers.IO) { + tTextPage.textPageGetText( + currentRange.first, + currentRange.second - currentRange.first + ) + } + if (!text.isNullOrBlank()) { + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } + + customMenuState = CustomPdfMenuState( + selectedText = text, + anchorRect = combinedRect, + charRange = currentRange + ) + pdfiumSelectionSuccessful = true + Timber.d( + "Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}" + ) + } } } } @@ -2511,6 +2404,40 @@ internal fun PdfPageComposable( detectTapGestures(onTap = { tapOffset -> val tapInContentCoords = screenToContentCoordinates(tapOffset) + + coroutineScope.launch { + val wasHandled = withContext(Dispatchers.IO) { + try { + pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> + val pagePtr = getNativePointer(page) + + if (pagePtr == 0L) { + Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex") + return@withContext false + } + + val pdfCoords = page.mapDeviceCoordsToPage( + 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, + currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt() + ) + + NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()) + } ?: false + } catch (e: Exception) { + Timber.tag("PdfInteraction").e(e, "Interaction error") + false + } + } + + if (wasHandled) { + Timber.tag("PdfInteraction").i("Action detected. Refreshing page.") + tiles = emptyList() + bitmapState = null + isLoadingPage = true + currentRenderedPageId = "ACTION_${System.currentTimeMillis()}" + } + } + val tapXInBitmap = tapInContentCoords.x val tapYInBitmap = tapInContentCoords.y @@ -2636,6 +2563,7 @@ internal fun PdfPageComposable( coroutineScope.launch { val startScale = scale val targetScale = if (startScale > 1.1f) 1f else 2.5f + Timber.tag("PdfZoomDebug").i("DoubleTap Triggered: CurrentScale=$startScale, Target=$targetScale") val startOffset = offset val targetOffsetUnbounded = if (targetScale <= 1.1f) { Offset.Zero @@ -2734,6 +2662,7 @@ internal fun PdfPageComposable( accumulatedPan += panChange if (accumulatedPan.getDistance() > touchSlop) { mode = 1 + Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Single Pointer)") } } else if (pointerCount > 1) { accumulatedZoom *= zoomChange @@ -2744,8 +2673,10 @@ internal fun PdfPageComposable( if (zoomDiff > 0.05f) { mode = 2 + Timber.tag("PdfZoomDebug").d("Mode Change: ZOOM (Multi Pointer)") } else if (panDist > touchSlop) { mode = 1 + Timber.tag("PdfZoomDebug").d("Mode Change: PAN (Multi Pointer)") } } } @@ -2764,6 +2695,7 @@ internal fun PdfPageComposable( val newY = (offset.y + panChange.y).coerceIn( -maxOffsetY, maxOffsetY ) + Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)") offset = Offset(newX, newY) event.changes.forEach { @@ -2773,40 +2705,25 @@ internal fun PdfPageComposable( val oldScale = scale val newScale = (scale * zoomChange).coerceIn(1f, 4f) - val previousCentroid = event.calculateCentroid( - useCurrent = false - ) + val previousCentroid = event.calculateCentroid(useCurrent = false) if (previousCentroid != Offset.Unspecified) { val ratio = newScale / oldScale - val screenCenter = Offset( - size.width / 2f, size.height / 2f - ) - val newOffset = - offset * ratio + (previousCentroid - screenCenter) * (1 - ratio) + panChange + val screenCenter = Offset(size.width / 2f, size.height / 2f) + val newOffset = offset * ratio + (previousCentroid - screenCenter) * (1 - ratio) + panChange val contentWidth = actualBitmapWidthPx * newScale val contentHeight = actualBitmapHeightPx * newScale - val maxOffsetX = - (contentWidth - size.width).coerceAtLeast(0f) / 2f - val maxOffsetY = - (contentHeight - size.height).coerceAtLeast(0f) / 2f + val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f + val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f + + Timber.tag("PdfZoomDebug").v("Scaling: RawZoom=$zoomChange, Scale=$oldScale->$newScale, Offset=$offset->$newOffset (Clamped Max: $maxOffsetX, $maxOffsetY)") offset = Offset( - x = newOffset.x.coerceIn( - -maxOffsetX, maxOffsetX - ), y = newOffset.y.coerceIn( - -maxOffsetY, maxOffsetY - ) + x = newOffset.x.coerceIn(-maxOffsetX, maxOffsetX), + y = newOffset.y.coerceIn(-maxOffsetY, maxOffsetY) ) scale = newScale - - if (scale < 1.05f) { - scale = 1f - offset = Offset.Zero - onScaleChanged(scale) - } else { - onScaleChanged(scale) - } + onScaleChanged(scale) } event.changes.forEach { if (it.positionChanged()) it.consume() @@ -2881,7 +2798,17 @@ internal fun PdfPageComposable( } } while (!canceled && event.changes.any { it.pressed }) - if (mode == 1 && scale > 1f) { + 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) + } + } + } else if (mode == 1 && scale > 1f) { val velocity = velocityTracker.calculateVelocity() val contentWidth = actualBitmapWidthPx * scale val contentHeight = actualBitmapHeightPx * scale @@ -3052,6 +2979,13 @@ internal fun PdfPageComposable( } }, contentAlignment = Alignment.Center ) { + SideEffect { + if (effectiveScale > 1f) { + Timber.tag("PdfZoomDiagnostics").d( + "BoxWithConstraints Page $pageIndex: MaxW=$maxWidth, MaxH=$maxHeight" + ) + } + } val imeInsets = WindowInsets.ime val screenHeight = constraints.maxHeight.toFloat() @@ -3189,7 +3123,7 @@ internal fun PdfPageComposable( "Highlighting (Pdfium): page $pageIndex, index: ${ttsHighlightData.startIndex}, len: ${ttsHighlightData.length}" ) rects = withContext(Dispatchers.IO) { - pdfDocumentItem.openPage(pdfPageIndex).use { page -> + pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> page.openTextPage().use { textPage -> val pdfRectsF = textPage.textPageGetRectsForRanges( intArrayOf( @@ -3214,7 +3148,7 @@ internal fun PdfPageComposable( emptyList() } } - } + } ?: emptyList() } } @@ -3290,7 +3224,8 @@ internal fun PdfPageComposable( currentContainerMaxHeight, density, virtualPage, - isVisible + isVisible, + currentRenderedPageId ) { if (!isVisible && !isVerticalScroll) return@LaunchedEffect @@ -3381,7 +3316,7 @@ internal fun PdfPageComposable( ) return@withContext null } - val page = pdfDocumentItem.openPage(pdfPageIndex) + val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null val rotation = page.getPageRotation() val screenDpi = (density.density * 160).roundToInt() val originalWidthPdfUnits = page.getPageWidth(screenDpi) @@ -3730,12 +3665,14 @@ internal fun PdfPageComposable( try { val charCount = withContext(Dispatchers.IO) { page = pdfDocumentItem.openPage(pdfPageIndex) - textPage = page.openTextPage() - textPage.textPageCountChars() + textPage = page?.openTextPage() + textPage?.textPageCountChars() ?: 0 } if (charCount > 0) { selectionCharRange.value = Pair(0, charCount) + val tPage = page + val tTextPage = textPage updateSelectionVisuals( pdfDocumentItem, pdfPageIndex, @@ -3743,12 +3680,12 @@ internal fun PdfPageComposable( actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, - providedPage = page, - providedTextPage = textPage + providedPage = tPage, + providedTextPage = tTextPage ) if (selectedWordScreenRects.isNotEmpty()) { val fullText = withContext(Dispatchers.IO) { - textPage!!.textPageGetText(0, charCount) + tTextPage?.textPageGetText(0, charCount) } if (!fullText.isNullOrBlank()) { val combinedRect = Rect(selectedWordScreenRects.first()) @@ -3904,53 +3841,48 @@ private fun PdfBitmapLayer( colorFilter: ColorFilter? = null, isDarkMode: Boolean = false ) { - SideEffect { - Timber.tag("PdfDrawPerf") - .v("BITMAP LAYER: SideEffect (Scale: $effectiveScale, Tiles: ${tiles.size})") - } Canvas(modifier = Modifier .fillMaxSize() .graphicsLayer()) { - val drawStart = System.nanoTime() - Timber.tag("PdfDrawPerf").v("BITMAP LAYER: Canvas Draw Start (Tiles: ${tiles.size})") translate(left = centeringOffsetX, top = centeringOffsetY) { - if (bitmapState != null && !bitmapState.isRecycled) { - val dstW = if (targetWidth > 0) targetWidth else bitmapState.width - val dstH = if (targetHeight > 0) targetHeight else bitmapState.height - val srcSize = IntSize(bitmapState.width, bitmapState.height) - val dstSize = IntSize(dstW, dstH) + // THIS is the fix: Hard clip to the target bounds so edge tiles can't bleed out. + clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) { + if (bitmapState != null && !bitmapState.isRecycled) { + val dstW = if (targetWidth > 0) targetWidth else bitmapState.width + val dstH = if (targetHeight > 0) targetHeight else bitmapState.height + val srcSize = IntSize(bitmapState.width, bitmapState.height) + val dstSize = IntSize(dstW, dstH) - // 1. Draw Base Bitmap (with Dark Mode filter if active) - drawImage( - image = bitmapState.asImageBitmap(), - srcOffset = IntOffset.Zero, - srcSize = srcSize, - dstOffset = IntOffset.Zero, - dstSize = dstSize, - colorFilter = colorFilter - ) + // 1. Draw Base Bitmap + drawImage( + image = bitmapState.asImageBitmap(), + srcOffset = IntOffset.Zero, + srcSize = srcSize, + dstOffset = IntOffset.Zero, + dstSize = dstSize, + colorFilter = colorFilter + ) - // 3. Draw Tiles - if (effectiveScale > 1f) { - tiles.forEach { tile -> - if (!tile.bitmap.isRecycled) { - drawImage( - image = tile.bitmap.asImageBitmap(), - srcOffset = IntOffset.Zero, - srcSize = IntSize(tile.bitmap.width, tile.bitmap.height), - dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top), - dstSize = IntSize( - tile.renderRect.width(), tile.renderRect.height() - ), - colorFilter = colorFilter - ) + // 2. Draw High-Res Tiles + if (effectiveScale > 1f) { + tiles.forEach { tile -> + if (!tile.bitmap.isRecycled) { + drawImage( + image = tile.bitmap.asImageBitmap(), + srcOffset = IntOffset.Zero, + srcSize = IntSize(tile.bitmap.width, tile.bitmap.height), + dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top), + dstSize = IntSize( + tile.renderRect.width(), tile.renderRect.height() + ), + colorFilter = colorFilter + ) + } } } } } } - val drawTime = (System.nanoTime() - drawStart) / 1_000_000f - Timber.tag("PdfDrawPerf").v("BITMAP LAYER: Canvas draw finished in ${drawTime}ms") } } @@ -4337,6 +4269,7 @@ internal object PdfAnnotationRenderHelper { } } +@Suppress("SameParameterValue") @Composable private fun PdfAnnotationLayer( actualBitmapWidthPx: Int, @@ -4454,13 +4387,6 @@ private fun PdfAnnotationLayer( @Composable private fun PdfPageStaticLayer(data: PageStaticData) { - SideEffect { - Timber.tag("PdfDrawPerf").v( - "STATIC LAYER: Composition (Should not happen during draw). DataHash: ${data.hashCode()}" - ) - } - Timber.tag("PdfDrawPerf").v("STATIC LAYER: Recomposing") - PdfBitmapLayer( bitmapState = data.bitmap.item, tiles = data.tiles.item, @@ -4591,15 +4517,11 @@ private fun PdfPageRenderer( onHighlightDelete: (String) -> Unit, onTts: (Int, Int) -> Unit, ) { - SideEffect { - Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}") - } Box(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() .graphicsLayer { - Timber.tag("PdfPerf").v("GraphicsLayer Update: Scale=$scale, Offset=$offset") scaleX = scale scaleY = scale translationX = offset.x @@ -4607,9 +4529,7 @@ private fun PdfPageRenderer( }) { // Layer 1: The Heavy Bitmap - Box(modifier = Modifier - .fillMaxSize() - .graphicsLayer()) { + Box(modifier = Modifier.fillMaxSize().graphicsLayer()) { PdfPageStaticLayer(data = staticData) } @@ -4636,11 +4556,9 @@ private fun PdfPageRenderer( selectionHighlightColor = selectionData.selectionHighlightColor ) - // Layer 3: Annotations (Drawing) + // Layer 3: Annotations & Text if (staticData.targetWidth > 0 && staticData.targetHeight > 0) { - Box(modifier = Modifier - .fillMaxSize() - .graphicsLayer()) { + Box(modifier = Modifier.fillMaxSize().graphicsLayer()) { PdfAnnotationLayer( actualBitmapWidthPx = staticData.targetWidth, actualBitmapHeightPx = staticData.targetHeight, @@ -4653,19 +4571,6 @@ private fun PdfPageRenderer( } if (richTextController != null) { - val density = LocalDensity.current - val textMeasurer = androidx.compose.ui.text.rememberTextMeasurer() - val targetW = staticData.targetWidth.toFloat() - val targetH = staticData.targetHeight.toFloat() - - LaunchedEffect(targetW, targetH, density) { - if (targetW > 0 && targetH > 0) { - richTextController.updateLayoutConfig( - targetW, targetH, density, textMeasurer - ) - } - } - val isEditable = isEditMode && selectedTool == InkType.TEXT val hasContent = richTextController.pageLayouts.any { it.pageIndex == selectionData.pageIndex @@ -4686,7 +4591,6 @@ private fun PdfPageRenderer( } } - // Text Boxes textBoxes.forEach { box -> val isDraggingThisBox = (box.id == draggingBoxId) val boxAlpha = if (isDraggingThisBox) 0f else 1f @@ -4753,46 +4657,46 @@ private fun PdfPageRenderer( ) } } - - // Layer 4: Page Number Indicator - if (totalPages > 0) { - val pageNumColor = if (staticData.isDarkMode) { - Color.White - } else { - Color.Black - } - - Box(modifier = Modifier - .offset { - IntOffset( - x = staticData.centeringOffsetX.toInt(), - y = staticData.centeringOffsetY.toInt() - ) - } - .size(width = with(density) { - staticData.targetWidth.toDp() - }, height = with(density) { - staticData.targetHeight.toDp() - })) { - Text( - text = "${selectionData.pageIndex + 1}/$totalPages", - color = pageNumColor.copy(alpha = 0.5f), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 12.sp, fontWeight = FontWeight.Bold - ), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 12.dp, bottom = 12.dp) - ) - } - } } - // Capture size for coordinate conversions - Canvas(modifier = Modifier.fillMaxSize()) { - if (staticData.canvasWidth != size.width || staticData.canvasHeight != size.height) { - onCanvasSizeChanged(size.width, size.height) + // Layer 4: Page Number Indicator + if (totalPages > 0) { + val pageNumColor = if (staticData.isDarkMode) { + Color.White + } else { + Color.Black } + + Box(modifier = Modifier + .offset { + IntOffset( + x = staticData.centeringOffsetX.toInt(), + y = staticData.centeringOffsetY.toInt() + ) + } + .size(width = with(density) { + staticData.targetWidth.toDp() + }, height = with(density) { + staticData.targetHeight.toDp() + })) { + Text( + text = "${selectionData.pageIndex + 1}/$totalPages", + color = pageNumColor.copy(alpha = 0.5f), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 12.sp, fontWeight = FontWeight.Bold + ), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 12.dp, bottom = 12.dp) + ) + } + } + } + + // Capture size for coordinate conversions + Canvas(modifier = Modifier.fillMaxSize()) { + if (staticData.canvasWidth != size.width || staticData.canvasHeight != size.height) { + onCanvasSizeChanged(size.width, size.height) } } @@ -4932,7 +4836,6 @@ private fun PdfPageRenderer( ): IntOffset { val coords = layoutCoordinates ?: return IntOffset.Zero - // Map the bitmap-space anchor (the icon) to window-space val topLeftLocal = contentToScreenCoordinates(Offset( menuState.anchorRect.left.toFloat(), menuState.anchorRect.top.toFloat())) @@ -4944,14 +4847,12 @@ private fun PdfPageRenderer( val bottomRightWindow = coords.localToWindow(bottomRightLocal) val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2 - val gapPx = with(density) { 16.dp.toPx() } // Increased gap + val gapPx = with(density) { 16.dp.toPx() } - // Try placing ABOVE the icon first var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt() if (yInWindow < 0) { yInWindow = (bottomRightWindow.y + gapPx).toInt() - // Ensure it doesn't get pushed out of the bottom boundary either if (yInWindow + popupContentSize.height > windowSize.height) { yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt() } @@ -4975,11 +4876,9 @@ private fun PdfPageRenderer( onSearch = onSearch, onSelectAll = onSelectAll, onColorSelected = { color -> - Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}") if (menuState.isExistingHighlight && menuState.highlightId != null) { onHighlightUpdate(menuState.highlightId, color) } else { - Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}") onHighlightAdd( selectionData.pageIndex, menuState.charRange, menuState.selectedText, color @@ -5189,4 +5088,33 @@ fun PdfRichTextLayer( } } } +} + +private fun getNativePointer(obj: Any): Long { + val priorityFields = listOf("pagePtr", "mNativePage", "page") + + for (name in priorityFields) { + try { + val field = obj.javaClass.getDeclaredField(name) + field.isAccessible = true + val value = field.get(obj) + if (value is Long && value != 0L) return value + if (value != null && value !is Long) { + val nestedPtr = getNativePointer(value) + if (nestedPtr != 0L) return nestedPtr + } + } catch (_: Exception) {} + } + + try { + for (field in obj.javaClass.declaredFields) { + if (field.type == Long::class.java || field.type == Long::class.javaPrimitiveType) { + field.isAccessible = true + val value = field.get(obj) as Long + if (value > 0xFFFFFFFFL) return value + } + } + } catch (_: Exception) {} + + return 0L } \ No newline at end of file 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 2bcb46d..353a678 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt @@ -131,13 +131,12 @@ object PdfToHtmlGenerator { headerFooterStrings: Set ): String { return try { - doc.openPage(pageIdx).use { page -> - if (page == null) return buildEmptyPageSection(pageNumber) + doc.openPage(pageIdx)?.use { page -> page.openTextPage().use { textPage -> val charCount = textPage.textPageCountChars() - val pagePtr = page.page.pagePtr - val textPagePtr = textPage.page.pagePtr + val pagePtr = getNativePointer(page) + val textPagePtr = getNativePointer(textPage) val imageElements = mutableListOf() val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr) @@ -178,7 +177,7 @@ object PdfToHtmlGenerator { val flags: IntArray? val charBoxes: FloatArray? - synchronized(PdfiumCore.lock) { + synchronized(NativePdfiumBridge::class.java) { sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount) weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount) flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount) @@ -293,8 +292,8 @@ object PdfToHtmlGenerator { } buildPageHtml(pageNumber, finalElements, headerFooterStrings) - } - } + } ?: buildEmptyPageSection(pageNumber) + } ?: buildEmptyPageSection(pageNumber) } catch (e: Exception) { Timber.tag(TAG).w(e, "Error extracting page $pageIdx") buildEmptyPageSection(pageNumber) @@ -483,7 +482,7 @@ object PdfToHtmlGenerator { for (pageIdx in samplePages) { try { - doc.openPage(pageIdx).use { page -> + doc.openPage(pageIdx)?.use { page -> page.openTextPage().use { textPage -> val charCount = textPage.textPageCountChars() if (charCount <= 0) return@use @@ -511,4 +510,21 @@ object PdfToHtmlGenerator { .replace(">", ">") .replace("\"", """) .replace("'", "'") + + private fun getNativePointer(obj: Any): Long { + val priorityFields = listOf("pagePtr", "mNativePage", "page") + for (name in priorityFields) { + try { + val field = obj.javaClass.getDeclaredField(name) + field.isAccessible = true + val value = field.get(obj) + if (value is Long && value != 0L) return value + if (value != null && value !is Long) { + val nestedPtr = getNativePointer(value) + if (nestedPtr != 0L) return nestedPtr + } + } catch (_: Exception) {} + } + return 0L + } } \ No newline at end of file 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 d372e05..50db84d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -358,8 +358,12 @@ internal fun PdfVerticalReader( val constrainedX = targetPanX.coerceIn(minPanX, maxPanX) val constrainedY = targetPanY.coerceIn(minPanY, headerHeightPx) - // DEDICATED LOG - Timber.tag("PdfZoomDebug").v("Clamp Internal: Zoom=$constrainedZoom, PanBoundsX=[$minPanX, $maxPanX], PanBoundsY=[$minPanY, $headerHeightPx]") + if (constrainedZoom > 1.01f) { + Timber.tag("PdfZoomIssue").v( + "Clamp: Zoom=$constrainedZoom, targetY=$targetPanY, finalY=$constrainedY, " + + "boundsY=[$minPanY, $headerHeightPx], zoomedHeight=$zoomedDocHeight" + ) + } return Triple(constrainedZoom, constrainedX, constrainedY) } @@ -370,10 +374,14 @@ internal fun PdfVerticalReader( return clampValues(targetZoom, targetPanX, targetPanY) } + var isFlinging by remember { mutableStateOf(false) } + var isFastFlinging by remember { mutableStateOf(false) } + var isInteracting by remember { mutableStateOf(false) } + LaunchedEffect( - totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value + totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging ) { - if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning) { + if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning || isInteracting || isFlinging) { return@LaunchedEffect } @@ -446,10 +454,6 @@ internal fun PdfVerticalReader( } var selectionClearTrigger by remember { mutableLongStateOf(0L) } - - var isFlinging by remember { mutableStateOf(false) } - var isFastFlinging by remember { mutableStateOf(false) } - var draggingBoxId by remember { mutableStateOf(null) } var draggingBoxOffset by remember { mutableStateOf(Offset.Zero) } var draggingBoxSize by remember { mutableStateOf(Size.Zero) } @@ -549,7 +553,6 @@ internal fun PdfVerticalReader( } var highResScale by remember { mutableFloatStateOf(1f) } - var isInteracting by remember { mutableStateOf(false) } LaunchedEffect(isInteracting) { if (isInteracting && isAutoScrollPlaying) { @@ -617,8 +620,12 @@ internal fun PdfVerticalReader( imeBottom, isEditMode, selectedTool, - zoomAnimatable.value + zoomAnimatable.value, + isInteracting, + isFlinging ) { + if (isInteracting || isFlinging) return@LaunchedEffect + val currentZoom = zoomAnimatable.value val zoomedDocHeight = totalDocHeight * currentZoom @@ -897,6 +904,7 @@ internal fun PdfVerticalReader( ) val down = awaitFirstDown(requireUnconsumed = false) + isInteracting = true Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}") @@ -1015,31 +1023,35 @@ internal fun PdfVerticalReader( (!isEditMode || selectedTool == InkType.TEXT || isMultiTouch || (isStylusOnlyMode && isTouchInput)) if (shouldScroll) { - if (!isInteracting) { - Timber.tag("PdfTouchDebug").i( - "VerticalReader: Taking control." - ) - isInteracting = true - } - panLocked = true if (zoomChange != 1f || panChange != Offset.Zero) { var effectiveZoomChange = zoomChange - - if (gestureDisambiguationMode == 1) { - effectiveZoomChange = 1f - } + if (gestureDisambiguationMode == 1) effectiveZoomChange = 1f val oldZoom = accumulatedZoom val rawTargetZoom = oldZoom * effectiveZoomChange val constrainedZoom = rawTargetZoom.coerceIn(1f, 5f) - val actualZoomFactor = if (oldZoom == 0f) 1f - else constrainedZoom / oldZoom - val rawNewPanX = - (accumulatedPanX + panChange.x) - (centroid.x - accumulatedPanX) * (actualZoomFactor - 1) - val rawNewPanY = - (accumulatedPanY + panChange.y) - (centroid.y - accumulatedPanY) * (actualZoomFactor - 1) + + val prevCentroid = centroid - panChange + val contentPivotX = (prevCentroid.x - accumulatedPanX) / oldZoom + val contentPivotY = (prevCentroid.y - accumulatedPanY) / oldZoom + + Timber.tag("PdfZoomIssue").v( + "PivotCalc: ScreenCentroidY=${centroid.y}, DocumentPanY=$accumulatedPanY, " + + "CalculatedContentPivotY=$contentPivotY" + ) + + val rawNewPanX = centroid.x - (contentPivotX * constrainedZoom) + val rawNewPanY = centroid.y - (contentPivotY * constrainedZoom) + + if (effectiveZoomChange > 1.0f) { + Timber.tag("PdfZoomIssue").d( + "PinchIn FIXED: ZoomFactor=$effectiveZoomChange, CentroidY=${centroid.y}, " + + "OldPanY=$accumulatedPanY, ResultRawPanY=$rawNewPanY" + ) + } + val (finalZoom, finalX, finalY) = clampCamera( constrainedZoom, rawNewPanX, rawNewPanY ) @@ -1064,11 +1076,8 @@ internal fun PdfVerticalReader( if (event.changes.isNotEmpty()) { velocityTrackerAccumulator += panChange - val time = event.changes[0].uptimeMillis - tracker.addPosition( - time, velocityTrackerAccumulator - ) + tracker.addPosition(time, velocityTrackerAccumulator) } } } 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 8ef6b55..f177548 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -33,7 +33,14 @@ import android.graphics.Bitmap import android.graphics.RectF import android.net.Uri import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal import android.os.ParcelFileDescriptor +import android.print.PageRange +import android.print.PrintAttributes +import android.print.PrintDocumentAdapter +import android.print.PrintDocumentInfo +import android.print.PrintManager import android.provider.OpenableColumns import android.util.Base64 import android.widget.Toast @@ -43,6 +50,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -57,12 +65,16 @@ 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.Orientation import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -74,6 +86,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -83,7 +96,10 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.selection.selectable @@ -94,6 +110,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Brush @@ -129,7 +146,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults import androidx.compose.material3.ModalDrawerSheet @@ -245,8 +261,8 @@ import com.aryan.reader.R import com.aryan.reader.SearchResult import com.aryan.reader.SearchTopBar import com.aryan.reader.SummarizationPopup -import com.aryan.reader.TooltipIconButton import com.aryan.reader.SummarizationResult +import com.aryan.reader.TooltipIconButton import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.countWords import com.aryan.reader.epubreader.AutoScrollControls @@ -270,8 +286,7 @@ import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks -import io.legere.pdfiumandroid.PdfDocument -import io.legere.pdfiumandroid.PdfPasswordException +import io.legere.pdfiumandroid.api.Bookmark import io.legere.pdfiumandroid.suspend.PdfDocumentKt import io.legere.pdfiumandroid.suspend.PdfPageKt import io.legere.pdfiumandroid.suspend.PdfTextPageKt @@ -292,6 +307,8 @@ import org.json.JSONObject import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL import kotlin.math.max @@ -574,8 +591,153 @@ private fun loadDisplayMode(context: Context): DisplayMode { } } +private const val MAX_FIXED_RECURSION = 128 + +/** + * Patches the library bug where siblings are truncated due to depth-state leakage. + */ +suspend fun PdfDocumentKt.getFixedTableOfContents(): List { + val tag = "PdfTocFix" + Timber.tag(tag).i("Starting Pure Reflection Traversal...") + + return try { + // 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt + val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true } + val docUInstance = documentField.get(this) ?: return getTableOfContents() + + // 2. Get the 'nativeDocument' field from PdfDocumentU + val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true } + val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents() + + // 3. Get the native pointer (long) from PdfDocumentU + val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true } + val mNativeDocPtr = ptrField.get(docUInstance) as Long + + // 4. Look up native methods using primitive 'long' types (mandatory for JNI) + val nClass = nativeDocInstance.javaClass + val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long' + + val getTitleM = nClass.getMethod("getBookmarkTitle", lp) + val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp) + val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp) + val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp) + + val topLevel = mutableListOf() + val visited = mutableSetOf() + + /** + * Corrected traversal: Iterative for siblings, recursive for children. + */ + fun walk(parentList: MutableList, startPtr: Long, level: Int) { + var currentPtr = startPtr + var itemIndex = 0 + + while (currentPtr != 0L) { + if (visited.contains(currentPtr)) break + visited.add(currentPtr) + + val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled" + val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long + + Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title") + + val bookmark = Bookmark().apply { + this.mNativePtr = currentPtr + this.title = title + this.pageIdx = pageIdx + } + parentList.add(bookmark) + + // Recursive dive into children + val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long + if (firstChild != 0L && level < MAX_FIXED_RECURSION) { + walk(bookmark.children, firstChild, level + 1) + } + + // Iterative move to next sibling + currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long + itemIndex++ + } + } + + // 5. Start from the root (Pass 0L as primitive long) + val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long + if (firstRoot != 0L) { + walk(topLevel, firstRoot, 0) + } + + if (topLevel.isEmpty()) { + Timber.tag(tag).w("No items found, falling back to library.") + getTableOfContents() + } else { + Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}") + topLevel + } + } catch (e: Exception) { + Timber.tag(tag).e(e, "Reflection traversal critical error.") + this.getTableOfContents() + } +} + internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int) +class PdfPrintDocumentAdapter( + private val context: Context, + private val pdfUri: Uri, + private val fileName: String +) : PrintDocumentAdapter() { + + override fun onLayout( + oldAttributes: PrintAttributes?, + newAttributes: PrintAttributes?, + cancellationSignal: CancellationSignal?, + callback: LayoutResultCallback?, + extras: Bundle? + ) { + if (cancellationSignal?.isCanceled == true) { + callback?.onLayoutCancelled() + return + } + + val info = PrintDocumentInfo.Builder(fileName) + .setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) + .build() + + callback?.onLayoutFinished(info, true) + } + + override fun onWrite( + pages: Array?, + destination: ParcelFileDescriptor?, + cancellationSignal: CancellationSignal?, + callback: WriteResultCallback? + ) { + try { + context.contentResolver.openFileDescriptor(pdfUri, "r")?.use { pfd -> + FileInputStream(pfd.fileDescriptor).use { input -> + FileOutputStream(destination?.fileDescriptor).use { output -> + val buf = ByteArray(8192) + var bytesRead: Int + while (input.read(buf).also { bytesRead = it } > 0) { + if (cancellationSignal?.isCanceled == true) { + Timber.tag("PdfPrint").d("Print job cancelled during write") + callback?.onWriteCancelled() + return + } + output.write(buf, 0, bytesRead) + } + } + } + } + Timber.tag("PdfPrint").i("PDF successfully streamed to print spooler") + callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES)) + } catch (e: Exception) { + Timber.tag("PdfPrint").e(e, "Error writing PDF to print spooler") + callback?.onWriteFailed(e.message) + } + } +} + private fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set { if (bookmarksJson.isNullOrBlank()) return emptySet() return try { @@ -615,23 +777,188 @@ private data class TtsPageData( private data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int) -private fun flattenToc(bookmarks: List, level: Int = 0): List { +private fun flattenToc(bookmarks: List, level: Int = 0): List { + Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items") val entries = mutableListOf() - for (bookmark in bookmarks) { + for ((index, bookmark) in bookmarks.withIndex()) { + val title = bookmark.title ?: "Untitled Chapter" + val childCount = bookmark.children.size + + Timber.tag("PdfTocDebug").d( + "Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount" + ) + entries.add( TocEntry( - title = bookmark.title ?: "Untitled Chapter", + title = title, pageIndex = bookmark.pageIdx.toInt(), nestLevel = level ) ) - if (bookmark.children.isNotEmpty()) { + + if (childCount > 0) { + Timber.tag("PdfTocDebug").v("Entering children of \"$title\"") entries.addAll(flattenToc(bookmark.children, level + 1)) + Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"") } } return entries } +private data class ScrollbarCalculations( + val thumbHeight: Float, + val thumbOffset: Float, + val contentHeight: Float, + val viewportHeight: Float +) + +@Composable +fun VerticalScrollbar( + listState: LazyListState, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isDragged by interactionSource.collectIsDraggedAsState() + + val scrollbarState by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val visibleItemsInfo = layoutInfo.visibleItemsInfo + val viewportHeight = layoutInfo.viewportSize.height.toFloat() + + if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) { + return@derivedStateOf null + } + + // Estimate total height + val averageItemHeight = visibleItemsInfo.sumOf { it.size } / visibleItemsInfo.size.toFloat() + val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight) + val viewportRatio = viewportHeight / estimatedContentHeight + + if (viewportRatio >= 1f) return@derivedStateOf null + + val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2) + + val firstItemIndex = listState.firstVisibleItemIndex + val firstItemOffset = listState.firstVisibleItemScrollOffset + val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset + val maxScrollPixels = estimatedContentHeight - viewportHeight + val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f) + val trackHeight = viewportHeight - thumbHeight + val thumbOffset = trackHeight * scrollProgress + + ScrollbarCalculations( + thumbHeight = thumbHeight, + thumbOffset = thumbOffset, + contentHeight = estimatedContentHeight, + viewportHeight = viewportHeight + ) + } + } + + val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f + val alpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = tween(durationMillis = 200), + label = "ScrollbarAlpha" + ) + + if (scrollbarState != null) { + val state = scrollbarState!! + val draggableState = rememberDraggableState { delta -> + val trackHeight = state.viewportHeight - state.thumbHeight + if (trackHeight > 0) { + val scrollRatio = delta / trackHeight + val totalScrollableDistance = state.contentHeight - state.viewportHeight + val scrollDelta = scrollRatio * totalScrollableDistance + listState.dispatchRawDelta(scrollDelta) + } + } + + Box( + modifier = modifier + .width(30.dp) + .fillMaxHeight() + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + interactionSource = interactionSource + ) + ) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .graphicsLayer { translationY = state.thumbOffset } + .padding(end = 4.dp) + .width(6.dp) + .height(with(LocalDensity.current) { state.thumbHeight.toDp() }) + .alpha(alpha) + .background( + color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(100) + ) + ) + } + } +} + +@Composable +private fun PdfTocTreeItem( + label: String, + nestLevel: Int, + isExpanded: Boolean, + hasChildren: Boolean, + isCurrent: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + val backgroundColor by animateColorAsState( + targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent, + label = "TocItemBackground" + ) + + val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width((16 * nestLevel).dp)) + + Box( + modifier = Modifier + .size(40.dp) + .clickable(enabled = hasChildren, onClick = onToggleExpand), + contentAlignment = Alignment.Center + ) { + if (hasChildren) { + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = label, + style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium, + fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, + color = contentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(end = 16.dp) + ) + } +} + @OptIn(UnstableApi::class) @Suppress("unused") private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) { @@ -644,6 +971,7 @@ private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitm var page: PdfPageKt? = null try { page = doc.openPage(pageIndex) + if (page == null) return@withContext null val bitmapWidth = 1080 val aspectRatio = @@ -853,6 +1181,25 @@ fun PdfViewerScreen( isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId) } + val onPrintDocument = { + val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager + val jobName = "${context.getString(R.string.app_name)} - $originalFileName" + + try { + Timber.tag("PdfPrint").d("Starting print job: $jobName") + printManager.print( + jobName, + PdfPrintDocumentAdapter(context, pdfUri, originalFileName), + null + ) + } catch (e: Exception) { + Timber.tag("PdfPrint").e(e, "Failed to initialize print job") + coroutineScope.launch { + snackbarHostState.showSnackbar("Could not open print settings") + } + } + } + val initialSettings = remember(isAutoScrollLocal, bookId) { if (isAutoScrollLocal) { loadPdfAutoScrollLocalSettings(context, bookId) ?: Triple( @@ -1357,7 +1704,7 @@ fun PdfViewerScreen( withContext(Dispatchers.IO) { try { - doc.openPage(pageIndex).use { page -> + doc.openPage(pageIndex)?.use { page -> page.openTextPage().use { textPage -> val fullText = textPage.textPageGetText(newStart, newEnd - newStart) ?: text val rects = textPage.textPageGetRectsForRanges(intArrayOf(newStart, newEnd - newStart)) @@ -1895,7 +2242,7 @@ fun PdfViewerScreen( if (pdfDocument != null) { try { withContext(Dispatchers.IO) { - pdfDocument!!.openPage(pageIndex).use { page -> + pdfDocument!!.openPage(pageIndex)?.use { page -> page.openTextPage().use { textPage -> val count = textPage.textPageCountChars() @@ -2414,10 +2761,10 @@ fun PdfViewerScreen( withContext(Dispatchers.IO) { Timber.d("TTS: Opening page $pageToRead for Pdfium text extraction.") tempPage = pdfDocument!!.openPage(pageToRead) - tempTextPage = tempPage.openTextPage() - val charCount = tempTextPage.textPageCountChars() + tempTextPage = tempPage?.openTextPage() + val charCount = tempTextPage?.textPageCountChars() ?: 0 if (charCount > 0) { - rawPageText = tempTextPage.textPageGetText(0, charCount)?.trim() + rawPageText = tempTextPage?.textPageGetText(0, charCount)?.trim() if (rawPageText.isNullOrBlank()) { Timber.d( "TTS: Pdfium extracted text but it's blank (charCount: $charCount)." @@ -2772,6 +3119,17 @@ fun PdfViewerScreen( pdfDocument = doc pfdState = currentPfdOpened val pagesCount = doc.getPageCount() + + if (pagesCount > 0) { + try { + val tableOfContents = doc.getFixedTableOfContents() + val flattened = flattenToc(tableOfContents) + withContext(Dispatchers.Main) { flatTableOfContents = flattened } + } catch (e: Exception) { + Timber.w(e, "Failed to load TOC") + } + } + totalPages = pagesCount if (pagesCount > 0) { @@ -2782,7 +3140,7 @@ fun PdfViewerScreen( cachedRatios } else { val computedRatios = ArrayList(pagesCount) - doc.openPage(0).use { page -> + doc.openPage(0)?.use { page -> val width = page.getPageWidthPoint() val height = page.getPageHeightPoint() val ratio = if (height > 0) width.toFloat() / height.toFloat() @@ -2797,7 +3155,7 @@ fun PdfViewerScreen( for (i in 0 until pagesCount) { if (!isActive) break try { - doc.openPage(i).use { page -> + doc.openPage(i)?.use { page -> val width = page.getPageWidthPoint() val height = page.getPageHeightPoint() val ratio = @@ -2843,7 +3201,7 @@ fun PdfViewerScreen( for (i in 1 until pagesCount) { if (!isActive) break try { - doc.openPage(i).use { page -> + doc.openPage(i)?.use { page -> val width = page.getPageWidthPoint() val height = page.getPageHeightPoint() val ratio = if (height > 0) width.toFloat() / height.toFloat() @@ -2866,16 +3224,6 @@ fun PdfViewerScreen( } } } - - launch(Dispatchers.IO) { - try { - val tableOfContents = doc.getTableOfContents() - val flattened = flattenToc(tableOfContents) - withContext(Dispatchers.Main) { flatTableOfContents = flattened } - } catch (e: Exception) { - Timber.w(e, "Failed to load TOC") - } - } } else { isDocumentReady = true isLoadingDocument = false @@ -2884,7 +3232,7 @@ fun PdfViewerScreen( Timber.i("PDF document loaded optimistically. Total Pages: $totalPages.") } } catch (e: Exception) { - if (e is PdfPasswordException || e.cause is PdfPasswordException) { + if (e.javaClass.name.contains("PasswordException") || e.cause?.javaClass?.name?.contains("PasswordException") == true) { Timber.w("PDF is password protected or password incorrect.") withContext(Dispatchers.Main) { if (documentPassword != null) { @@ -3358,9 +3706,7 @@ fun PdfViewerScreen( 0 -> { // Chapters Page if (flatTableOfContents.isEmpty()) { Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center ) { Text( @@ -3370,54 +3716,99 @@ fun PdfViewerScreen( ) } } else { - val currentTocEntry by remember( - pagerState.currentPage, flatTableOfContents - ) { - derivedStateOf { - flatTableOfContents.lastOrNull { - it.pageIndex <= pagerState.currentPage + val listState = rememberLazyListState() + + val allParentIndices = remember(flatTableOfContents) { + flatTableOfContents.indices.filter { i -> + val next = flatTableOfContents.getOrNull(i + 1) + next != null && next.nestLevel > flatTableOfContents[i].nestLevel + }.toSet() + } + + var expandedEntryIndices by rememberSaveable(flatTableOfContents) { + mutableStateOf(allParentIndices) + } + + val visibleItemInfo = remember(flatTableOfContents, expandedEntryIndices) { + val result = mutableListOf>() + val visibilityStack = BooleanArray(20) { false } + visibilityStack[0] = true + + for (i in flatTableOfContents.indices) { + val entry = flatTableOfContents[i] + val level = entry.nestLevel.coerceIn(0, 19) + + if (visibilityStack[level]) { + result.add(i to entry) + val isExpanded = expandedEntryIndices.contains(i) + if (level + 1 < visibilityStack.size) { + visibilityStack[level + 1] = isExpanded + } + } else { + if (level + 1 < visibilityStack.size) { + visibilityStack[level + 1] = false + } } } + result } - LazyColumn(modifier = Modifier.fillMaxHeight()) { - itemsIndexed( - items = flatTableOfContents, key = { index, entry -> - "toc_${index}_${entry.pageIndex}_${entry.title.hashCode()}" - }) { _, entry -> - val isCurrentChapter = entry == currentTocEntry - ListItem( - headlineContent = { - Text( - entry.title, - fontWeight = if (isCurrentChapter) FontWeight.Bold - else FontWeight.Normal, - modifier = Modifier.padding( - start = (16 * entry.nestLevel).dp - ) - ) - }, colors = if (isCurrentChapter) { - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - headlineColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - } else { - ListItemDefaults.colors() - }, modifier = Modifier.clickable { - coroutineScope.launch { - drawerState.close() - if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage( - entry.pageIndex - ) + + val currentTocEntry by remember(pagerState.currentPage, verticalReaderState.currentPage, displayMode, flatTableOfContents) { + derivedStateOf { + val activePage = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + flatTableOfContents.lastOrNull { it.pageIndex <= activePage } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxHeight() + .padding(end = 12.dp) + ) { + items( + items = visibleItemInfo, + key = { it.second.title + it.first } + ) { item -> + val (originalIndex, entry) = item + + val nextItem = flatTableOfContents.getOrNull(originalIndex + 1) + val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel + val isExpanded = expandedEntryIndices.contains(originalIndex) + val isCurrentChapter = entry == currentTocEntry + + PdfTocTreeItem( + label = entry.title, + nestLevel = entry.nestLevel, + isExpanded = isExpanded, + hasChildren = hasChildren, + isCurrent = isCurrentChapter, + onToggleExpand = { + expandedEntryIndices = if (isExpanded) { + expandedEntryIndices - originalIndex } else { - verticalReaderState.scrollToPage( - entry.pageIndex - ) + expandedEntryIndices + originalIndex + } + }, + onClick = { + coroutineScope.launch { + drawerState.close() + if (displayMode == DisplayMode.PAGINATION) { + pagerState.scrollToPage(entry.pageIndex) + } else { + verticalReaderState.scrollToPage(entry.pageIndex) + } } } - }) - HorizontalDivider() + ) + } } + + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) } } } @@ -3767,7 +4158,13 @@ fun PdfViewerScreen( modifier = Modifier.fillMaxSize(), key = { it }, beyondViewportPageCount = dynamicBeyondViewportPageCount, - userScrollEnabled= currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null + userScrollEnabled = run { + val enabled = currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null + SideEffect { + Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)") + } + enabled + } ) { pageIndex -> val isVisiblePage = remember(pagerState.currentPage, pageIndex) { kotlin.math.abs(pagerState.currentPage - pageIndex) <= 1 @@ -5221,6 +5618,20 @@ fun PdfViewerScreen( Icons.Default.Save, contentDescription = null ) }) + DropdownMenuItem( + text = { Text("Print") }, + onClick = { + showMoreMenu = false + onPrintDocument() + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.print), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) } } } @@ -6929,6 +7340,7 @@ private fun debugPdfLinks( if (pageCount > 0) { val pageIndex = 0 // Testing the first page page = doc.openPage(pageIndex) + if (page == null) return@launch Timber.d("Opened page $pageIndex") Timber.d( @@ -6957,7 +7369,7 @@ private fun debugPdfLinks( // Method 2: The one that is working page.openTextPage().use { textPage -> - textPage.loadWebLink().use { webLinks -> + textPage.loadWebLink()?.use { webLinks -> val webLinkCount = webLinks.countWebLinks() Timber.d("[METHOD 2] loadWebLink() found $webLinkCount links.") if (webLinkCount > 0) { 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 3e02b04..b09b0b8 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 @@ -171,7 +171,7 @@ class PdfTextRepository(context: Context) { var ocrUsed = false try { - document.openPage(pageIndex).use { page -> + document.openPage(pageIndex)?.use { page -> page.openTextPage().use { textPage -> val count = textPage.textPageCountChars() if (count > 0) { @@ -188,7 +188,7 @@ class PdfTextRepository(context: Context) { if (text.isBlank()) { try { - document.openPage(pageIndex).use { page -> + document.openPage(pageIndex)?.use { page -> val targetWidth = 1080 val ptrWidth = page.getPageWidthPoint() val ptrHeight = page.getPageHeightPoint() @@ -270,11 +270,11 @@ class PdfTextRepository(context: Context) { suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean { return withContext(Dispatchers.IO) { try { - document.openPage(pageIndex).use { page -> + document.openPage(pageIndex)?.use { page -> page.openTextPage().use { textPage -> textPage.textPageCountChars() > 0 } - } + } ?: false } catch (_: Exception) { false } @@ -290,7 +290,7 @@ class PdfTextRepository(context: Context) { return withContext(Dispatchers.IO) { val rects = mutableListOf() try { - document.openPage(pageIndex).use { page -> + document.openPage(pageIndex)?.use { page -> val targetWidth = 1080 val ptrWidth = page.getPageWidthPoint() val ptrHeight = page.getPageHeightPoint() diff --git a/app/src/main/res/drawable-nodpi/print.xml b/app/src/main/res/drawable-nodpi/print.xml new file mode 100644 index 0000000..f144dac --- /dev/null +++ b/app/src/main/res/drawable-nodpi/print.xml @@ -0,0 +1,10 @@ + + +