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

@ -236,6 +236,10 @@ data class ReaderScreenState(
val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
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) {
@ -335,7 +339,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
else null,
pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(),
pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet(),
recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0)
recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0),
isTabsEnabled = prefs.getBoolean(KEY_TABS_ENABLED, false),
openTabIds = prefs.getString(KEY_OPEN_TAB_IDS, null)?.let {
try {
val arr = JSONArray(it)
List(arr.length()) { i -> arr.getString(i) }
} catch(_: Exception) { emptyList() }
} ?: emptyList(),
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null),
)
)
@ -394,6 +406,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (internalState.recentFilesLimit > 0) combined.take(internalState.recentFilesLimit) else combined
}
val allBaseFiles = recentFilesFromDb.filterNot { it.bookId.endsWith("_reflow") }
val openTabsList = internalState.openTabIds.mapNotNull { tabId ->
allBaseFiles.find { it.bookId == tabId }
}
val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
baseVisibleFiles.any { dbItem -> dbItem.uriString == contextItem.uriString }
}.toSet()
@ -433,6 +450,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
rawLibraryFiles = baseVisibleFiles,
contextualActionItems = validContextualItems,
shelves = allShelves,
openTabs = openTabsList,
booksAvailableForAdding = booksAvailableForAdding
)
}.stateIn(
@ -441,6 +459,103 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
initialValue = ReaderScreenState()
)
fun setTabsEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) }
_internalState.update { it.copy(isTabsEnabled = enabled) }
if (!enabled) {
val active = _internalState.value.activeTabBookId
val newTabs = if (active != null) listOf(active) else emptyList()
prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(newTabs).toString()) }
_internalState.update { it.copy(openTabIds = newTabs) }
}
}
fun switchTab(bookId: String) {
Timber.tag("PdfTabSync").i("ViewModel: switchTab called for bookId: $bookId")
val item = uiState.value.rawLibraryFiles.find { it.bookId == bookId } ?: run {
Timber.tag("PdfTabSync").e("ViewModel: switchTab FAILED - BookId $bookId not found in library")
return
}
val currentTabs = _internalState.value.openTabIds.toMutableList()
if (!currentTabs.contains(bookId)) {
if (currentTabs.size >= 20) {
viewModelScope.launch(Dispatchers.Main) {
showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true)
}
return
}
currentTabs.add(bookId)
}
prefs.edit {
putString(KEY_ACTIVE_TAB, bookId)
putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString())
}
val uri = item.getUri()
Timber.tag("PdfTabSync").d("ViewModel: ActiveTab updated to $bookId. URI found: ${uri != null}")
uri?.let {
Timber.tag("PdfTabSync").d("ViewModel: Setting new URI directly: $it")
_internalState.update { state ->
state.copy(
openTabIds = currentTabs,
activeTabBookId = bookId,
selectedPdfUri = it,
selectedBookId = bookId,
selectedFileType = item.type,
initialPageInBook = item.lastPage,
initialBookmarksJson = item.bookmarksJson,
isLoading = false,
errorMessage = null
)
}
viewModelScope.launch {
addFileToRecent(
it,
item.type,
bookId,
customDisplayName = item.displayName,
isRecent = true,
sourceFolderUri = item.sourceFolderUri
)
}
} ?: run {
_internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) }
}
}
fun closeTab(bookId: String) {
Timber.tag("PdfTabSync").i("ViewModel: closeTab called for $bookId")
val currentTabs = _internalState.value.openTabIds.toMutableList()
currentTabs.remove(bookId)
if (currentTabs.isEmpty()) {
prefs.edit {
remove(KEY_OPEN_TAB_IDS)
remove(KEY_ACTIVE_TAB)
}
_internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) }
clearSelectedFile()
} else {
val activeTab = _internalState.value.activeTabBookId
if (activeTab == bookId) {
val nextTabId = currentTabs.last()
prefs.edit {
putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString())
putString(KEY_ACTIVE_TAB, nextTabId)
}
_internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = nextTabId) }
switchTab(nextTabId)
} else {
prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) }
_internalState.update { it.copy(openTabIds = currentTabs) }
}
}
}
fun onSearchQueryChange(newQuery: String) {
_internalState.update {
if (it.isSearchActive) {
@ -2643,6 +2758,67 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return fileName ?: uri.lastPathSegment
}
fun onFilesSelected(uris: List<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) {
if (isFromRecent) {
Timber.i("Opening recent file: $uri")
@ -2904,12 +3080,30 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun openBook(
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false
) {
val openBookStartTime = System.currentTimeMillis()
Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
if (_internalState.value.isTabsEnabled && type == FileType.PDF) {
val currentTabs = _internalState.value.openTabIds.toMutableList()
if (!currentTabs.contains(bookId)) {
if (currentTabs.size >= 20) {
viewModelScope.launch(Dispatchers.Main) {
showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true)
}
return
}
currentTabs.add(bookId)
}
prefs.edit {
putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString())
putString(KEY_ACTIVE_TAB, bookId)
}
_internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) }
}
if (uri.scheme != "opds-pse") {
try {
if (uri.scheme == "file") {
@ -2980,6 +3174,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isRecent = true,
sourceFolderUri = null
)
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
_navigationEvent.send(NavigationEvent("pdf_viewer", bookId, uri))
} else {
Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition")
}
}
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) {
viewModelScope.launch {
@ -3012,6 +3213,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
_navigationEvent.send(NavigationEvent("epub_reader", bookId, uri))
}
when (type) {
FileType.EPUB -> {
loadEpub(uri, bookId, customDisplayName = originalDisplayName)
@ -4152,5 +4358,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
internal const val KEY_PINNED_HOME = "pinned_home_books"
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit"
private const val KEY_TABS_ENABLED = "tabs_enabled"
private const val KEY_OPEN_TAB_IDS = "open_tab_ids"
private const val KEY_ACTIVE_TAB = "active_tab_book_id"
}
}