General improvements (#149)

* Improved eraser hit detection and added horizontal scrolling to AnnotationDock

* fix: lock scroll tracking during PDF rotation to prevent page jumping in vertical mode

* Implemented a multi-tab reading system for the PDF viewer.

* Redesigned the About dialog on homescreen.

* improved bulk file import support

* Implemented document caching and improved tab restoration in the PDF viewer.

* Implemented native link detection and information extraction in `pdfium_bridge.cpp` and integrated it into `PdfPageComposable.kt`. This includes adding `getLinkInfoAtPoint` to `NativePdfiumBridge` to handle URI, GoTo, and RemoteGoTo/Launch actions, and updating the tap gesture logic to prioritize native link handling.

* Fixed index bounds and layout padding calculation in PdfViewerScreen
This commit is contained in:
Aryan 2026-04-05 09:41:26 +05:30 committed by GitHub
parent 381193d774
commit c8f361376f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1236 additions and 601 deletions

View file

@ -39,7 +39,19 @@ typedef void* (*FPDFLink_GetAnnot_t)(void* link);
typedef int (*FPDFAnnot_GetFlags_t)(void* annot); typedef int (*FPDFAnnot_GetFlags_t)(void* annot);
typedef int (*FPDFAnnot_SetFlags_t)(void* annot, int flags); 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 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 std::mutex g_pdfium_mutex;
static FPDFLink_GetAnnot_t get_link_annot_func = nullptr; static FPDFLink_GetAnnot_t get_link_annot_func = nullptr;
static FPDFLink_GetAction_t get_link_action_func = nullptr; static FPDFLink_GetAction_t get_link_action_func = nullptr;
@ -124,6 +136,13 @@ static bool init_pdfium() {
get_link_annot_func = (FPDFLink_GetAnnot_t) dlsym(pdfium_handle, "FPDFLink_GetAnnot"); 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_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 --- // --- Validation & Logging ---
bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func && bool success = get_annot_count_func && get_annot_func && get_annot_subtype_func &&
get_annot_rect_func && get_annot_string_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); int count = get_annot_count_func(page);
void* hitAnnot = nullptr; 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++) { for (int i = 0; i < count; i++) {
void* annot = get_annot_func(page, i); void* annot = get_annot_func(page, i);
if (!annot) continue; 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) { if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
hitAnnot = annot; 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; break;
} }
} }
@ -492,3 +518,91 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
return JNI_FALSE; 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<std::mutex> 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<void*>(pagePtr);
void* wrapperDoc = reinterpret_cast<void*>(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<char> 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<char> 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;
}

View file

@ -221,9 +221,7 @@ fun HomeScreen(
if (isContextualModeActive) { if (isContextualModeActive) {
viewModel.clearContextualAction() viewModel.clearContextualAction()
} }
uris.forEach { uri -> viewModel.onFilesSelected(uris)
viewModel.onFileSelected(uri, isFromRecent = false)
}
} }
val fallbackFilePickerLauncher = rememberLauncherForActivityResult( val fallbackFilePickerLauncher = rememberLauncherForActivityResult(
@ -232,9 +230,7 @@ fun HomeScreen(
if (isContextualModeActive) { if (isContextualModeActive) {
viewModel.clearContextualAction() viewModel.clearContextualAction()
} }
uris.forEach { uri -> viewModel.onFilesSelected(uris)
viewModel.onFileSelected(uri, isFromRecent = false)
}
} }
val onSelectFileClick = { val onSelectFileClick = {
@ -311,7 +307,8 @@ fun HomeScreen(
onShowDeviceManagement = viewModel::showDeviceManagementForDebug, onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled, onFolderSyncToggle = viewModel::setFolderSyncEnabled,
onClearReflowCache = { showClearReflowCacheDialog = true }, onClearReflowCache = { showClearReflowCacheDialog = true },
onRecentFilesLimitChange = viewModel::setRecentFilesLimit onRecentFilesLimitChange = viewModel::setRecentFilesLimit,
onTabsToggle = viewModel::setTabsEnabled
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
@ -769,7 +766,8 @@ fun DefaultTopAppBar(
onAboutClick: () -> Unit, onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit, onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit, onFolderSyncToggle: (Boolean) -> Unit,
onRecentFilesLimitChange: (Int) -> Unit onRecentFilesLimitChange: (Int) -> Unit,
onTabsToggle: (Boolean) -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) }
@ -822,6 +820,17 @@ fun DefaultTopAppBar(
showOptionsMenu = false 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() HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache() onClearCache()

View file

@ -199,9 +199,7 @@ fun LibraryScreen(
if (isContextualModeActive) { if (isContextualModeActive) {
viewModel.clearContextualAction() viewModel.clearContextualAction()
} }
uris.forEach { uri -> viewModel.onFilesSelected(uris)
viewModel.onFileSelected(uri, isFromRecent = false)
}
} }
val fallbackFilePickerLauncher = rememberLauncherForActivityResult( val fallbackFilePickerLauncher = rememberLauncherForActivityResult(
@ -210,9 +208,7 @@ fun LibraryScreen(
if (isContextualModeActive) { if (isContextualModeActive) {
viewModel.clearContextualAction() viewModel.clearContextualAction()
} }
uris.forEach { uri -> viewModel.onFilesSelected(uris)
viewModel.onFileSelected(uri, isFromRecent = false)
}
} }
val onSelectFileClick = { val onSelectFileClick = {

View file

@ -236,6 +236,10 @@ data class ReaderScreenState(
val pinnedLibraryBookIds: Set<String> = emptySet(), val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(), val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0, val recentFilesLimit: Int = 0,
val isTabsEnabled: Boolean = false,
val openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null,
) )
open class MainViewModel(application: Application) : AndroidViewModel(application) { open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -335,7 +339,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
else null, else null,
pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(), pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(),
pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, 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 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 -> val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
baseVisibleFiles.any { dbItem -> dbItem.uriString == contextItem.uriString } baseVisibleFiles.any { dbItem -> dbItem.uriString == contextItem.uriString }
}.toSet() }.toSet()
@ -433,6 +450,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
rawLibraryFiles = baseVisibleFiles, rawLibraryFiles = baseVisibleFiles,
contextualActionItems = validContextualItems, contextualActionItems = validContextualItems,
shelves = allShelves, shelves = allShelves,
openTabs = openTabsList,
booksAvailableForAdding = booksAvailableForAdding booksAvailableForAdding = booksAvailableForAdding
) )
}.stateIn( }.stateIn(
@ -441,6 +459,103 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
initialValue = ReaderScreenState() 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) { fun onSearchQueryChange(newQuery: String) {
_internalState.update { _internalState.update {
if (it.isSearchActive) { if (it.isSearchActive) {
@ -2643,6 +2758,67 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return fileName ?: uri.lastPathSegment return fileName ?: uri.lastPathSegment
} }
fun onFilesSelected(uris: List<Uri>) {
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) { fun onFileSelected(uri: Uri, isFromRecent: Boolean = false) {
if (isFromRecent) { if (isFromRecent) {
Timber.i("Opening recent file: $uri") Timber.i("Opening recent file: $uri")
@ -2904,12 +3080,30 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
private fun openBook( 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() val openBookStartTime = System.currentTimeMillis()
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") .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") { if (uri.scheme != "opds-pse") {
try { try {
if (uri.scheme == "file") { if (uri.scheme == "file") {
@ -2980,6 +3174,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isRecent = true, isRecent = true,
sourceFolderUri = null 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) { } 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 { 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) { when (type) {
FileType.EPUB -> { FileType.EPUB -> {
loadEpub(uri, bookId, customDisplayName = originalDisplayName) 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_HOME = "pinned_home_books"
internal const val KEY_PINNED_LIBRARY = "pinned_library_books" internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit" 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"
} }
} }

View file

@ -21,14 +21,15 @@ package com.aryan.reader.pdf
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -76,6 +77,7 @@ fun AnnotationDock(
onToggleStylusOnlyMode: () -> Unit onToggleStylusOnlyMode: () -> Unit
) { ) {
val showFullDock = isSticky || !isMinimized val showFullDock = isSticky || !isMinimized
val scrollState = rememberScrollState()
val dockHeight = 56.dp val dockHeight = 56.dp
val buttonSize = 36.dp val buttonSize = 36.dp
@ -93,7 +95,9 @@ fun AnnotationDock(
modifier = modifier.height(dockHeight) modifier = modifier.height(dockHeight)
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = horizontalPadding), modifier = Modifier
.padding(horizontal = horizontalPadding)
.horizontalScroll(scrollState),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing) horizontalArrangement = Arrangement.spacedBy(spacing)
) { ) {
@ -235,8 +239,6 @@ fun AnnotationDock(
) )
} }
Spacer(modifier = Modifier.weight(1f))
// Undo // Undo
Box( Box(
modifier = Modifier modifier = Modifier

View file

@ -25,6 +25,7 @@ object NativePdfiumBridge {
@JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? @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 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 getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? @JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?

View file

@ -2427,14 +2427,14 @@ internal fun PdfPageComposable(
val tapYInBitmap = tapInContentCoords.y val tapYInBitmap = tapInContentCoords.y
coroutineScope.launch { coroutineScope.launch {
val wasHandled = withContext(Dispatchers.IO) { val nativeResult = withContext(Dispatchers.IO) {
try { try {
pdfDocumentItem.openPage(pdfPageIndex)?.use { page -> pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
val pagePtr = page.getNativePointer() val pagePtr = page.getNativePointer()
if (pagePtr == 0L) { if (pagePtr == 0L) {
Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex") Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex")
return@withContext false return@withContext 0
} }
val pdfCoords = page.mapDeviceCoordsToPage( val pdfCoords = page.mapDeviceCoordsToPage(
@ -2442,29 +2442,66 @@ internal fun PdfPageComposable(
currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt() currentPageRotation, tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()
) )
NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()) val docPtr = try {
} ?: false val pdfDocKt = (pdfDocumentItem as? PdfDocumentWrapper)?.pdfDocument
} catch (e: Exception) { if (pdfDocKt != null) {
Timber.tag("PdfInteraction").e(e, "Interaction error") val documentField = pdfDocKt.javaClass.getDeclaredField("document").apply { isAccessible = true }
false 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
}
} }
} }
if (wasHandled) { 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")
0
}
}
if (nativeResult == 2) {
Timber.tag("PdfInteraction").i("Action detected. Refreshing page.") Timber.tag("PdfInteraction").i("Action detected. Refreshing page.")
tiles = emptyList() tiles = emptyList()
bitmapState = null bitmapState = null
isLoadingPage = true isLoadingPage = true
currentRenderedPageId = "ACTION_${System.currentTimeMillis()}" currentRenderedPageId = "ACTION_${System.currentTimeMillis()}"
} return@launch
} else if (nativeResult == 1) {
return@launch
} }
val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale val annotHitTolerance = with(density) { 24.dp.toPx() } / inputScale
val hitTolerance = with(density) { 16.dp.toPx() } / inputScale val hitTolerance = with(density) { 16.dp.toPx() } / inputScale
Timber.d( Timber.d("detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})")
"detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})"
)
var tappedRect: Rect? = null var tappedRect: Rect? = null
val hitHighlightPair = userHighlightScreenRects.findLast { pair -> val hitHighlightPair = userHighlightScreenRects.findLast { pair ->
@ -2474,8 +2511,7 @@ internal fun PdfPageComposable(
val hitRight = r.right + hitTolerance val hitRight = r.right + hitTolerance
val hitBottom = r.bottom + hitTolerance val hitBottom = r.bottom + hitTolerance
tapXInBitmap in hitLeft..hitRight && tapXInBitmap in hitLeft..hitRight && tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom
tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom
} }
if (hit != null) { if (hit != null) {
tappedRect = hit tappedRect = hit
@ -2498,9 +2534,7 @@ internal fun PdfPageComposable(
(bottom + annotHitTolerance).toInt() (bottom + annotHitTolerance).toInt()
) )
val isHit = inflatedHitBox.contains(tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt()) inflatedHitBox.contains(tapInContentCoords.x.toInt(), tapInContentCoords.y.toInt())
isHit
} }
if (standardHit != null) { if (standardHit != null) {
@ -2513,12 +2547,11 @@ internal fun PdfPageComposable(
author = annot.author, author = annot.author,
annotation = annot annotation = annot
) )
return@detectTapGestures return@launch
} }
if (hitHighlightPair != null && tappedRect != null) { if (hitHighlightPair != null && tappedRect != null) {
val hitHighlight = hitHighlightPair.first val hitHighlight = hitHighlightPair.first
val combinedRect = Rect(hitHighlightPair.second.first()) val combinedRect = Rect(hitHighlightPair.second.first())
hitHighlightPair.second.forEach { combinedRect.union(it) } hitHighlightPair.second.forEach { combinedRect.union(it) }
@ -2529,47 +2562,30 @@ internal fun PdfPageComposable(
isExistingHighlight = true, isExistingHighlight = true,
highlightId = hitHighlight.id highlightId = hitHighlight.id
) )
return@detectTapGestures return@launch
} }
Timber.d(
"detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})"
)
val clickedLink = pageLinks.firstOrNull { link -> val clickedLink = pageLinks.firstOrNull { link ->
link.tapBounds.contains( link.tapBounds.contains(tapXInBitmap.toInt(), tapYInBitmap.toInt())
tapXInBitmap.toInt(), tapYInBitmap.toInt()
)
} }
if (clickedLink != null) { if (clickedLink != null) {
Timber.d( Timber.d("PdfPageComposable: Fallback pageLinks intercepted click.")
"PdfPageComposable: Link clicked. Ignoring selection logic."
)
if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) { if (clickedLink.destPageIdx != null && clickedLink.destPageIdx >= 0) {
onInternalLinkClicked(clickedLink.destPageIdx) onInternalLinkClicked(clickedLink.destPageIdx)
} else if (clickedLink.url != null) { } else if (clickedLink.url != null) {
onLinkClicked(clickedLink.url) onLinkClicked(clickedLink.url)
} }
return@detectTapGestures return@launch
} }
val wasMenuVisible = customMenuState != null val wasMenuVisible = customMenuState != null
val wasSelectionVisible = val wasSelectionVisible = selectionCharRange.value != null || ocrSelectionSymbolIndices != null
selectionCharRange.value != null || ocrSelectionSymbolIndices != null
Timber.d(
"PdfPageComposable: State check - MenuVisible=$wasMenuVisible, SelectionVisible=$wasSelectionVisible"
)
if (wasMenuVisible || wasSelectionVisible) { if (wasMenuVisible || wasSelectionVisible) {
Timber.d(
"PdfPageComposable: Clearing selection/menu."
)
customMenuState = null customMenuState = null
selectionCharRange.value = null selectionCharRange.value = null
ocrSelectionSymbolIndices = null ocrSelectionSymbolIndices = null
coroutineScope.launch {
updateSelectionVisuals( updateSelectionVisuals(
pdfDocumentItem, pdfDocumentItem,
pdfPageIndex, pdfPageIndex,
@ -2578,13 +2594,10 @@ internal fun PdfPageComposable(
actualBitmapHeightPx, actualBitmapHeightPx,
currentPageRotation, currentPageRotation,
) )
}
} else { } else {
Timber.d(
"PdfPageComposable: No selection active. Calling onSingleTap()."
)
currentOnSingleTap() currentOnSingleTap()
} }
}
}, onDoubleTap = { tapOffset -> }, onDoubleTap = { tapOffset ->
if (isZoomEnabled && !isVerticalScroll) { if (isZoomEnabled && !isVerticalScroll) {
if (actualBitmapWidthPx == 0) return@detectTapGestures if (actualBitmapWidthPx == 0) return@detectTapGestures

View file

@ -333,20 +333,20 @@ internal fun PdfVerticalReader(
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) } val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
val panYAnimatable = remember { Animatable(0f) } val panYAnimatable = remember { Animatable(0f) }
var isResizing by remember { mutableStateOf(false) }
var previousScreenWidth by remember { mutableFloatStateOf(0f) } var previousScreenWidth by remember { mutableFloatStateOf(0f) }
LaunchedEffect(screenWidth, screenHeight) { var previousScreenHeight by remember { mutableFloatStateOf(0f) }
if (previousScreenWidth > 0f && previousScreenWidth != screenWidth) { val targetPageDuringResize = remember { mutableIntStateOf(-1) }
if (zoomAnimatable.value <= 1.1f) {
val centeredX = if ((screenWidth * fitZoom) < screenWidth) {
(screenWidth - (screenWidth * fitZoom)) / 2f
} else 0f
zoomAnimatable.snapTo(fitZoom) if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
panXAnimatable.snapTo(centeredX) if (previousScreenWidth > 0f) {
onZoomChange(fitZoom) isResizing = true
if (targetPageDuringResize.intValue == -1) {
targetPageDuringResize.intValue = state.currentPage
} }
} }
previousScreenWidth = screenWidth previousScreenWidth = screenWidth
previousScreenHeight = screenHeight
} }
var isInitialLayout by remember { mutableStateOf(true) } var isInitialLayout by remember { mutableStateOf(true) }
@ -354,20 +354,50 @@ internal fun PdfVerticalReader(
LaunchedEffect(layoutState.pages) { LaunchedEffect(layoutState.pages) {
if (!isInitialLayout) { if (!isInitialLayout) {
val targetPageIdx = state.currentPage val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
targetPageDuringResize.intValue
} else {
state.currentPage
}
val newLayout = layoutState.pages val newLayout = layoutState.pages
val pageLayout = newLayout.getOrNull(targetPageIdx) val pageLayout = newLayout.getOrNull(targetPageIdx)
if (pageLayout != null) { if (pageLayout != null) {
val currentZoom = zoomAnimatable.value val currentZoom = zoomAnimatable.value
val targetPanY = headerHeightPx - (pageLayout.y * currentZoom) val isFit = currentZoom <= 1.1f
val zoomedDocHeight = layoutState.totalHeight * currentZoom 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 minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx) val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
Timber.tag("PdfZoomDiagnostics").i("Layout changed (Orientation/Size). Snapping to Page $targetPageIdx at PanY: $finalPanY") val targetPanX = if (isFit) {
panYAnimatable.snapTo(finalPanY) 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 isInitialLayout = false
} }
@ -410,9 +440,9 @@ internal fun PdfVerticalReader(
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
LaunchedEffect( 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 return@LaunchedEffect
} }
@ -641,9 +671,10 @@ internal fun PdfVerticalReader(
selectedTool, selectedTool,
zoomAnimatable.value, zoomAnimatable.value,
isInteracting, isInteracting,
isFlinging isFlinging,
isResizing
) { ) {
if (isInteracting || isFlinging) return@LaunchedEffect if (isInteracting || isFlinging || isResizing) return@LaunchedEffect
val currentZoom = zoomAnimatable.value val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom val zoomedDocHeight = totalDocHeight * currentZoom
@ -1255,11 +1286,11 @@ internal fun PdfVerticalReader(
} }
} }
LaunchedEffect(visiblePages, screenHeight) { LaunchedEffect(visiblePages, screenHeight, isResizing) {
snapshotFlow { snapshotFlow {
Pair(panYAnimatable.value, zoomAnimatable.value) Pair(panYAnimatable.value, zoomAnimatable.value)
}.collectLatest { (panY, zoom) -> }.collectLatest { (panY, zoom) ->
if (visiblePages.isNotEmpty()) { if (!isResizing && visiblePages.isNotEmpty()) {
state.firstVisiblePage = visiblePages.first().index state.firstVisiblePage = visiblePages.first().index
state.lastVisiblePage = visiblePages.last().index state.lastVisiblePage = visiblePages.last().index

View file

@ -47,6 +47,7 @@ import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.widthIn
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility 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.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Check 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.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Fullscreen
@ -1078,6 +1082,35 @@ private fun getFastFileId(context: Context, uri: Uri): String {
return result return result
} }
private data class DocumentCacheItem(
val doc: ReaderDocument,
val pfd: ParcelFileDescriptor,
val totalPages: Int,
val pageAspectRatios: List<Float>,
val flatTableOfContents: List<TocEntry>
)
private class DocumentCache(val maxSize: Int = 3) {
val cache = object : android.util.LruCache<String, DocumentCacheItem>(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") @Suppress("KotlinConstantConditions")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt")
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
@ -1110,6 +1143,8 @@ fun PdfViewerScreen(
var showThemePanel by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) }
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
val documentCache = remember { DocumentCache(3) }
val tabStateMap = remember { mutableStateMapOf<String, Int>() }
val activeTheme = remember(currentThemeId, customThemes) { val activeTheme = remember(currentThemeId, customThemes) {
PdfBuiltInThemes.find { it.id == currentThemeId } PdfBuiltInThemes.find { it.id == currentThemeId }
@ -1149,30 +1184,39 @@ fun PdfViewerScreen(
var isBackgroundIndexing by remember { mutableStateOf(false) } var isBackgroundIndexing by remember { mutableStateOf(false) }
var backgroundIndexingProgress by remember { mutableFloatStateOf(0f) } var backgroundIndexingProgress by remember { mutableFloatStateOf(0f) }
var currentBookId by remember { mutableStateOf<String?>(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 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<String?>(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 reflowBookId = remember(bookId) { "${bookId}_reflow" }
val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) { val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) {
derivedStateOf { derivedStateOf {
uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted } uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
} }
} }
val originalFileName by remember(uiState.recentFiles, pdfUri) {
derivedStateOf { LaunchedEffect(bookId) {
uiState.recentFiles.find { it.uriString == pdfUri.toString() }?.displayName isScrollLocked = loadPdfScrollLocked(context, bookId)
?: pdfUri.lastPathSegment ?: "Document.pdf" 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 isAutoScrollModeActive by remember { mutableStateOf(false) }
var isAutoScrollPlaying by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) }
@ -1254,7 +1298,7 @@ fun PdfViewerScreen(
Timber.tag("PdfPrint").d("Starting print job: $jobName") Timber.tag("PdfPrint").d("Starting print job: $jobName")
printManager.print( printManager.print(
jobName, jobName,
PdfPrintDocumentAdapter(context, pdfUri, originalFileName), PdfPrintDocumentAdapter(context, effectivePdfUri, originalFileName),
null null
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -1471,6 +1515,8 @@ fun PdfViewerScreen(
mutableStateMapOf<Int, MutableList<PdfAnnotation>>() mutableStateMapOf<Int, MutableList<PdfAnnotation>>()
} }
var lastEraserPoint by remember { mutableStateOf<PdfPoint?>(null) }
var areAnnotationsLoaded by remember { mutableStateOf(false) } var areAnnotationsLoaded by remember { mutableStateOf(false) }
val richTextRepository = remember(context) { PdfRichTextRepository(context) } val richTextRepository = remember(context) { PdfRichTextRepository(context) }
@ -1513,6 +1559,7 @@ fun PdfViewerScreen(
if (initialScrollDone) { if (initialScrollDone) {
Timber.tag("PdfPositionDebug").v("UI: Tracking | currentPage: $currentPage | pendingRestorePage updated") Timber.tag("PdfPositionDebug").v("UI: Tracking | currentPage: $currentPage | pendingRestorePage updated")
pendingRestorePage = currentPage pendingRestorePage = currentPage
currentBookId?.let { tabStateMap[it] = currentPage }
} }
} }
} }
@ -1822,7 +1869,7 @@ fun PdfViewerScreen(
val onInsertPage: () -> Unit = { val onInsertPage: () -> Unit = {
coroutineScope.launch { 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") Timber.tag("RichTextMigration").i("INSERT: User requested blank page at index $targetIndex")
val (refWidth, refHeight) = withContext(Dispatchers.IO) { val (refWidth, refHeight) = withContext(Dispatchers.IO) {
@ -2195,13 +2242,15 @@ fun PdfViewerScreen(
} }
} }
LaunchedEffect(isDocumentReady, totalDisplayPages, displayMode) { LaunchedEffect(isDocumentReady, totalDisplayPages, displayMode, currentBookId) {
if (isDocumentReady && !initialScrollDone) { if (isDocumentReady && !initialScrollDone) {
val pageCount = totalDisplayPages val pageCount = totalDisplayPages
if (pageCount <= 0) return@LaunchedEffect if (pageCount <= 0) return@LaunchedEffect
val targetPage = pendingRestorePage?.coerceIn(0, pageCount - 1) ?: 0 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 { try {
when (displayMode) { when (displayMode) {
@ -2209,21 +2258,35 @@ fun PdfViewerScreen(
if (pagerState.currentPage != targetPage) { if (pagerState.currentPage != targetPage) {
pagerState.scrollToPage(targetPage) pagerState.scrollToPage(targetPage)
} }
initialScrollDone = true
} }
DisplayMode.VERTICAL_SCROLL -> { DisplayMode.VERTICAL_SCROLL -> {
while (verticalReaderState.snapToPageHandler == null) { var attempts = 0
while (verticalReaderState.snapToPageHandler == null && attempts < 100) {
delay(16) delay(16)
attempts++
} }
if (verticalReaderState.snapToPageHandler != null) {
Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage)") Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage)")
verticalReaderState.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")
}
}
}
delay(50)
initialScrollDone = true initialScrollDone = true
} Timber.tag("PdfPositionDebug").i("UI: Restoration Complete | Now at Page: $currentPage | initialScrollDone: $initialScrollDone")
}
Timber.tag("PdfPositionDebug").i("UI: Restoration Complete | Now at Page: $currentPage")
} catch (e: Exception) { } 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)") Timber.tag("PdfPositionDebug").w("UI: Restoration cancelled (likely new recomposition)")
throw e
} else { } else {
Timber.tag("PdfPositionDebug").e(e, "UI: Restoration error.") Timber.tag("PdfPositionDebug").e(e, "UI: Restoration error.")
initialScrollDone = true initialScrollDone = true
@ -2433,7 +2496,7 @@ fun PdfViewerScreen(
} }
viewModel.savePdfWithAnnotations( viewModel.savePdfWithAnnotations(
sourceUri = pdfUri, sourceUri = effectivePdfUri,
destUri = uri, destUri = uri,
annotations = allAnnotations, annotations = allAnnotations,
richTextPageLayouts = currentRichTextLayouts, richTextPageLayouts = currentRichTextLayouts,
@ -2446,7 +2509,7 @@ fun PdfViewerScreen(
} }
SaveMode.ORIGINAL -> { SaveMode.ORIGINAL -> {
viewModel.saveOriginalPdf(pdfUri, uri) viewModel.saveOriginalPdf(effectivePdfUri, uri)
} }
else -> {} else -> {}
@ -2773,6 +2836,7 @@ fun PdfViewerScreen(
fun isAnnotationHit( fun isAnnotationHit(
annotation: PdfAnnotation, annotation: PdfAnnotation,
hitPoint: PdfPoint, hitPoint: PdfPoint,
lastHitPoint: PdfPoint?,
pageAspectRatio: Float, pageAspectRatio: Float,
threshold: Float threshold: Float
): Boolean { ): Boolean {
@ -2781,11 +2845,37 @@ fun PdfViewerScreen(
val effectiveThreshold = threshold + (annotation.strokeWidth / 2f) val effectiveThreshold = threshold + (annotation.strokeWidth / 2f)
val thresholdSq = effectiveThreshold * effectiveThreshold 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) { if (annotation.points.size == 1) {
val p = annotation.points[0] val p = annotation.points[0]
val dx = (p.x - hitPoint.x) return distSqToEraser(p.x, p.y / pageAspectRatio) < thresholdSq
val dy = (p.y - hitPoint.y) / pageAspectRatio
return (dx * dx + dy * dy) < thresholdSq
} }
for (i in 0 until annotation.points.size - 1) { 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) val distSq = (pax - closestX) * (pax - closestX) + (pay - closestY) * (pay - closestY)
if (distSq < thresholdSq) return true 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 return false
@ -2878,7 +2973,7 @@ fun PdfViewerScreen(
val chunks = splitTextIntoChunks(textToChunk) val chunks = splitTextIntoChunks(textToChunk)
val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() } val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
?: pdfUri.lastPathSegment ?: "Document" ?: effectivePdfUri.lastPathSegment ?: "Document"
val pageTitle = "Page ${pageToRead + 1}" val pageTitle = "Page ${pageToRead + 1}"
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) } val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
@ -3040,8 +3135,13 @@ fun PdfViewerScreen(
} }
} }
LaunchedEffect(pdfUri, pdfiumCore, documentPassword) { LaunchedEffect(effectivePdfUri, pdfiumCore, documentPassword) {
Timber.d("LaunchedEffect: Loading PDF document for URI: $pdfUri") 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 isLoadingDocument = true
isDocumentReady = false isDocumentReady = false
errorMessage = null errorMessage = null
@ -3052,7 +3152,7 @@ fun PdfViewerScreen(
ocrUsedForCurrentPageTts = false ocrUsedForCurrentPageTts = false
flatTableOfContents = emptyList() flatTableOfContents = emptyList()
val fastId = getFastFileId(context, pdfUri) val fastId = getFastFileId(context, effectivePdfUri)
val selectedId = uiState.selectedBookId val selectedId = uiState.selectedBookId
if (selectedId != null && selectedId != fastId) { if (selectedId != null && selectedId != fastId) {
@ -3063,43 +3163,47 @@ fun PdfViewerScreen(
currentBookId = fastId currentBookId = fastId
} }
val oldDoc = pdfDocument val cachedItem = documentCache.get(currentBookId!!)
val oldPfd = pfdState 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 pdfDocument = null
pfdState = null pfdState = null
totalPages = 0 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 var currentPfdOpened: ParcelFileDescriptor? = null
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.d("Opening ParcelFileDescriptor for URI: $pdfUri") Timber.tag("PdfTabSync").v("UI: Opening PFD for $effectivePdfUri")
if (pdfUri.scheme != "opds-pse") { 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") 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) { if (!isActive) {
doc.close() doc.close()
@ -3180,6 +3284,17 @@ fun PdfViewerScreen(
isDocumentReady = true isDocumentReady = true
isLoadingDocument = false isLoadingDocument = false
documentCache.put(
currentBookId!!,
DocumentCacheItem(
doc = doc,
pfd = currentPfdOpened!!,
totalPages = pagesCount,
pageAspectRatios = ratios,
flatTableOfContents = flatTableOfContents
)
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
showPasswordDialog = false showPasswordDialog = false
isPasswordError = false isPasswordError = false
@ -3220,9 +3335,11 @@ fun PdfViewerScreen(
isLoadingDocument = false 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) { } 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 errorString = e.toString()
val causeString = e.cause?.toString() ?: "" val causeString = e.cause?.toString() ?: ""
@ -3297,28 +3414,22 @@ fun PdfViewerScreen(
ttsController.stop() ttsController.stop()
PdfBitmapPool.clear() PdfBitmapPool.clear()
PdfThumbnailCache.clear() PdfThumbnailCache.clear()
documentCache.evictAll()
val docToClose = pdfDocument val docToClose = pdfDocument
val pfdToClose = pfdState val pfdToClose = pfdState
pdfDocument = null pdfDocument = null
pfdState = null pfdState = null
if (docToClose != null || pfdToClose != null) { if (docToClose != null || pfdToClose != null) {
coroutineScope.launch(Dispatchers.IO) { CoroutineScope(Dispatchers.IO).launch {
docToClose?.let { docToClose?.let {
Timber.d("Closing PDF document in onDispose.") Timber.d("Closing PDF document in onDispose.")
try { try { it.close() } catch (e: Exception) { Timber.e(e, "Error closing document") }
it.close()
} catch (e: Exception) {
Timber.e(e, "Error closing document in onDispose")
}
} }
pfdToClose?.let { pfdToClose?.let {
Timber.d("Closing ParcelFileDescriptor in onDispose: $it") Timber.d("Closing ParcelFileDescriptor in onDispose: $it")
try { try { it.close() } catch (e: Exception) { Timber.e(e, "Error closing ParcelFileDescriptor") }
it.close()
} catch (e: Exception) {
Timber.e(e, "Error closing ParcelFileDescriptor in onDispose")
}
} }
} }
} }
@ -3329,7 +3440,7 @@ fun PdfViewerScreen(
var isOcrScanning by remember { mutableStateOf(false) } var isOcrScanning by remember { mutableStateOf(false) }
LaunchedEffect(pdfUri, currentBookId, totalPages) { LaunchedEffect(effectivePdfUri, currentBookId, totalPages) {
if (currentBookId == null || totalPages == 0) return@LaunchedEffect if (currentBookId == null || totalPages == 0) return@LaunchedEffect
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect
@ -3359,7 +3470,7 @@ fun PdfViewerScreen(
"Indexer: Starting background indexing for ${totalPages - existingPages.size} pages." "Indexer: Starting background indexing for ${totalPages - existingPages.size} pages."
) )
bgPfd = context.contentResolver.openFileDescriptor(pdfUri, "r") bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
if (bgPfd != null) { if (bgPfd != null) {
bgDoc = pdfiumCore.newDocument(bgPfd, documentPassword) bgDoc = pdfiumCore.newDocument(bgPfd, documentPassword)
@ -4223,6 +4334,13 @@ fun PdfViewerScreen(
val currentSelectedTool by rememberUpdatedState(selectedTool) val currentSelectedTool by rememberUpdatedState(selectedTool)
val currentStrokeColorState by rememberUpdatedState(
currentStrokeColor
)
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination = @Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
remember(pageIndex) { remember(pageIndex) {
{ point: PdfPoint -> { point: PdfPoint ->
@ -4231,8 +4349,9 @@ fun PdfViewerScreen(
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList() val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter { val toRemove = existing.filter {
isAnnotationHit(it, point, aspectRatio, activeToolThickness) isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
} }
lastEraserPoint = point
if (toRemove.isNotEmpty()) { if (toRemove.isNotEmpty()) {
val batch = val batch =
erasedAnnotationsFromStroke.getOrPut( erasedAnnotationsFromStroke.getOrPut(
@ -4259,13 +4378,6 @@ fun PdfViewerScreen(
} }
} }
val currentStrokeColorState by rememberUpdatedState(
currentStrokeColor
)
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination = @Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination =
remember(pageIndex) { remember(pageIndex) {
{ point: PdfPoint -> { point: PdfPoint ->
@ -4274,11 +4386,12 @@ fun PdfViewerScreen(
} else { } else {
if (currentSelectedTool == InkType.TEXT) { if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) { } else if (currentSelectedTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear() erasedAnnotationsFromStroke.clear()
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList() val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter { val toRemove = existing.filter {
isAnnotationHit(it, point, aspectRatio, activeToolThickness) isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
} }
if (toRemove.isNotEmpty()) { if (toRemove.isNotEmpty()) {
val batch = val batch =
@ -4617,12 +4730,13 @@ fun PdfViewerScreen(
} else { } else {
if (currentSelectedTool == InkType.TEXT) { if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) { } else if (currentSelectedTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear() erasedAnnotationsFromStroke.clear()
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList() val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter { val toRemove = existing.filter {
isAnnotationHit(it, point, aspectRatio, activeToolThickness) isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
} }
if (toRemove.isNotEmpty()) { if (toRemove.isNotEmpty()) {
val batch = val batch =
@ -4660,8 +4774,9 @@ fun PdfViewerScreen(
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList() val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter { val toRemove = existing.filter {
isAnnotationHit(it, point, aspectRatio, activeToolThickness) isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
} }
lastEraserPoint = point
if (toRemove.isNotEmpty()) { if (toRemove.isNotEmpty()) {
val batch = val batch =
erasedAnnotationsFromStroke.getOrPut( erasedAnnotationsFromStroke.getOrPut(
@ -5229,15 +5344,13 @@ fun PdfViewerScreen(
modifier = Modifier.align(Alignment.TopCenter) modifier = Modifier.align(Alignment.TopCenter)
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.height(56.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp tonalElevation = 4.dp
) { ) {
Column(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().height(56.dp)
.fillMaxSize()
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -5255,8 +5368,7 @@ fun PdfViewerScreen(
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_back), text = stringResource(R.string.tooltip_back),
description = stringResource(R.string.tooltip_back_desc), description = stringResource(R.string.tooltip_back_desc),
onClick = { saveStateAndExit() } onClick = { saveStateAndExit() }) {
) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back" contentDescription = "Back"
@ -5280,17 +5392,14 @@ fun PdfViewerScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier.padding(start = 12.dp).weight(1f)
.padding(start = 12.dp)
.weight(1f)
.testTag("PageNumberIndicator") .testTag("PageNumberIndicator")
) )
TooltipIconButton( TooltipIconButton(
text = "Theme", text = "Theme",
description = "Theme Settings", description = "Theme Settings",
onClick = { showThemePanel = true } onClick = { showThemePanel = true }) {
) {
Icon( Icon(
painter = painterResource(id = R.drawable.palette), painter = painterResource(id = R.drawable.palette),
contentDescription = "Theme Settings", contentDescription = "Theme Settings",
@ -5299,19 +5408,14 @@ fun PdfViewerScreen(
} }
TooltipIconButton( TooltipIconButton(
text = if (isScrollLocked) text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan)
stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
else description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc)
stringResource(R.string.tooltip_lock_pan), else stringResource(R.string.tooltip_lock_pan_desc),
description = if (isScrollLocked)
stringResource(R.string.tooltip_unlock_pan_desc)
else
stringResource(R.string.tooltip_lock_pan_desc),
onClick = { onClick = {
isScrollLocked = !isScrollLocked isScrollLocked = !isScrollLocked
savePdfScrollLocked(context, bookId, isScrollLocked) savePdfScrollLocked(context, bookId, isScrollLocked)
} }) {
) {
Icon( Icon(
imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning",
@ -5325,8 +5429,7 @@ fun PdfViewerScreen(
onClick = { onClick = {
isFullScreen = true isFullScreen = true
savePdfFullScreen(context, bookId, true) savePdfFullScreen(context, bookId, true)
} }) {
) {
Icon( Icon(
imageVector = Icons.Default.Fullscreen, imageVector = Icons.Default.Fullscreen,
contentDescription = "Enter Full Screen", contentDescription = "Enter Full Screen",
@ -5337,8 +5440,7 @@ fun PdfViewerScreen(
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary), text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc), description = stringResource(R.string.tooltip_dictionary_desc),
onClick = { showDictionarySettingsSheet = true } onClick = { showDictionarySettingsSheet = true }) {
) {
Icon( Icon(
painter = painterResource(id = R.drawable.dictionary), painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings", contentDescription = "Dictionary Settings",
@ -5347,7 +5449,9 @@ fun PdfViewerScreen(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
TooltipIconButton(text = "Pen Playground", onClick = { showPenPlayground = true }) { TooltipIconButton(
text = "Pen Playground",
onClick = { showPenPlayground = true }) {
Icon( Icon(
imageVector = Icons.Default.Star, imageVector = Icons.Default.Star,
contentDescription = "Open Pen Playground", contentDescription = "Open Pen Playground",
@ -5356,10 +5460,12 @@ fun PdfViewerScreen(
} }
TooltipIconButton(text = "Import SVG", onClick = { TooltipIconButton(text = "Import SVG", onClick = {
val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage val page =
if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
val svgAnnotations = SvgToAnnotationConverter.importSvgFromAssets( val svgAnnotations =
SvgToAnnotationConverter.importSvgFromAssets(
context = context, context = context,
fileName = "demo_art.svg", fileName = "demo_art.svg",
pageIndex = page pageIndex = page
@ -5367,11 +5473,18 @@ fun PdfViewerScreen(
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (svgAnnotations.isNotEmpty()) { if (svgAnnotations.isNotEmpty()) {
val existing = allAnnotations[page] ?: emptyList() val existing =
allAnnotations = allAnnotations + (page to (existing + svgAnnotations)) allAnnotations[page] ?: emptyList()
allAnnotations =
allAnnotations + (page to (existing + svgAnnotations))
svgAnnotations.forEach { annot -> svgAnnotations.forEach { annot ->
undoStack.add(HistoryAction.Add(page, annot)) undoStack.add(
HistoryAction.Add(
page,
annot
)
)
} }
redoStack.clear() redoStack.clear()
@ -5395,8 +5508,7 @@ fun PdfViewerScreen(
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_more_options), text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc), description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true } onClick = { showMoreMenu = true }) {
) {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
contentDescription = "More Options" contentDescription = "More Options"
@ -5414,8 +5526,7 @@ fun PdfViewerScreen(
showMoreMenu = false showMoreMenu = false
hasSelectedOcrLanguage = true hasSelectedOcrLanguage = true
showOcrLanguageDialog = true showOcrLanguageDialog = true
} })
)
HorizontalDivider() HorizontalDivider()
} }
@ -5465,8 +5576,7 @@ fun PdfViewerScreen(
contentDescription = "Selected" contentDescription = "Selected"
) )
} }
} })
)
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
text = { Text("Auto Scroll") }, text = { Text("Auto Scroll") },
@ -5476,8 +5586,7 @@ fun PdfViewerScreen(
isAutoScrollModeActive = true isAutoScrollModeActive = true
isAutoScrollPlaying = true isAutoScrollPlaying = true
showBars = !isMusicianMode showBars = !isMusicianMode
} })
)
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
@ -5492,8 +5601,7 @@ fun PdfViewerScreen(
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} })
)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
DropdownMenuItem( DropdownMenuItem(
@ -5503,9 +5611,12 @@ fun PdfViewerScreen(
showTtsSettingsSheet = true showTtsSettingsSheet = true
}, },
leadingIcon = { leadingIcon = {
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp)) Icon(
} painter = painterResource(id = R.drawable.text_to_speech),
contentDescription = null,
modifier = Modifier.size(20.dp)
) )
})
} }
HorizontalDivider() HorizontalDivider()
@ -5564,20 +5675,25 @@ fun PdfViewerScreen(
} }
saveAllData(true).join() saveAllData(true).join()
val resolvedPage = if (!initialScrollDone && currentPage == 0) { val resolvedPage =
if (!initialScrollDone && currentPage == 0) {
pendingRestorePage ?: 0 pendingRestorePage ?: 0
} else { } else {
currentPage currentPage
} }
if (hasReflowFile) { if (hasReflowFile) {
val item = uiState.allRecentFiles.find { it.bookId == reflowBookId } val item =
uiState.allRecentFiles.find { it.bookId == reflowBookId }
if (item != null) { if (item != null) {
viewModel.switchToFileSeamlessly(item, resolvedPage) viewModel.switchToFileSeamlessly(
item,
resolvedPage
)
} else { } else {
viewModel.generateAndImportReflowFile( viewModel.generateAndImportReflowFile(
pdfBookId = bookId, pdfBookId = bookId,
pdfUri = pdfUri, pdfUri = effectivePdfUri,
originalTitle = originalFileName, originalTitle = originalFileName,
autoOpenPage = resolvedPage autoOpenPage = resolvedPage
) )
@ -5585,7 +5701,7 @@ fun PdfViewerScreen(
} else { } else {
viewModel.generateAndImportReflowFile( viewModel.generateAndImportReflowFile(
pdfBookId = bookId, pdfBookId = bookId,
pdfUri = pdfUri, pdfUri = effectivePdfUri,
originalTitle = originalFileName, originalTitle = originalFileName,
autoOpenPage = resolvedPage autoOpenPage = resolvedPage
) )
@ -5598,8 +5714,7 @@ fun PdfViewerScreen(
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} })
)
HorizontalDivider() HorizontalDivider()
@ -5626,10 +5741,13 @@ fun PdfViewerScreen(
}) })
} }
if (uiState.selectedFileType == FileType.PDF) { if (uiState.selectedFileType == FileType.PDF) {
DropdownMenuItem(text = { Text("Print") }, onClick = { DropdownMenuItem(
text = { Text("Print") },
onClick = {
showMoreMenu = false showMoreMenu = false
onPrintDocument() onPrintDocument()
}, leadingIcon = { },
leadingIcon = {
Icon( Icon(
painter = painterResource(id = R.drawable.print), painter = painterResource(id = R.drawable.print),
contentDescription = null, contentDescription = null,
@ -5641,6 +5759,84 @@ fun PdfViewerScreen(
} }
} }
} }
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 = val popupPlacementConfig =
remember(dockLocation, dockOffset, boxMaxHeightFloat, dockHeightPx) { remember(dockLocation, dockOffset, boxMaxHeightFloat, dockHeightPx) {
val margin = 16.dp val margin = 16.dp
with(density) { boxMaxHeightFloat.toDp() }
val dockTopY = when (dockLocation) { val dockTopY = when (dockLocation) {
DockLocation.TOP -> 0f DockLocation.TOP -> 0f
DockLocation.BOTTOM -> boxMaxHeightFloat - dockHeightPx DockLocation.BOTTOM -> boxMaxHeightFloat - dockHeightPx
@ -6183,10 +6377,10 @@ fun PdfViewerScreen(
if (isDockInBottomHalf) { if (isDockInBottomHalf) {
val distFromBottom = boxMaxHeightFloat - dockTopY val distFromBottom = boxMaxHeightFloat - dockTopY
val paddingBottom = with(density) { distFromBottom.toDp() } + margin val paddingBottom = with(density) { distFromBottom.toDp() } + margin
Triple(Alignment.BottomCenter, 0.dp, paddingBottom) Triple(Alignment.BottomCenter, 0.dp, paddingBottom.coerceAtLeast(0.dp))
} else { } else {
val paddingTop = with(density) { dockBottomY.toDp() } + margin 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 }) 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) { if (showPenPlayground) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -7109,7 +7354,7 @@ fun PdfViewerScreen(
viewModel.sharePdf( viewModel.sharePdf(
activityContext = context, activityContext = context,
sourceUri = pdfUri, sourceUri = effectivePdfUri,
annotations = allAnnotations, annotations = allAnnotations,
richTextPageLayouts = currentRichTextLayouts, richTextPageLayouts = currentRichTextLayouts,
textBoxes = textBoxes.toList(), textBoxes = textBoxes.toList(),

View file

@ -625,4 +625,19 @@
<string name="error_opening_dictionary">Error opening dictionary</string> <string name="error_opening_dictionary">Error opening dictionary</string>
<string name="error_opening_translate">Error opening translate app</string> <string name="error_opening_translate">Error opening translate app</string>
<string name="error_opening_search">Error opening search app</string> <string name="error_opening_search">Error opening search app</string>
<!-- About Section -->
<string name="about_app_name">Episteme</string>
<string name="about_oss_version">Open Source Version</string>
<string name="about_play_version">Playstore Version</string>
<string name="about_version_name">Version %1$s</string>
<string name="about_build_code">Build %1$s</string>
<string name="about_github">GitHub</string>
<string name="about_github_desc">Browse source code, star, fork, and report issues.</string>
<string name="about_privacy">Privacy Policy</string>
<string name="about_privacy_desc">How we handle your data.</string>
<string name="about_terms">Terms of Service</string>
<string name="about_terms_desc">Usage terms and conditions.</string>
<string name="about_licenses_desc">Open source libraries used.</string>
<string name="banner_importing_multiple">Importing %1$d books… They will appear in your Library shortly.</string>
</resources> </resources>