diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index 33e643d..a1969c3 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -39,7 +39,19 @@ 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); +typedef void* (*FPDFLink_GetLinkAtPoint_t)(void* page, double x, double y); +typedef unsigned long (*FPDFAction_GetURIPath_t)(void* document, void* action, void* buffer, unsigned long buflen); +typedef void* (*FPDFLink_GetDest_t)(void* document, void* link); +typedef void* (*FPDFAction_GetDest_t)(void* document, void* action); +typedef int (*FPDFDest_GetDestPageIndex_t)(void* document, void* dest); +typedef unsigned long (*FPDFAction_GetFilePath_t)(void* action, void* buffer, unsigned long buflen); +static FPDFLink_GetLinkAtPoint_t get_link_at_point_func = nullptr; +static FPDFAction_GetURIPath_t get_uri_path_func = nullptr; +static FPDFLink_GetDest_t get_dest_func = nullptr; +static FPDFAction_GetDest_t get_action_dest_func = nullptr; +static FPDFDest_GetDestPageIndex_t get_dest_page_index_func = nullptr; +static FPDFAction_GetFilePath_t get_file_path_func = nullptr; static std::mutex g_pdfium_mutex; static FPDFLink_GetAnnot_t get_link_annot_func = nullptr; static FPDFLink_GetAction_t get_link_action_func = nullptr; @@ -124,6 +136,13 @@ static bool init_pdfium() { 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"); + get_link_at_point_func = (FPDFLink_GetLinkAtPoint_t) dlsym(pdfium_handle, "FPDFLink_GetLinkAtPoint"); + get_uri_path_func = (FPDFAction_GetURIPath_t) dlsym(pdfium_handle, "FPDFAction_GetURIPath"); + get_dest_func = (FPDFLink_GetDest_t) dlsym(pdfium_handle, "FPDFLink_GetDest"); + get_action_dest_func = (FPDFAction_GetDest_t) dlsym(pdfium_handle, "FPDFAction_GetDest"); + get_dest_page_index_func = (FPDFDest_GetDestPageIndex_t) dlsym(pdfium_handle, "FPDFDest_GetDestPageIndex"); + get_file_path_func = (FPDFAction_GetFilePath_t) dlsym(pdfium_handle, "FPDFAction_GetFilePath"); + // --- Validation & Logging --- bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func && get_annot_rect_func && get_annot_string_func; @@ -442,7 +461,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl int count = get_annot_count_func(page); void* hitAnnot = nullptr; - // 1. Find which annotation was clicked + LOGI("PdfLinkDiagnostic: [C++] performClick at x=%f, y=%f (Total annots: %d)", x, y, count); + for (int i = 0; i < count; i++) { void* annot = get_annot_func(page, i); if (!annot) continue; @@ -456,6 +476,12 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl if (x >= minX && x <= maxX && y >= minY && y <= maxY) { hitAnnot = annot; + int subtype = get_annot_subtype_func(hitAnnot); + LOGI("PdfLinkDiagnostic: [C++] HIT! Annot Index %d, Subtype %d", i, subtype); + if (get_annot_flags_func) { + int flags = get_annot_flags_func(hitAnnot); + LOGI("PdfLinkDiagnostic: [C++] Flags for hit annot: %d", flags); + } break; } } @@ -491,4 +517,92 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl } return JNI_FALSE; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jclass clazz, jlong docPtr, jlong pagePtr, jdouble x, jdouble y) { + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium()) { + LOGE("PdfLinkDiagnostic: init_pdfium failed."); + return nullptr; + } + if (!get_link_at_point_func) { + LOGE("PdfLinkDiagnostic: get_link_at_point_func is null."); + return nullptr; + } + if (pagePtr == 0 || docPtr == 0) { + LOGE("PdfLinkDiagnostic: Missing Pointers -> pagePtr=%ld, docPtr=%ld", (long)pagePtr, (long)docPtr); + return nullptr; + } + + void* page = reinterpret_cast(pagePtr); + void* wrapperDoc = reinterpret_cast(docPtr); + + void* doc = wrapperDoc ? *(void**)wrapperDoc : nullptr; + + if (!doc) { + LOGE("PdfLinkDiagnostic: Dereferenced doc pointer is null!"); + return nullptr; + } + + LOGI("PdfLinkDiagnostic: Checking native link at x=%f, y=%f", x, y); + + void* link = get_link_at_point_func(page, x, y); + if (!link) { + LOGI("PdfLinkDiagnostic: No FPDF_LINK found at point."); + return nullptr; + } + + LOGI("PdfLinkDiagnostic: FPDF_LINK found!"); + + if (get_link_action_func && get_action_type_func) { + void* action = get_link_action_func(link); + if (action) { + unsigned long type = get_action_type_func(action); + LOGI("PdfLinkDiagnostic: Action Type = %lu", type); + + if (type == 3 && get_uri_path_func) { // 3 = URI + unsigned long len = get_uri_path_func(doc, action, nullptr, 0); + if (len > 0) { + std::vector buffer(len); + get_uri_path_func(doc, action, buffer.data(), len); + std::string uri(buffer.data()); + LOGI("PdfLinkDiagnostic: Extracted Action URI = %s", uri.c_str()); + std::string result = "URI:" + uri; + return env->NewStringUTF(result.c_str()); + } + } else if (type == 1 && get_action_dest_func && get_dest_page_index_func) { // 1 = GoTo + void* dest = get_action_dest_func(doc, action); + if (dest) { + int pageIndex = get_dest_page_index_func(doc, dest); + LOGI("PdfLinkDiagnostic: Extracted Action GoTo Page = %d", pageIndex); + std::string result = "PAGE:" + std::to_string(pageIndex); + return env->NewStringUTF(result.c_str()); + } + } else if ((type == 2 || type == 4) && get_file_path_func) { // 2 = RemoteGoTo, 4 = Launch + unsigned long len = get_file_path_func(action, nullptr, 0); + if (len > 0) { + std::vector buffer(len); + get_file_path_func(action, buffer.data(), len); + std::string path(buffer.data()); + LOGI("PdfLinkDiagnostic: Extracted File Path = %s", path.c_str()); + std::string result = "URI:" + path; + return env->NewStringUTF(result.c_str()); + } + } + } + } + + if (get_dest_func && get_dest_page_index_func) { + void* dest = get_dest_func(doc, link); + if (dest) { + int pageIndex = get_dest_page_index_func(doc, dest); + LOGI("PdfLinkDiagnostic: Extracted Direct Dest Page = %d", pageIndex); + std::string result = "PAGE:" + std::to_string(pageIndex); + return env->NewStringUTF(result.c_str()); + } + } + + LOGI("PdfLinkDiagnostic: Link found but payload was empty or unsupported."); + return nullptr; } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index ee7c3d9..d2ed9be 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -221,9 +221,7 @@ fun HomeScreen( if (isContextualModeActive) { viewModel.clearContextualAction() } - uris.forEach { uri -> - viewModel.onFileSelected(uri, isFromRecent = false) - } + viewModel.onFilesSelected(uris) } val fallbackFilePickerLauncher = rememberLauncherForActivityResult( @@ -232,9 +230,7 @@ fun HomeScreen( if (isContextualModeActive) { viewModel.clearContextualAction() } - uris.forEach { uri -> - viewModel.onFileSelected(uri, isFromRecent = false) - } + viewModel.onFilesSelected(uris) } val onSelectFileClick = { @@ -311,7 +307,8 @@ fun HomeScreen( onShowDeviceManagement = viewModel::showDeviceManagementForDebug, onFolderSyncToggle = viewModel::setFolderSyncEnabled, onClearReflowCache = { showClearReflowCacheDialog = true }, - onRecentFilesLimitChange = viewModel::setRecentFilesLimit + onRecentFilesLimitChange = viewModel::setRecentFilesLimit, + onTabsToggle = viewModel::setTabsEnabled ) } else { ContextualTopAppBar( @@ -769,7 +766,8 @@ fun DefaultTopAppBar( onAboutClick: () -> Unit, onShowDeviceManagement: () -> Unit, onFolderSyncToggle: (Boolean) -> Unit, - onRecentFilesLimitChange: (Int) -> Unit + onRecentFilesLimitChange: (Int) -> Unit, + onTabsToggle: (Boolean) -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } @@ -822,6 +820,17 @@ fun DefaultTopAppBar( showOptionsMenu = false }) + HorizontalDivider() + + DropdownMenuItem(text = { Text("Enable Multi-Tab Reading") }, onClick = { + onTabsToggle(!uiState.isTabsEnabled) + showOptionsMenu = false + }, trailingIcon = { + if (uiState.isTabsEnabled) { + Icon(Icons.Default.Check, contentDescription = "Enabled") + } + }) + HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { onClearCache() diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 8d630d6..a20b19b 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -199,9 +199,7 @@ fun LibraryScreen( if (isContextualModeActive) { viewModel.clearContextualAction() } - uris.forEach { uri -> - viewModel.onFileSelected(uri, isFromRecent = false) - } + viewModel.onFilesSelected(uris) } val fallbackFilePickerLauncher = rememberLauncherForActivityResult( @@ -210,9 +208,7 @@ fun LibraryScreen( if (isContextualModeActive) { viewModel.clearContextualAction() } - uris.forEach { uri -> - viewModel.onFileSelected(uri, isFromRecent = false) - } + viewModel.onFilesSelected(uris) } val onSelectFileClick = { diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 7eeea57..4621c2a 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -236,6 +236,10 @@ data class ReaderScreenState( val pinnedLibraryBookIds: Set = emptySet(), val libraryFilters: LibraryFilters = LibraryFilters(), val recentFilesLimit: Int = 0, + val isTabsEnabled: Boolean = false, + val openTabIds: List = emptyList(), + val openTabs: List = emptyList(), + val activeTabBookId: String? = null, ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -335,7 +339,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio else null, pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(), pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet(), - recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0) + recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0), + isTabsEnabled = prefs.getBoolean(KEY_TABS_ENABLED, false), + openTabIds = prefs.getString(KEY_OPEN_TAB_IDS, null)?.let { + try { + val arr = JSONArray(it) + List(arr.length()) { i -> arr.getString(i) } + } catch(_: Exception) { emptyList() } + } ?: emptyList(), + activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), ) ) @@ -394,6 +406,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (internalState.recentFilesLimit > 0) combined.take(internalState.recentFilesLimit) else combined } + val allBaseFiles = recentFilesFromDb.filterNot { it.bookId.endsWith("_reflow") } + val openTabsList = internalState.openTabIds.mapNotNull { tabId -> + allBaseFiles.find { it.bookId == tabId } + } + val validContextualItems = internalState.contextualActionItems.filter { contextItem -> baseVisibleFiles.any { dbItem -> dbItem.uriString == contextItem.uriString } }.toSet() @@ -433,6 +450,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio rawLibraryFiles = baseVisibleFiles, contextualActionItems = validContextualItems, shelves = allShelves, + openTabs = openTabsList, booksAvailableForAdding = booksAvailableForAdding ) }.stateIn( @@ -441,6 +459,103 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio initialValue = ReaderScreenState() ) + fun setTabsEnabled(enabled: Boolean) { + prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) } + _internalState.update { it.copy(isTabsEnabled = enabled) } + if (!enabled) { + val active = _internalState.value.activeTabBookId + val newTabs = if (active != null) listOf(active) else emptyList() + prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(newTabs).toString()) } + _internalState.update { it.copy(openTabIds = newTabs) } + } + } + + fun switchTab(bookId: String) { + Timber.tag("PdfTabSync").i("ViewModel: switchTab called for bookId: $bookId") + val item = uiState.value.rawLibraryFiles.find { it.bookId == bookId } ?: run { + Timber.tag("PdfTabSync").e("ViewModel: switchTab FAILED - BookId $bookId not found in library") + return + } + + val currentTabs = _internalState.value.openTabIds.toMutableList() + if (!currentTabs.contains(bookId)) { + if (currentTabs.size >= 20) { + viewModelScope.launch(Dispatchers.Main) { + showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true) + } + return + } + currentTabs.add(bookId) + } + + prefs.edit { + putString(KEY_ACTIVE_TAB, bookId) + putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) + } + + val uri = item.getUri() + Timber.tag("PdfTabSync").d("ViewModel: ActiveTab updated to $bookId. URI found: ${uri != null}") + + uri?.let { + Timber.tag("PdfTabSync").d("ViewModel: Setting new URI directly: $it") + _internalState.update { state -> + state.copy( + openTabIds = currentTabs, + activeTabBookId = bookId, + selectedPdfUri = it, + selectedBookId = bookId, + selectedFileType = item.type, + initialPageInBook = item.lastPage, + initialBookmarksJson = item.bookmarksJson, + isLoading = false, + errorMessage = null + ) + } + + viewModelScope.launch { + addFileToRecent( + it, + item.type, + bookId, + customDisplayName = item.displayName, + isRecent = true, + sourceFolderUri = item.sourceFolderUri + ) + } + } ?: run { + _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) } + } + } + + fun closeTab(bookId: String) { + Timber.tag("PdfTabSync").i("ViewModel: closeTab called for $bookId") + val currentTabs = _internalState.value.openTabIds.toMutableList() + currentTabs.remove(bookId) + + if (currentTabs.isEmpty()) { + prefs.edit { + remove(KEY_OPEN_TAB_IDS) + remove(KEY_ACTIVE_TAB) + } + _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) } + clearSelectedFile() + } else { + val activeTab = _internalState.value.activeTabBookId + if (activeTab == bookId) { + val nextTabId = currentTabs.last() + prefs.edit { + putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) + putString(KEY_ACTIVE_TAB, nextTabId) + } + _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = nextTabId) } + switchTab(nextTabId) + } else { + prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) } + _internalState.update { it.copy(openTabIds = currentTabs) } + } + } + } + fun onSearchQueryChange(newQuery: String) { _internalState.update { if (it.isSearchActive) { @@ -2643,6 +2758,67 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return fileName ?: uri.lastPathSegment } + fun onFilesSelected(uris: List) { + if (uris.isEmpty()) return + + if (uris.size == 1) { + onFileSelected(uris.first(), isFromRecent = false) + return + } + + viewModelScope.launch { + _internalState.update { + it.copy( + bannerMessage = BannerMessage( + message = appContext.getString(R.string.banner_importing_multiple, uris.size), + isPersistent = true + ), + contextualActionItems = emptySet() + ) + } + + var importedCount = 0 + + withContext(Dispatchers.IO) { + for (externalUri in uris) { + val importResult = prepareBookForImport(externalUri) + if (importResult != null) { + val (internalUri, bookId, type) = importResult + val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" + + addFileToRecent( + uri = internalUri, + type = type, + bookId = bookId, + customDisplayName = displayName, + isRecent = false, + sourceFolderUri = null + ) + importedCount++ + } else { + val hash = FileHasher.calculateSha256 { + appContext.contentResolver.openInputStream(externalUri) + } + if (hash != null && recentFilesRepository.getFileByBookId(hash) != null) { + importedCount++ + } + } + } + } + + _internalState.update { + it.copy( + bannerMessage = BannerMessage( + message = "Imported $importedCount books. You can find them in the Library tab.", + isPersistent = false + ) + ) + } + + Timber.tag("BulkImport").i("Bulk import complete. $importedCount files processed.") + } + } + fun onFileSelected(uri: Uri, isFromRecent: Boolean = false) { if (isFromRecent) { Timber.i("Opening recent file: $uri") @@ -2904,12 +3080,30 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun openBook( - uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null + uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false ) { val openBookStartTime = System.currentTimeMillis() Timber.tag("FileOpenPerf") .d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") + if (_internalState.value.isTabsEnabled && type == FileType.PDF) { + val currentTabs = _internalState.value.openTabIds.toMutableList() + if (!currentTabs.contains(bookId)) { + if (currentTabs.size >= 20) { + viewModelScope.launch(Dispatchers.Main) { + showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true) + } + return + } + currentTabs.add(bookId) + } + prefs.edit { + putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) + putString(KEY_ACTIVE_TAB, bookId) + } + _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) } + } + if (uri.scheme != "opds-pse") { try { if (uri.scheme == "file") { @@ -2980,6 +3174,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isRecent = true, sourceFolderUri = null ) + + if (!suppressNavigation) { + Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event") + _navigationEvent.send(NavigationEvent("pdf_viewer", bookId, uri)) + } else { + Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition") + } } } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) { viewModelScope.launch { @@ -3012,6 +3213,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } + if (!suppressNavigation) { + Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event") + _navigationEvent.send(NavigationEvent("epub_reader", bookId, uri)) + } + when (type) { FileType.EPUB -> { loadEpub(uri, bookId, customDisplayName = originalDisplayName) @@ -4152,5 +4358,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio internal const val KEY_PINNED_HOME = "pinned_home_books" internal const val KEY_PINNED_LIBRARY = "pinned_library_books" private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit" + private const val KEY_TABS_ENABLED = "tabs_enabled" + private const val KEY_OPEN_TAB_IDS = "open_tab_ids" + private const val KEY_ACTIVE_TAB = "active_tab_book_id" } } diff --git a/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt b/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt index 7d9b190..2b43eb0 100644 --- a/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt +++ b/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt @@ -21,14 +21,15 @@ package com.aryan.reader.pdf import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -76,6 +77,7 @@ fun AnnotationDock( onToggleStylusOnlyMode: () -> Unit ) { val showFullDock = isSticky || !isMinimized + val scrollState = rememberScrollState() val dockHeight = 56.dp val buttonSize = 36.dp @@ -93,7 +95,9 @@ fun AnnotationDock( modifier = modifier.height(dockHeight) ) { Row( - modifier = Modifier.padding(horizontal = horizontalPadding), + modifier = Modifier + .padding(horizontal = horizontalPadding) + .horizontalScroll(scrollState), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing) ) { @@ -235,8 +239,6 @@ fun AnnotationDock( ) } - Spacer(modifier = Modifier.weight(1f)) - // Undo Box( modifier = Modifier 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 18b9899..9bb73e5 100644 --- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt +++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt @@ -25,6 +25,7 @@ object NativePdfiumBridge { @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 getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String? @JvmStatic external fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int @JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? 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 8d038b2..ecbe68a 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -2427,14 +2427,14 @@ internal fun PdfPageComposable( val tapYInBitmap = tapInContentCoords.y coroutineScope.launch { - val wasHandled = withContext(Dispatchers.IO) { + val nativeResult = withContext(Dispatchers.IO) { try { pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> val pagePtr = page.getNativePointer() if (pagePtr == 0L) { Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex") - return@withContext false + return@withContext 0 } val pdfCoords = page.mapDeviceCoordsToPage( @@ -2442,134 +2442,150 @@ internal fun PdfPageComposable( currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt() ) - NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()) - } ?: false + val docPtr = try { + val pdfDocKt = (pdfDocumentItem as? PdfDocumentWrapper)?.pdfDocument + if (pdfDocKt != null) { + val documentField = pdfDocKt.javaClass.getDeclaredField("document").apply { isAccessible = true } + val docUInstance = documentField.get(pdfDocKt) + if (docUInstance != null) { + val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true } + ptrField.get(docUInstance) as Long + } else 0L + } else 0L + } catch (e: Exception) { 0L } + + Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr") + + val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint( + docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble() + ) + + if (linkInfo != null) { + Timber.tag("PdfLinkDiagnostic").i(">>> Native Link Info Extracted: $linkInfo") + if (linkInfo.startsWith("URI:")) { + val url = linkInfo.substringAfter("URI:") + withContext(Dispatchers.Main) { onLinkClicked(url) } + return@withContext 1 + } else if (linkInfo.startsWith("PAGE:")) { + val targetPage = linkInfo.substringAfter("PAGE:").toIntOrNull() + if (targetPage != null && targetPage >= 0) { + withContext(Dispatchers.Main) { onInternalLinkClicked(targetPage) } + return@withContext 1 + } + } + } + + val clickHandled = NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()) + if (clickHandled) { + return@withContext 2 + } + return@withContext 0 + } ?: 0 } catch (e: Exception) { Timber.tag("PdfInteraction").e(e, "Interaction error") - false + 0 } } - if (wasHandled) { + if (nativeResult == 2) { Timber.tag("PdfInteraction").i("Action detected. Refreshing page.") tiles = emptyList() bitmapState = null isLoadingPage = true currentRenderedPageId = "ACTION_${System.currentTimeMillis()}" + return@launch + } else if (nativeResult == 1) { + return@launch } - } - val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale - val hitTolerance = with(density) { 16.dp.toPx() } / inputScale + val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale + val hitTolerance = with(density) { 16.dp.toPx() } / inputScale - Timber.d( - "detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})" - ) + Timber.d("detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})") - var tappedRect: Rect? = null - val hitHighlightPair = userHighlightScreenRects.findLast { pair -> - val hit = pair.second.find { r -> - val hitLeft = r.left - hitTolerance - val hitTop = r.top - hitTolerance - val hitRight = r.right + hitTolerance - val hitBottom = r.bottom + hitTolerance + var tappedRect: Rect? = null + val hitHighlightPair = userHighlightScreenRects.findLast { pair -> + val hit = pair.second.find { r -> + val hitLeft = r.left - hitTolerance + val hitTop = r.top - hitTolerance + val hitRight = r.right + hitTolerance + val hitBottom = r.bottom + hitTolerance - tapXInBitmap in hitLeft..hitRight && - tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom + tapXInBitmap in hitLeft..hitRight && tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom + } + if (hit != null) { + tappedRect = hit + true + } else false } - if (hit != null) { - tappedRect = hit - true - } else false - } - val standardHit = standardAnnotScreenRects.findLast { (annot, screenRect) -> - if (annot.subtype == 2) return@findLast false + val standardHit = standardAnnotScreenRects.findLast { (annot, screenRect) -> + if (annot.subtype == 2) return@findLast false - val left = min(screenRect.left, screenRect.right) - val right = max(screenRect.left, screenRect.right) - val top = min(screenRect.top, screenRect.bottom) - val bottom = max(screenRect.top, screenRect.bottom) + val left = min(screenRect.left, screenRect.right) + val right = max(screenRect.left, screenRect.right) + val top = min(screenRect.top, screenRect.bottom) + val bottom = max(screenRect.top, screenRect.bottom) - val inflatedHitBox = Rect( - (left - annotHitTolerance).toInt(), - (top - annotHitTolerance).toInt(), - (right + annotHitTolerance).toInt(), - (bottom + annotHitTolerance).toInt() - ) + val inflatedHitBox = Rect( + (left - annotHitTolerance).toInt(), + (top - annotHitTolerance).toInt(), + (right + annotHitTolerance).toInt(), + (bottom + annotHitTolerance).toInt() + ) - val isHit = inflatedHitBox.contains(tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()) - - isHit - } - - if (standardHit != null) { - val (annot, screenRect) = standardHit - customMenuState = CustomPdfMenuState( - selectedText = annot.contents ?: "No comment", - anchorRect = screenRect, - charRange = Pair(-1, -1), - isComment = true, - author = annot.author, - annotation = annot - ) - return@detectTapGestures - } - - if (hitHighlightPair != null && tappedRect != null) { - val hitHighlight = hitHighlightPair.first - - val combinedRect = Rect(hitHighlightPair.second.first()) - hitHighlightPair.second.forEach { combinedRect.union(it) } - - customMenuState = CustomPdfMenuState( - selectedText = hitHighlight.text, - anchorRect = combinedRect, - charRange = hitHighlight.range, - isExistingHighlight = true, - highlightId = hitHighlight.id - ) - return@detectTapGestures - } - - Timber.d( - "detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})" - ) - - val clickedLink = pageLinks.firstOrNull { link -> - link.tapBounds.contains( - tapXInBitmap.toInt(), tapYInBitmap.toInt() - ) - } - - if (clickedLink != null) { - Timber.d( - "PdfPageComposable: Link clicked. Ignoring selection logic." - ) - if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) { - onInternalLinkClicked(clickedLink.destPageIdx) - } else if (clickedLink.url != null) { - onLinkClicked(clickedLink.url) + inflatedHitBox.contains(tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()) } - return@detectTapGestures - } - val wasMenuVisible = customMenuState != null - val wasSelectionVisible = - selectionCharRange.value != null || ocrSelectionSymbolIndices != null + if (standardHit != null) { + val (annot, screenRect) = standardHit + customMenuState = CustomPdfMenuState( + selectedText = annot.contents ?: "No comment", + anchorRect = screenRect, + charRange = Pair(-1, -1), + isComment = true, + author = annot.author, + annotation = annot + ) + return@launch + } - Timber.d( - "PdfPageComposable: State check - MenuVisible=$wasMenuVisible, SelectionVisible=$wasSelectionVisible" - ) + if (hitHighlightPair != null && tappedRect != null) { + val hitHighlight = hitHighlightPair.first + val combinedRect = Rect(hitHighlightPair.second.first()) + hitHighlightPair.second.forEach { combinedRect.union(it) } - if (wasMenuVisible || wasSelectionVisible) { - Timber.d( - "PdfPageComposable: Clearing selection/menu." - ) - customMenuState = null - selectionCharRange.value = null - ocrSelectionSymbolIndices = null - coroutineScope.launch { + customMenuState = CustomPdfMenuState( + selectedText = hitHighlight.text, + anchorRect = combinedRect, + charRange = hitHighlight.range, + isExistingHighlight = true, + highlightId = hitHighlight.id + ) + return@launch + } + + val clickedLink = pageLinks.firstOrNull { link -> + link.tapBounds.contains(tapXInBitmap.toInt(), tapYInBitmap.toInt()) + } + + if (clickedLink != null) { + Timber.d("PdfPageComposable: Fallback pageLinks intercepted click.") + if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) { + onInternalLinkClicked(clickedLink.destPageIdx) + } else if (clickedLink.url != null) { + onLinkClicked(clickedLink.url) + } + return@launch + } + + val wasMenuVisible = customMenuState != null + val wasSelectionVisible = selectionCharRange.value != null || ocrSelectionSymbolIndices != null + + if (wasMenuVisible || wasSelectionVisible) { + customMenuState = null + selectionCharRange.value = null + ocrSelectionSymbolIndices = null updateSelectionVisuals( pdfDocumentItem, pdfPageIndex, @@ -2578,12 +2594,9 @@ internal fun PdfPageComposable( actualBitmapHeightPx, currentPageRotation, ) + } else { + currentOnSingleTap() } - } else { - Timber.d( - "PdfPageComposable: No selection active. Calling onSingleTap()." - ) - currentOnSingleTap() } }, onDoubleTap = { tapOffset -> if (isZoomEnabled && !isVerticalScroll) { 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 5fe4047..8dc735e 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -333,20 +333,20 @@ internal fun PdfVerticalReader( val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) } val panYAnimatable = remember { Animatable(0f) } + var isResizing by remember { mutableStateOf(false) } var previousScreenWidth by remember { mutableFloatStateOf(0f) } - LaunchedEffect(screenWidth, screenHeight) { - if (previousScreenWidth > 0f && previousScreenWidth != screenWidth) { - if (zoomAnimatable.value <= 1.1f) { - val centeredX = if ((screenWidth * fitZoom) < screenWidth) { - (screenWidth - (screenWidth * fitZoom)) / 2f - } else 0f + var previousScreenHeight by remember { mutableFloatStateOf(0f) } + val targetPageDuringResize = remember { mutableIntStateOf(-1) } - zoomAnimatable.snapTo(fitZoom) - panXAnimatable.snapTo(centeredX) - onZoomChange(fitZoom) + if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) { + if (previousScreenWidth > 0f) { + isResizing = true + if (targetPageDuringResize.intValue == -1) { + targetPageDuringResize.intValue = state.currentPage } } previousScreenWidth = screenWidth + previousScreenHeight = screenHeight } var isInitialLayout by remember { mutableStateOf(true) } @@ -354,20 +354,50 @@ internal fun PdfVerticalReader( LaunchedEffect(layoutState.pages) { if (!isInitialLayout) { - val targetPageIdx = state.currentPage + val targetPageIdx = if (targetPageDuringResize.intValue != -1) { + targetPageDuringResize.intValue + } else { + state.currentPage + } + val newLayout = layoutState.pages val pageLayout = newLayout.getOrNull(targetPageIdx) if (pageLayout != null) { val currentZoom = zoomAnimatable.value - val targetPanY = headerHeightPx - (pageLayout.y * currentZoom) - val zoomedDocHeight = layoutState.totalHeight * currentZoom + val isFit = currentZoom <= 1.1f + val targetZoom = if (isFit) fitZoom else currentZoom + + val targetPanY = headerHeightPx - (pageLayout.y * targetZoom) + val zoomedDocHeight = layoutState.totalHeight * targetZoom val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx) val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx) - Timber.tag("PdfZoomDiagnostics").i("Layout changed (Orientation/Size). Snapping to Page $targetPageIdx at PanY: $finalPanY") - panYAnimatable.snapTo(finalPanY) + val targetPanX = if (isFit) { + if ((screenWidth * targetZoom) < screenWidth) { + (screenWidth - (screenWidth * targetZoom)) / 2f + } else 0f + } else { + panXAnimatable.value + } + + panXAnimatable.updateBounds(null, null) + panYAnimatable.updateBounds(null, null) + + coroutineScope { + launch { zoomAnimatable.snapTo(targetZoom) } + launch { panXAnimatable.snapTo(targetPanX) } + launch { panYAnimatable.snapTo(finalPanY) } + } + + panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx) + state.currentPage = targetPageIdx + if (isFit) onZoomChange(targetZoom) } + + delay(50) + isResizing = false + targetPageDuringResize.intValue = -1 } isInitialLayout = false } @@ -410,9 +440,9 @@ internal fun PdfVerticalReader( var isDragging by remember { mutableStateOf(false) } LaunchedEffect( - totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging + totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing ) { - if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning || isInteracting || isFlinging) { + if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning || isInteracting || isFlinging || isResizing) { return@LaunchedEffect } @@ -641,9 +671,10 @@ internal fun PdfVerticalReader( selectedTool, zoomAnimatable.value, isInteracting, - isFlinging + isFlinging, + isResizing ) { - if (isInteracting || isFlinging) return@LaunchedEffect + if (isInteracting || isFlinging || isResizing) return@LaunchedEffect val currentZoom = zoomAnimatable.value val zoomedDocHeight = totalDocHeight * currentZoom @@ -1255,11 +1286,11 @@ internal fun PdfVerticalReader( } } - LaunchedEffect(visiblePages, screenHeight) { + LaunchedEffect(visiblePages, screenHeight, isResizing) { snapshotFlow { Pair(panYAnimatable.value, zoomAnimatable.value) }.collectLatest { (panY, zoom) -> - if (visiblePages.isNotEmpty()) { + if (!isResizing && visiblePages.isNotEmpty()) { state.firstVisiblePage = visiblePages.first().index state.lastVisiblePage = visiblePages.last().index 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 28b3928..5dbea84 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -47,6 +47,7 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.widthIn import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility @@ -97,6 +98,7 @@ 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.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -111,10 +113,12 @@ 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.Add import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Fullscreen @@ -1078,6 +1082,35 @@ private fun getFastFileId(context: Context, uri: Uri): String { return result } +private data class DocumentCacheItem( + val doc: ReaderDocument, + val pfd: ParcelFileDescriptor, + val totalPages: Int, + val pageAspectRatios: List, + val flatTableOfContents: List +) + +private class DocumentCache(val maxSize: Int = 3) { + val cache = object : android.util.LruCache(maxSize) { + override fun entryRemoved( + evicted: Boolean, + key: String, + oldValue: DocumentCacheItem, + newValue: DocumentCacheItem? + ) { + if (evicted) { + CoroutineScope(Dispatchers.IO).launch { + try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) } + try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) } + } + } + } + } + fun put(key: String, item: DocumentCacheItem) { cache.put(key, item) } + fun get(key: String): DocumentCacheItem? = cache.get(key) + fun evictAll() { cache.evictAll() } +} + @Suppress("KotlinConstantConditions") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt") @ExperimentalMaterial3Api @@ -1110,6 +1143,8 @@ fun PdfViewerScreen( var showThemePanel by remember { mutableStateOf(false) } var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } + val documentCache = remember { DocumentCache(3) } + val tabStateMap = remember { mutableStateMapOf() } val activeTheme = remember(currentThemeId, customThemes) { PdfBuiltInThemes.find { it.id == currentThemeId } @@ -1149,30 +1184,39 @@ fun PdfViewerScreen( var isBackgroundIndexing by remember { mutableStateOf(false) } var backgroundIndexingProgress by remember { mutableFloatStateOf(0f) } - var currentBookId by remember { mutableStateOf(null) } - val bookId = currentBookId ?: pdfUri.toString().hashCode().toString() - - LaunchedEffect(bookId) { - isScrollLocked = loadPdfScrollLocked(context, bookId) - isFullScreen = loadPdfFullScreen(context, bookId) - } - val uiState by viewModel.uiState.collectAsState() + val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri + val effectiveFileType = uiState.selectedFileType ?: FileType.PDF + + var showNewTabSheet by remember { mutableStateOf(false) } + val sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = false) + + val isTabsEnabled = uiState.isTabsEnabled + val openTabs = uiState.openTabs + val activeTabBookId = uiState.activeTabBookId + val originalFileName by remember(uiState.recentFiles, effectivePdfUri) { + derivedStateOf { + uiState.recentFiles.find { it.uriString == effectivePdfUri.toString() }?.displayName + ?: effectivePdfUri.lastPathSegment ?: "Document.pdf" + } + } + var currentBookId by remember { mutableStateOf(null) } + val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString() + val view = LocalView.current + var isDockDragging by remember { mutableStateOf(false) } + var initialScrollDone by remember { mutableStateOf(false) } + val reflowBookId = remember(bookId) { "${bookId}_reflow" } val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) { derivedStateOf { uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted } } } - val originalFileName by remember(uiState.recentFiles, pdfUri) { - derivedStateOf { - uiState.recentFiles.find { it.uriString == pdfUri.toString() }?.displayName - ?: pdfUri.lastPathSegment ?: "Document.pdf" - } + + LaunchedEffect(bookId) { + isScrollLocked = loadPdfScrollLocked(context, bookId) + isFullScreen = loadPdfFullScreen(context, bookId) } - val view = LocalView.current - var isDockDragging by remember { mutableStateOf(false) } - var initialScrollDone by remember { mutableStateOf(false) } var isAutoScrollModeActive by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) } @@ -1254,7 +1298,7 @@ fun PdfViewerScreen( Timber.tag("PdfPrint").d("Starting print job: $jobName") printManager.print( jobName, - PdfPrintDocumentAdapter(context, pdfUri, originalFileName), + PdfPrintDocumentAdapter(context, effectivePdfUri, originalFileName), null ) } catch (e: Exception) { @@ -1471,6 +1515,8 @@ fun PdfViewerScreen( mutableStateMapOf>() } + var lastEraserPoint by remember { mutableStateOf(null) } + var areAnnotationsLoaded by remember { mutableStateOf(false) } val richTextRepository = remember(context) { PdfRichTextRepository(context) } @@ -1513,6 +1559,7 @@ fun PdfViewerScreen( if (initialScrollDone) { Timber.tag("PdfPositionDebug").v("UI: Tracking | currentPage: $currentPage | pendingRestorePage updated") pendingRestorePage = currentPage + currentBookId?.let { tabStateMap[it] = currentPage } } } } @@ -1822,7 +1869,7 @@ fun PdfViewerScreen( val onInsertPage: () -> Unit = { coroutineScope.launch { - val targetIndex = currentPage + 1 + val targetIndex = (currentPage + 1).coerceIn(0, virtualPages.size) Timber.tag("RichTextMigration").i("INSERT: User requested blank page at index $targetIndex") val (refWidth, refHeight) = withContext(Dispatchers.IO) { @@ -2195,13 +2242,15 @@ fun PdfViewerScreen( } } - LaunchedEffect(isDocumentReady, totalDisplayPages, displayMode) { + LaunchedEffect(isDocumentReady, totalDisplayPages, displayMode, currentBookId) { if (isDocumentReady && !initialScrollDone) { val pageCount = totalDisplayPages if (pageCount <= 0) return@LaunchedEffect val targetPage = pendingRestorePage?.coerceIn(0, pageCount - 1) ?: 0 - Timber.tag("PdfPositionDebug").i("UI: Restoration Start | Target: $targetPage | Mode: $displayMode | Total: $pageCount") + Timber.tag("PdfPositionDebug").i("UI: Restoration Start | Target: $targetPage | Mode: $displayMode | Total: $pageCount | BookId: $currentBookId") + + delay(100) try { when (displayMode) { @@ -2209,21 +2258,35 @@ fun PdfViewerScreen( if (pagerState.currentPage != targetPage) { pagerState.scrollToPage(targetPage) } - initialScrollDone = true } DisplayMode.VERTICAL_SCROLL -> { - while (verticalReaderState.snapToPageHandler == null) { + var attempts = 0 + while (verticalReaderState.snapToPageHandler == null && attempts < 100) { delay(16) + attempts++ + } + if (verticalReaderState.snapToPageHandler != null) { + Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage)") + verticalReaderState.snapToPage(targetPage) + + var waitAttempts = 0 + while (verticalReaderState.currentPage != targetPage && waitAttempts < 20) { + delay(16) + waitAttempts++ + } + } else { + Timber.tag("PdfPositionDebug").w("UI: snapToPageHandler is null after timeout") } - Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage)") - verticalReaderState.snapToPage(targetPage) - initialScrollDone = true } } - Timber.tag("PdfPositionDebug").i("UI: Restoration Complete | Now at Page: $currentPage") + + delay(50) + initialScrollDone = true + Timber.tag("PdfPositionDebug").i("UI: Restoration Complete | Now at Page: $currentPage | initialScrollDone: $initialScrollDone") } catch (e: Exception) { - if (e is CancellationException) { + if (e is CancellationException || e.javaClass.name.contains("CancellationException")) { Timber.tag("PdfPositionDebug").w("UI: Restoration cancelled (likely new recomposition)") + throw e } else { Timber.tag("PdfPositionDebug").e(e, "UI: Restoration error.") initialScrollDone = true @@ -2433,7 +2496,7 @@ fun PdfViewerScreen( } viewModel.savePdfWithAnnotations( - sourceUri = pdfUri, + sourceUri = effectivePdfUri, destUri = uri, annotations = allAnnotations, richTextPageLayouts = currentRichTextLayouts, @@ -2446,7 +2509,7 @@ fun PdfViewerScreen( } SaveMode.ORIGINAL -> { - viewModel.saveOriginalPdf(pdfUri, uri) + viewModel.saveOriginalPdf(effectivePdfUri, uri) } else -> {} @@ -2773,6 +2836,7 @@ fun PdfViewerScreen( fun isAnnotationHit( annotation: PdfAnnotation, hitPoint: PdfPoint, + lastHitPoint: PdfPoint?, pageAspectRatio: Float, threshold: Float ): Boolean { @@ -2781,11 +2845,37 @@ fun PdfViewerScreen( val effectiveThreshold = threshold + (annotation.strokeWidth / 2f) val thresholdSq = effectiveThreshold * effectiveThreshold + fun distSqToEraser(px: Float, pyScaled: Float): Float { + val e1x = hitPoint.x + val e1yScaled = hitPoint.y / pageAspectRatio + if (lastHitPoint == null) { + val dx = px - e1x + val dy = pyScaled - e1yScaled + return dx * dx + dy * dy + } + val e0x = lastHitPoint.x + val e0yScaled = lastHitPoint.y / pageAspectRatio + + val ex = e1x - e0x + val ey = e1yScaled - e0yScaled + val segLenSq = (ex * ex + ey * ey) + if (segLenSq < 1e-8f) { + val dx = px - e1x + val dy = pyScaled - e1yScaled + return dx * dx + dy * dy + } + val t = ((px - e0x) * ex + (pyScaled - e0yScaled) * ey) / segLenSq + val tClamped = t.coerceIn(0f, 1f) + val closestX = e0x + ex * tClamped + val closestY = e0yScaled + ey * tClamped + val dx = px - closestX + val dy = pyScaled - closestY + return dx * dx + dy * dy + } + if (annotation.points.size == 1) { val p = annotation.points[0] - val dx = (p.x - hitPoint.x) - val dy = (p.y - hitPoint.y) / pageAspectRatio - return (dx * dx + dy * dy) < thresholdSq + return distSqToEraser(p.x, p.y / pageAspectRatio) < thresholdSq } for (i in 0 until annotation.points.size - 1) { @@ -2807,6 +2897,11 @@ fun PdfViewerScreen( val distSq = (pax - closestX) * (pax - closestX) + (pay - closestY) * (pay - closestY) if (distSq < thresholdSq) return true + + if (lastHitPoint != null) { + if (distSqToEraser(a.x, a.y / pageAspectRatio) < thresholdSq) return true + if (distSqToEraser(b.x, b.y / pageAspectRatio) < thresholdSq) return true + } } return false @@ -2878,7 +2973,7 @@ fun PdfViewerScreen( val chunks = splitTextIntoChunks(textToChunk) val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() } - ?: pdfUri.lastPathSegment ?: "Document" + ?: effectivePdfUri.lastPathSegment ?: "Document" val pageTitle = "Page ${pageToRead + 1}" val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) } @@ -3040,8 +3135,13 @@ fun PdfViewerScreen( } } - LaunchedEffect(pdfUri, pdfiumCore, documentPassword) { - Timber.d("LaunchedEffect: Loading PDF document for URI: $pdfUri") + LaunchedEffect(effectivePdfUri, pdfiumCore, documentPassword) { + Timber.tag("PdfTabSync").i("UI: LaunchedEffect triggered by URI change: $effectivePdfUri") + + Timber.tag("PdfTabSync").d("UI: Loading State -> activeTabBookId: ${uiState.activeTabBookId}, isLoading: $isLoadingDocument") + + bookmarks = loadPdfBookmarksFromJson(uiState.initialBookmarksJson ?: initialBookmarksJson) + isLoadingDocument = true isDocumentReady = false errorMessage = null @@ -3052,7 +3152,7 @@ fun PdfViewerScreen( ocrUsedForCurrentPageTts = false flatTableOfContents = emptyList() - val fastId = getFastFileId(context, pdfUri) + val fastId = getFastFileId(context, effectivePdfUri) val selectedId = uiState.selectedBookId if (selectedId != null && selectedId != fastId) { @@ -3063,43 +3163,47 @@ fun PdfViewerScreen( currentBookId = fastId } - val oldDoc = pdfDocument - val oldPfd = pfdState + val cachedItem = documentCache.get(currentBookId!!) + if (cachedItem != null) { + Timber.tag("PdfTabSync").i("UI: Restoring from cache for $currentBookId") + pdfDocument = cachedItem.doc + pfdState = cachedItem.pfd + totalPages = cachedItem.totalPages + pageAspectRatios = cachedItem.pageAspectRatios + flatTableOfContents = cachedItem.flatTableOfContents + + val mapPage = tabStateMap[currentBookId!!] + val uiPage = uiState.initialPageInBook + Timber.tag("PdfTabSync").d("UI: Restoring position | tabStateMap=$mapPage, uiState=$uiPage, initialPage=$initialPage") + + pendingRestorePage = mapPage ?: uiPage ?: initialPage + initialScrollDone = false + isDocumentReady = true + isLoadingDocument = false + return@LaunchedEffect + } + + val mapPageInit = tabStateMap[currentBookId!!] + val uiPageInit = uiState.initialPageInBook + Timber.tag("PdfTabSync").d("UI: Initial position | tabStateMap=$mapPageInit, uiState=$uiPageInit, initialPage=$initialPage") + pendingRestorePage = mapPageInit ?: uiPageInit ?: initialPage + initialScrollDone = false pdfDocument = null pfdState = null totalPages = 0 - if (oldDoc != null || oldPfd != null) { - withContext(Dispatchers.IO) { - oldDoc?.let { - try { - it.close() - } catch (e: Exception) { - Timber.e(e) - } - } - oldPfd?.let { - try { - it.close() - } catch (e: Exception) { - Timber.e(e) - } - } - } - } - var currentPfdOpened: ParcelFileDescriptor? = null try { withContext(Dispatchers.IO) { - Timber.d("Opening ParcelFileDescriptor for URI: $pdfUri") + Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri") if (pdfUri.scheme != "opds-pse") { - currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r") + currentPfdOpened = context.contentResolver.openFileDescriptor(effectivePdfUri, "r") if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor") } - val doc = DocumentFactory.loadDocument(context, pdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore) + val doc = DocumentFactory.loadDocument(context, effectivePdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore) if (!isActive) { doc.close() @@ -3180,6 +3284,17 @@ fun PdfViewerScreen( isDocumentReady = true isLoadingDocument = false + documentCache.put( + currentBookId!!, + DocumentCacheItem( + doc = doc, + pfd = currentPfdOpened!!, + totalPages = pagesCount, + pageAspectRatios = ratios, + flatTableOfContents = flatTableOfContents + ) + ) + withContext(Dispatchers.Main) { showPasswordDialog = false isPasswordError = false @@ -3220,9 +3335,11 @@ fun PdfViewerScreen( isLoadingDocument = false } - Timber.i("PDF document loaded optimistically. Total Pages: $totalPages.") + Timber.tag("PdfTabSync").v("UI: Pdfium Document created. Page count: $pagesCount") } } catch (e: Throwable) { + if (e is CancellationException || e.javaClass.name.contains("CancellationException")) throw e + Timber.tag("PdfTabSync").e(e, "UI: Error in load effect for $effectivePdfUri") val errorString = e.toString() val causeString = e.cause?.toString() ?: "" @@ -3297,28 +3414,22 @@ fun PdfViewerScreen( ttsController.stop() PdfBitmapPool.clear() PdfThumbnailCache.clear() + documentCache.evictAll() + val docToClose = pdfDocument val pfdToClose = pfdState pdfDocument = null pfdState = null if (docToClose != null || pfdToClose != null) { - coroutineScope.launch(Dispatchers.IO) { + CoroutineScope(Dispatchers.IO).launch { docToClose?.let { Timber.d("Closing PDF document in onDispose.") - try { - it.close() - } catch (e: Exception) { - Timber.e(e, "Error closing document in onDispose") - } + try { it.close() } catch (e: Exception) { Timber.e(e, "Error closing document") } } pfdToClose?.let { Timber.d("Closing ParcelFileDescriptor in onDispose: $it") - try { - it.close() - } catch (e: Exception) { - Timber.e(e, "Error closing ParcelFileDescriptor in onDispose") - } + try { it.close() } catch (e: Exception) { Timber.e(e, "Error closing ParcelFileDescriptor") } } } } @@ -3329,7 +3440,7 @@ fun PdfViewerScreen( var isOcrScanning by remember { mutableStateOf(false) } - LaunchedEffect(pdfUri, currentBookId, totalPages) { + LaunchedEffect(effectivePdfUri, currentBookId, totalPages) { if (currentBookId == null || totalPages == 0) return@LaunchedEffect if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect @@ -3359,7 +3470,7 @@ fun PdfViewerScreen( "Indexer: Starting background indexing for ${totalPages - existingPages.size} pages." ) - bgPfd = context.contentResolver.openFileDescriptor(pdfUri, "r") + bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r") if (bgPfd != null) { bgDoc = pdfiumCore.newDocument(bgPfd, documentPassword) @@ -4223,6 +4334,13 @@ fun PdfViewerScreen( val currentSelectedTool by rememberUpdatedState(selectedTool) + val currentStrokeColorState by rememberUpdatedState( + currentStrokeColor + ) + val currentStrokeWidthState by rememberUpdatedState( + currentStrokeWidth + ) + @Suppress("ControlFlowWithEmptyBody") val onDrawPagination = remember(pageIndex) { { point: PdfPoint -> @@ -4231,8 +4349,9 @@ fun PdfViewerScreen( val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, aspectRatio, activeToolThickness) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) } + lastEraserPoint = point if (toRemove.isNotEmpty()) { val batch = erasedAnnotationsFromStroke.getOrPut( @@ -4259,13 +4378,6 @@ fun PdfViewerScreen( } } - val currentStrokeColorState by rememberUpdatedState( - currentStrokeColor - ) - val currentStrokeWidthState by rememberUpdatedState( - currentStrokeWidth - ) - @Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination = remember(pageIndex) { { point: PdfPoint -> @@ -4274,11 +4386,12 @@ fun PdfViewerScreen( } else { if (currentSelectedTool == InkType.TEXT) { } else if (currentSelectedTool == InkType.ERASER) { + lastEraserPoint = point erasedAnnotationsFromStroke.clear() val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, aspectRatio, activeToolThickness) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) } if (toRemove.isNotEmpty()) { val batch = @@ -4617,12 +4730,13 @@ fun PdfViewerScreen( } else { if (currentSelectedTool == InkType.TEXT) { } else if (currentSelectedTool == InkType.ERASER) { + lastEraserPoint = point erasedAnnotationsFromStroke.clear() val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, aspectRatio, activeToolThickness) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) } if (toRemove.isNotEmpty()) { val batch = @@ -4660,8 +4774,9 @@ fun PdfViewerScreen( val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, aspectRatio, activeToolThickness) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) } + lastEraserPoint = point if (toRemove.isNotEmpty()) { val batch = erasedAnnotationsFromStroke.getOrPut( @@ -5229,413 +5344,494 @@ fun PdfViewerScreen( modifier = Modifier.align(Alignment.TopCenter) ) { Surface( - modifier = Modifier - .fillMaxWidth() - .height(56.dp), + modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (searchState.isSearchActive) { - SearchTopBar( - searchState = searchState, - focusRequester = focusRequester, - onCloseSearch = { - searchState.isSearchActive = false - searchState.onQueryChange("") - keyboardController?.hide() - focusManager.clearFocus() - }) - } else { - TooltipIconButton( - text = stringResource(R.string.tooltip_back), - description = stringResource(R.string.tooltip_back_desc), - onClick = { saveStateAndExit() } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - val currentPageForDisplay = - if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage - } else { - verticalReaderState.currentPage - } - val titleText = when { - isLoadingDocument -> "Loading PDF..." - errorMessage != null -> "Error loading PDF" - totalPages > 0 && pagerState.pageCount > 0 -> "Page ${currentPageForDisplay + 1} of $totalPages" - totalPages > 0 && pagerState.pageCount == 0 -> "Loading page..." - else -> "PDF Viewer" - } - Text( - text = titleText, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(start = 12.dp) - .weight(1f) - .testTag("PageNumberIndicator") - ) - - TooltipIconButton( - text = "Theme", - description = "Theme Settings", - onClick = { showThemePanel = true } - ) { - Icon( - painter = painterResource(id = R.drawable.palette), - contentDescription = "Theme Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - TooltipIconButton( - text = if (isScrollLocked) - stringResource(R.string.tooltip_unlock_pan) - else - stringResource(R.string.tooltip_lock_pan), - description = if (isScrollLocked) - stringResource(R.string.tooltip_unlock_pan_desc) - else - stringResource(R.string.tooltip_lock_pan_desc), - onClick = { - isScrollLocked = !isScrollLocked - savePdfScrollLocked(context, bookId, isScrollLocked) - } - ) { - Icon( - imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, - contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - TooltipIconButton( - text = stringResource(R.string.tooltip_fullscreen), - description = stringResource(R.string.tooltip_fullscreen_desc), - onClick = { - isFullScreen = true - savePdfFullScreen(context, bookId, true) - } - ) { - Icon( - imageVector = Icons.Default.Fullscreen, - contentDescription = "Enter Full Screen", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - TooltipIconButton( - text = stringResource(R.string.tooltip_dictionary), - description = stringResource(R.string.tooltip_dictionary_desc), - onClick = { showDictionarySettingsSheet = true } - ) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = "Dictionary Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (BuildConfig.DEBUG) { - TooltipIconButton(text = "Pen Playground", onClick = { showPenPlayground = true }) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = "Open Pen Playground", - tint = MaterialTheme.colorScheme.primary - ) - } - - TooltipIconButton(text = "Import SVG", onClick = { - val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage - - coroutineScope.launch(Dispatchers.IO) { - val svgAnnotations = SvgToAnnotationConverter.importSvgFromAssets( - context = context, - fileName = "demo_art.svg", - pageIndex = page - ) - - withContext(Dispatchers.Main) { - if (svgAnnotations.isNotEmpty()) { - val existing = allAnnotations[page] ?: emptyList() - allAnnotations = allAnnotations + (page to (existing + svgAnnotations)) - - svgAnnotations.forEach { annot -> - undoStack.add(HistoryAction.Add(page, annot)) - } - redoStack.clear() - - snackbarHostState.showSnackbar("Imported ${svgAnnotations.size} SVG strokes!") - } else { - snackbarHostState.showSnackbar("Failed to import SVG or empty.") - } - } - } - }) { - Icon( - imageVector = Icons.Default.Brush, - contentDescription = "Import SVG", - tint = Color(0xFFE91E63) - ) - } - } - - Box { - var showMoreMenu by remember { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().height(56.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (searchState.isSearchActive) { + SearchTopBar( + searchState = searchState, + focusRequester = focusRequester, + onCloseSearch = { + searchState.isSearchActive = false + searchState.onQueryChange("") + keyboardController?.hide() + focusManager.clearFocus() + }) + } else { TooltipIconButton( - text = stringResource(R.string.tooltip_more_options), - description = stringResource(R.string.tooltip_more_options_desc), - onClick = { showMoreMenu = true } - ) { + text = stringResource(R.string.tooltip_back), + description = stringResource(R.string.tooltip_back_desc), + onClick = { saveStateAndExit() }) { Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "More Options" + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + val currentPageForDisplay = + if (displayMode == DisplayMode.PAGINATION) { + pagerState.currentPage + } else { + verticalReaderState.currentPage + } + val titleText = when { + isLoadingDocument -> "Loading PDF..." + errorMessage != null -> "Error loading PDF" + totalPages > 0 && pagerState.pageCount > 0 -> "Page ${currentPageForDisplay + 1} of $totalPages" + totalPages > 0 && pagerState.pageCount == 0 -> "Loading page..." + else -> "PDF Viewer" + } + Text( + text = titleText, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp).weight(1f) + .testTag("PageNumberIndicator") + ) + + TooltipIconButton( + text = "Theme", + description = "Theme Settings", + onClick = { showThemePanel = true }) { + Icon( + painter = painterResource(id = R.drawable.palette), + contentDescription = "Theme Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - // Main "More" menu - DropdownMenu( - expanded = showMoreMenu, - onDismissRequest = { showMoreMenu = false }) { - if (BuildConfig.IS_PRO) { - DropdownMenuItem( - text = { Text("OCR Language") }, - onClick = { - showMoreMenu = false - hasSelectedOcrLanguage = true - showOcrLanguageDialog = true - } + TooltipIconButton( + text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) + else stringResource(R.string.tooltip_lock_pan), + description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) + else stringResource(R.string.tooltip_lock_pan_desc), + onClick = { + isScrollLocked = !isScrollLocked + savePdfScrollLocked(context, bookId, isScrollLocked) + }) { + Icon( + imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + TooltipIconButton( + text = stringResource(R.string.tooltip_fullscreen), + description = stringResource(R.string.tooltip_fullscreen_desc), + onClick = { + isFullScreen = true + savePdfFullScreen(context, bookId, true) + }) { + Icon( + imageVector = Icons.Default.Fullscreen, + contentDescription = "Enter Full Screen", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + TooltipIconButton( + text = stringResource(R.string.tooltip_dictionary), + description = stringResource(R.string.tooltip_dictionary_desc), + onClick = { showDictionarySettingsSheet = true }) { + Icon( + painter = painterResource(id = R.drawable.dictionary), + contentDescription = "Dictionary Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (BuildConfig.DEBUG) { + TooltipIconButton( + text = "Pen Playground", + onClick = { showPenPlayground = true }) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Open Pen Playground", + tint = MaterialTheme.colorScheme.primary ) - HorizontalDivider() } - DropdownMenuItem( - text = { Text("Reading Mode: Vertical scroll") }, - enabled = !isTtsSessionActive, - onClick = { - displayMode = DisplayMode.VERTICAL_SCROLL - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.VERTICAL_SCROLL) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected" - ) - } - }) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Reading Mode: Paginated") }, - enabled = !isTtsSessionActive, - onClick = { - displayMode = DisplayMode.PAGINATION - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.PAGINATION) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected" - ) - } - }) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Keep Screen On") }, - onClick = { - isKeepScreenOn = !isKeepScreenOn - saveKeepScreenOn(context, isKeepScreenOn) - showMoreMenu = false - }, - trailingIcon = { - if (isKeepScreenOn) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected" - ) - } - } - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Auto Scroll") }, - enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, - onClick = { - showMoreMenu = false - isAutoScrollModeActive = true - isAutoScrollPlaying = true - showBars = !isMusicianMode - } - ) + TooltipIconButton(text = "Import SVG", onClick = { + val page = + if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage - HorizontalDivider() - DropdownMenuItem( - text = { Text("TTS Voice Settings") }, - onClick = { - showMoreMenu = false - showDeviceVoiceSettingsSheet = true - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.GraphicEq, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - } - ) + coroutineScope.launch(Dispatchers.IO) { + val svgAnnotations = + SvgToAnnotationConverter.importSvgFromAssets( + context = context, + fileName = "demo_art.svg", + pageIndex = page + ) + + withContext(Dispatchers.Main) { + if (svgAnnotations.isNotEmpty()) { + val existing = + allAnnotations[page] ?: emptyList() + allAnnotations = + allAnnotations + (page to (existing + svgAnnotations)) + + svgAnnotations.forEach { annot -> + undoStack.add( + HistoryAction.Add( + page, + annot + ) + ) + } + redoStack.clear() + + snackbarHostState.showSnackbar("Imported ${svgAnnotations.size} SVG strokes!") + } else { + snackbarHostState.showSnackbar("Failed to import SVG or empty.") + } + } + } + }) { + Icon( + imageVector = Icons.Default.Brush, + contentDescription = "Import SVG", + tint = Color(0xFFE91E63) + ) + } + } + + Box { + var showMoreMenu by remember { mutableStateOf(false) } + TooltipIconButton( + text = stringResource(R.string.tooltip_more_options), + description = stringResource(R.string.tooltip_more_options_desc), + onClick = { showMoreMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More Options" + ) + } + + // Main "More" menu + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false }) { + if (BuildConfig.IS_PRO) { + DropdownMenuItem( + text = { Text("OCR Language") }, + onClick = { + showMoreMenu = false + hasSelectedOcrLanguage = true + showOcrLanguageDialog = true + }) + HorizontalDivider() + } - if (BuildConfig.DEBUG) { DropdownMenuItem( - text = { Text("TTS Settings (Debug)") }, + text = { Text("Reading Mode: Vertical scroll") }, + enabled = !isTtsSessionActive, + onClick = { + displayMode = DisplayMode.VERTICAL_SCROLL + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.VERTICAL_SCROLL) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected" + ) + } + }) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Reading Mode: Paginated") }, + enabled = !isTtsSessionActive, + onClick = { + displayMode = DisplayMode.PAGINATION + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.PAGINATION) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected" + ) + } + }) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Keep Screen On") }, + onClick = { + isKeepScreenOn = !isKeepScreenOn + saveKeepScreenOn(context, isKeepScreenOn) + showMoreMenu = false + }, + trailingIcon = { + if (isKeepScreenOn) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected" + ) + } + }) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Auto Scroll") }, + enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, onClick = { showMoreMenu = false - showTtsSettingsSheet = true + isAutoScrollModeActive = true + isAutoScrollPlaying = true + showBars = !isMusicianMode + }) + + HorizontalDivider() + DropdownMenuItem( + text = { Text("TTS Voice Settings") }, + onClick = { + showMoreMenu = false + showDeviceVoiceSettingsSheet = true }, leadingIcon = { - Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp)) - } - ) - } + Icon( + imageVector = Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }) - HorizontalDivider() - DropdownMenuItem(text = { - Text( - if (isBookmarked) "Remove bookmark" - else "Bookmark this page" - ) - }, onClick = { - showMoreMenu = false - onBookmarkClick() - }) - HorizontalDivider() + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("TTS Settings (Debug)") }, + onClick = { + showMoreMenu = false + showTtsSettingsSheet = true + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.text_to_speech), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }) + } - DropdownMenuItem( - text = { Text("Insert Blank Page") }, - onClick = { + HorizontalDivider() + DropdownMenuItem(text = { + Text( + if (isBookmarked) "Remove bookmark" + else "Bookmark this page" + ) + }, onClick = { showMoreMenu = false - onInsertPage() + onBookmarkClick() }) + HorizontalDivider() - val canDelete = - virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage - if (canDelete) { DropdownMenuItem( - text = { Text("Delete Page") }, + text = { Text("Insert Blank Page") }, onClick = { showMoreMenu = false - onDeletePage() + onInsertPage() + }) + + val canDelete = + virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage + if (canDelete) { + DropdownMenuItem( + text = { Text("Delete Page") }, + onClick = { + showMoreMenu = false + onDeletePage() + }, + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error + ) + ) + } + + HorizontalDivider() + + DropdownMenuItem( + text = { + Text( + when { + isReflowingThisBook -> "Generating... ${(reflowProgressValue * 100).toInt()}%" + hasReflowFile -> "Open Text View" + else -> "Generate Text View" + } + ) }, - colors = MenuDefaults.itemColors( - textColor = MaterialTheme.colorScheme.error - ) - ) - } + enabled = pdfDocument != null && !isReflowingThisBook, + onClick = { + showMoreMenu = false - HorizontalDivider() + coroutineScope.launch { + if (richTextController != null) { + withContext(NonCancellable) { richTextController.saveImmediate() } + } + saveAllData(true).join() - DropdownMenuItem( - text = { - Text( - when { - isReflowingThisBook -> "Generating... ${(reflowProgressValue * 100).toInt()}%" - hasReflowFile -> "Open Text View" - else -> "Generate Text View" - } - ) - }, - enabled = pdfDocument != null && !isReflowingThisBook, - onClick = { - showMoreMenu = false + val resolvedPage = + if (!initialScrollDone && currentPage == 0) { + pendingRestorePage ?: 0 + } else { + currentPage + } - coroutineScope.launch { - if (richTextController != null) { - withContext(NonCancellable) { richTextController.saveImmediate() } - } - saveAllData(true).join() - - val resolvedPage = if (!initialScrollDone && currentPage == 0) { - pendingRestorePage ?: 0 - } else { - currentPage - } - - if (hasReflowFile) { - val item = uiState.allRecentFiles.find { it.bookId == reflowBookId } - if (item != null) { - viewModel.switchToFileSeamlessly(item, resolvedPage) + if (hasReflowFile) { + val item = + uiState.allRecentFiles.find { it.bookId == reflowBookId } + if (item != null) { + viewModel.switchToFileSeamlessly( + item, + resolvedPage + ) + } else { + viewModel.generateAndImportReflowFile( + pdfBookId = bookId, + pdfUri = effectivePdfUri, + originalTitle = originalFileName, + autoOpenPage = resolvedPage + ) + } } else { viewModel.generateAndImportReflowFile( pdfBookId = bookId, - pdfUri = pdfUri, + pdfUri = effectivePdfUri, originalTitle = originalFileName, autoOpenPage = resolvedPage ) } - } else { - viewModel.generateAndImportReflowFile( - pdfBookId = bookId, - pdfUri = pdfUri, - originalTitle = originalFileName, - autoOpenPage = resolvedPage - ) } - } - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.format_size), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - } - ) - - HorizontalDivider() - - DropdownMenuItem(text = { Text("Share") }, onClick = { - showMoreMenu = false - showShareDialog = true - }, leadingIcon = { - Icon( - Icons.Default.Share, contentDescription = null - ) - }) - if (uiState.selectedFileType == FileType.PDF) { - DropdownMenuItem( - text = { Text("Save copy to device") }, - onClick = { - showMoreMenu = false - showSaveDialog = true }, leadingIcon = { Icon( - Icons.Default.Save, - contentDescription = null + painter = painterResource(id = R.drawable.format_size), + contentDescription = null, + modifier = Modifier.size(20.dp) ) }) - } - if (uiState.selectedFileType == FileType.PDF) { - DropdownMenuItem(text = { Text("Print") }, onClick = { + + HorizontalDivider() + + DropdownMenuItem(text = { Text("Share") }, onClick = { showMoreMenu = false - onPrintDocument() + showShareDialog = true }, leadingIcon = { Icon( - painter = painterResource(id = R.drawable.print), - contentDescription = null, - modifier = Modifier.size(20.dp) + Icons.Default.Share, contentDescription = null ) }) + if (uiState.selectedFileType == FileType.PDF) { + DropdownMenuItem( + text = { Text("Save copy to device") }, + onClick = { + showMoreMenu = false + showSaveDialog = true + }, + leadingIcon = { + Icon( + Icons.Default.Save, + contentDescription = null + ) + }) + } + if (uiState.selectedFileType == FileType.PDF) { + DropdownMenuItem( + text = { Text("Print") }, + onClick = { + showMoreMenu = false + onPrintDocument() + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.print), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }) + } + } + } + } + } + if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + verticalAlignment = Alignment.Bottom + ) { + items(openTabs, key = { it.bookId }) { tab -> + val isSelected = tab.bookId == activeTabBookId + val bgColor = if (isSelected) MaterialTheme.colorScheme.surface else Color.Transparent + val contentColor = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + + Row( + modifier = Modifier + .height(if (isSelected) 44.dp else 36.dp) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .background(bgColor) + .clickable { + Timber.tag("PdfTabSync").i("UI: Tab clicked: ${tab.bookId}") + if (tab.bookId != activeTabBookId) { + coroutineScope.launch { + Timber.tag("PdfTabSync").d("UI: Dispatching switchTab for ${tab.bookId}") + currentBookId?.let { tabStateMap[it] = currentPage } + saveAllData(true).join() + viewModel.switchTab(tab.bookId) + } + } else { + Timber.tag("PdfTabSync").d("UI: Ignored click - Tab already active.") + } + } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Tab Title + Text( + text = tab.customName ?: tab.title ?: tab.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 140.dp), + style = MaterialTheme.typography.labelLarge, + color = contentColor + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + coroutineScope.launch { + if (isSelected) saveAllData(true).join() + viewModel.closeTab(tab.bookId) + if (isSelected && openTabs.size == 1) { + onNavigateBack() + } + } + }, + modifier = Modifier.size(20.dp) + ) { + Icon(Icons.Default.Close, contentDescription = "Close Tab", modifier = Modifier.size(16.dp), tint = contentColor) + } + } + } + + item { + IconButton( + onClick = { showNewTabSheet = true }, + modifier = Modifier + .padding(start = 8.dp, bottom = 4.dp) + .size(36.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Tab", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } @@ -6168,8 +6364,6 @@ fun PdfViewerScreen( val popupPlacementConfig = remember(dockLocation, dockOffset, boxMaxHeightFloat, dockHeightPx) { val margin = 16.dp - with(density) { boxMaxHeightFloat.toDp() } - val dockTopY = when (dockLocation) { DockLocation.TOP -> 0f DockLocation.BOTTOM -> boxMaxHeightFloat - dockHeightPx @@ -6183,10 +6377,10 @@ fun PdfViewerScreen( if (isDockInBottomHalf) { val distFromBottom = boxMaxHeightFloat - dockTopY val paddingBottom = with(density) { distFromBottom.toDp() } + margin - Triple(Alignment.BottomCenter, 0.dp, paddingBottom) + Triple(Alignment.BottomCenter, 0.dp, paddingBottom.coerceAtLeast(0.dp)) } else { val paddingTop = with(density) { dockBottomY.toDp() } + margin - Triple(Alignment.TopCenter, paddingTop, 0.dp) + Triple(Alignment.TopCenter, paddingTop.coerceAtLeast(0.dp), 0.dp) } } @@ -6804,6 +6998,57 @@ fun PdfViewerScreen( onConfirm = { password -> documentPassword = password }) } + if (showNewTabSheet) { + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = { showNewTabSheet = false }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + val pdfFiles = remember(uiState.rawLibraryFiles, openTabs) { + val openIds = openTabs.map { it.bookId } + uiState.rawLibraryFiles + .filter { it.type == FileType.PDF && it.bookId !in openIds } + .sortedByDescending { it.timestamp } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Text( + text = "Add PDF to Tab", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(16.dp) + ) + if (pdfFiles.isEmpty()) { + Text( + "No other PDFs found in your library.", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(pdfFiles, key = { it.bookId }) { file -> + ListItem( + headlineContent = { Text(file.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { file.author?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } }, + modifier = Modifier.clickable { + coroutineScope.launch { + sheetState.hide() + showNewTabSheet = false + viewModel.switchTab(file.bookId) + } + } + ) + HorizontalDivider() + } + } + } + } + } + } + if (showPenPlayground) { Box( modifier = Modifier @@ -7109,7 +7354,7 @@ fun PdfViewerScreen( viewModel.sharePdf( activityContext = context, - sourceUri = pdfUri, + sourceUri = effectivePdfUri, annotations = allAnnotations, richTextPageLayouts = currentRichTextLayouts, textBoxes = textBoxes.toList(), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 24bf6a2..5062c69 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -625,4 +625,19 @@ Error opening dictionary Error opening translate app Error opening search app + + + Episteme + Open Source Version + Playstore Version + Version %1$s + Build %1$s + GitHub + Browse source code, star, fork, and report issues. + Privacy Policy + How we handle your data. + Terms of Service + Usage terms and conditions. + Open source libraries used. + Importing %1$d books… They will appear in your Library shortly.