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;
} }
} }
@ -491,4 +517,92 @@ 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,134 +2442,150 @@ 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
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) { } catch (e: Exception) {
Timber.tag("PdfInteraction").e(e, "Interaction error") Timber.tag("PdfInteraction").e(e, "Interaction error")
false 0
} }
} }
if (wasHandled) { 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 ->
val hit = pair.second.find { r -> val hit = pair.second.find { r ->
val hitLeft = r.left - hitTolerance val hitLeft = r.left - hitTolerance
val hitTop = r.top - hitTolerance val hitTop = r.top - hitTolerance
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) {
tappedRect = hit
true
} else false
} }
if (hit != null) {
tappedRect = hit
true
} else false
}
val standardHit = standardAnnotScreenRects.findLast { (annot, screenRect) -> val standardHit = standardAnnotScreenRects.findLast { (annot, screenRect) ->
if (annot.subtype == 2) return@findLast false if (annot.subtype == 2) return@findLast false
val left = min(screenRect.left, screenRect.right) val left = min(screenRect.left, screenRect.right)
val right = max(screenRect.left, screenRect.right) val right = max(screenRect.left, screenRect.right)
val top = min(screenRect.top, screenRect.bottom) val top = min(screenRect.top, screenRect.bottom)
val bottom = max(screenRect.top, screenRect.bottom) val bottom = max(screenRect.top, screenRect.bottom)
val inflatedHitBox = Rect( val inflatedHitBox = Rect(
(left - annotHitTolerance).toInt(), (left - annotHitTolerance).toInt(),
(top - annotHitTolerance).toInt(), (top - annotHitTolerance).toInt(),
(right + annotHitTolerance).toInt(), (right + annotHitTolerance).toInt(),
(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) {
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)
} }
return@detectTapGestures
}
val wasMenuVisible = customMenuState != null if (standardHit != null) {
val wasSelectionVisible = val (annot, screenRect) = standardHit
selectionCharRange.value != null || ocrSelectionSymbolIndices != null customMenuState = CustomPdfMenuState(
selectedText = annot.contents ?: "No comment",
anchorRect = screenRect,
charRange = Pair(-1, -1),
isComment = true,
author = annot.author,
annotation = annot
)
return@launch
}
Timber.d( if (hitHighlightPair != null && tappedRect != null) {
"PdfPageComposable: State check - MenuVisible=$wasMenuVisible, SelectionVisible=$wasSelectionVisible" val hitHighlight = hitHighlightPair.first
) val combinedRect = Rect(hitHighlightPair.second.first())
hitHighlightPair.second.forEach { combinedRect.union(it) }
if (wasMenuVisible || wasSelectionVisible) { customMenuState = CustomPdfMenuState(
Timber.d( selectedText = hitHighlight.text,
"PdfPageComposable: Clearing selection/menu." anchorRect = combinedRect,
) charRange = hitHighlight.range,
customMenuState = null isExistingHighlight = true,
selectionCharRange.value = null highlightId = hitHighlight.id
ocrSelectionSymbolIndices = null )
coroutineScope.launch { 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( updateSelectionVisuals(
pdfDocumentItem, pdfDocumentItem,
pdfPageIndex, pdfPageIndex,
@ -2578,12 +2594,9 @@ internal fun PdfPageComposable(
actualBitmapHeightPx, actualBitmapHeightPx,
currentPageRotation, currentPageRotation,
) )
} else {
currentOnSingleTap()
} }
} else {
Timber.d(
"PdfPageComposable: No selection active. Calling onSingleTap()."
)
currentOnSingleTap()
} }
}, onDoubleTap = { tapOffset -> }, onDoubleTap = { tapOffset ->
if (isZoomEnabled && !isVerticalScroll) { if (isZoomEnabled && !isVerticalScroll) {

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

File diff suppressed because it is too large Load diff

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>