Pdf text highlight (#49)

* feat(pdf): implement native text highlighting and interactive selection menu

- Add `PdfUserHighlight` data model to track page index, character ranges, and normalized coordinates.
- Redesign `PdfSelectionMenuPopup` to match the EPUB reader style, featuring a color palette row and a deletion option.
- Integrate highlight rendering into the `PdfPageSelectionsLayer` using Canvas drawing.
- Add hit-testing to `detectTapGestures` to allow users to tap existing highlights to change colors or remove them.

* Implemented PDF highlight persistence, sync, and exporting.

* Improved PDF highlight exporting and visibility logic

- Fixed PDF highlight rendering by correctly calculating coordinates and merging adjacent rectangles into lines.
- Updated `PdfExporter` to include highlights in the exported document and added debug logging.
- Corrected visibility check in `PdfPageComposable` to account for vertical offsets.
- Enhanced `PdfViewerScreen` highlight creation to use merged line rectangles

* Refactored annotation synchronization logic and improved background sync reliability.

- Refactored JSON bundle creation and extraction into reusable helper functions.
- Improved stale file detection by checking all annotation types (ink, text, layout, highlights, text boxes).
- Added background annotation downloading to `SyncWorker`
- Implemented duplicate file cleanup in `GoogleDriveRepository` during uploads.
- Added a `showFeedback` flag to `syncFolderMetadata` to control UI visibility during background operations.
- Fixed a potential data loss edge case by aborting Firestore sync if the bundle upload fails.

* Improved highlight interaction and management in PDF viewer

- Implemented hit tolerance for highlight selection to improve touch detection accuracy.
- Added logic to shift highlight page indices when adding or deleting pages.
- Included highlights in the auto-pruning check for empty pages.
- Added a "Highlights" tab to the navigation drawer to list, navigate to, and delete user highlights.
This commit is contained in:
Aryan 2026-03-09 17:37:44 +05:30 committed by GitHub
parent 8c7c8cb48e
commit ef1bfdc57a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 921 additions and 163 deletions

View file

@ -69,10 +69,12 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.pdf.PdfCoverGenerator
import com.aryan.reader.pdf.PdfExporter
import com.aryan.reader.pdf.PdfUserHighlight
import com.aryan.reader.pdf.ReflowWorker
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PdfHighlightRepository
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository
@ -232,6 +234,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val pageLayoutRepository = PageLayoutRepository(appContext)
private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext)
private val pdfTextBoxRepository = PdfTextBoxRepository(appContext)
private val pdfHighlightRepository = PdfHighlightRepository(appContext)
data class PageModificationResult(
val layout: List<VirtualPage>,
@ -918,6 +921,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
annotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = null,
bookId: String
) {
viewModelScope.launch {
@ -929,13 +933,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val outputStream = appContext.contentResolver.openOutputStream(destUri)
if (outputStream != null) {
PdfExporter.exportAnnotatedPdf(
appContext,
sourceUri,
outputStream,
virtualPages,
annotations,
richTextPageLayouts,
textBoxes
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
virtualPages = virtualPages,
inkAnnotations = annotations,
richTextPageLayouts = richTextPageLayouts,
textBoxes = textBoxes,
highlights = highlights
)
showBanner("PDF saved successfully.")
} else {
@ -978,6 +983,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
annotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = null,
includeAnnotations: Boolean,
filename: String,
bookId: String? = null
@ -1009,13 +1015,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
PdfExporter.exportAnnotatedPdf(
appContext,
sourceUri,
outputStream,
virtualPages,
annotations,
richTextPageLayouts,
textBoxes
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
virtualPages = virtualPages,
inkAnnotations = annotations,
richTextPageLayouts = richTextPageLayouts,
textBoxes = textBoxes,
highlights = highlights
)
} else {
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
@ -1068,18 +1075,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val deviceId = getInstallationId()
Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}")
// --- CHANGED BLOCK START ---
// Gather files from all three repositories
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId)
val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId)
val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId)
val hasInk = inkFile?.exists() == true
val hasRichText = richTextFile.exists()
val hasLayout = layoutFile.exists()
val hasTextBoxes = textBoxFile.exists()
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes
val hasHighlights = highlightFile.exists()
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
if (hasAnyData) {
if (googleDriveRepository.hasDrivePermissions(appContext)) {
@ -1089,42 +1096,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val bundleJson = JSONObject()
bundleJson.put("version", 2)
if (hasInk) {
fun putJsonSafe(key: String, file: File?) {
if (file == null || !file.exists()) return
try {
val inkContent = inkFile.readText()
val jsonArray = JSONArray(inkContent)
bundleJson.put("ink", jsonArray)
val content = file.readText().trim()
if (content.startsWith("[")) {
bundleJson.put(key, JSONArray(content))
} else if (content.startsWith("{")) {
bundleJson.put(key, JSONObject(content))
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse local ink file")
Timber.e(e, "Failed to parse local $key file")
}
}
if (hasRichText) {
try {
val textContent = richTextFile.readText()
bundleJson.put("text", JSONArray(textContent))
} catch (e: Exception) {
Timber.e(e)
}
}
if (hasLayout) {
try {
val layoutContent = layoutFile.readText()
bundleJson.put("layout", JSONArray(layoutContent))
} catch (e: Exception) {
Timber.e(e)
}
}
if (hasTextBoxes) {
try {
val tbContent = textBoxFile.readText()
bundleJson.put("textBoxes", JSONArray(tbContent))
} catch (e: Exception) {
Timber.e(e, "Failed to parse local text box file")
}
}
if (hasInk) putJsonSafe("ink", inkFile)
if (hasRichText) putJsonSafe("text", richTextFile)
if (hasLayout) putJsonSafe("layout", layoutFile)
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
if (hasHighlights) putJsonSafe("highlights", highlightFile)
val bundleFile =
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
@ -1139,7 +1129,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else {
Timber.tag("AnnotationSync").e("Bundle upload FAILED.")
Timber.tag("AnnotationSync").e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
return@launch
}
}
}
@ -1148,12 +1139,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
.d("No local data (ink/text/layout) to upload for ${book.bookId}")
}
val newTimestamp = System.currentTimeMillis()
val metadataToSync = book.toBookMetadata().copy(
lastModifiedTimestamp = System.currentTimeMillis(),
lastModifiedTimestamp = newTimestamp,
hasAnnotations = hasAnyData
)
firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId)
recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp))
Timber.tag("AnnotationSync")
.d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)")
} catch (e: Exception) {
@ -1410,19 +1403,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun syncFolderMetadata() {
triggerFolderSyncWorker(metadataOnly = true)
fun syncFolderMetadata(showFeedback: Boolean = false) {
triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback)
}
fun scanSyncedFolder() {
triggerFolderSyncWorker(metadataOnly = false)
triggerFolderSyncWorker(metadataOnly = false, showFeedback = true)
}
private fun triggerFolderSyncWorker(metadataOnly: Boolean) {
private fun triggerFolderSyncWorker(metadataOnly: Boolean, showFeedback: Boolean) {
val folders = _internalState.value.syncedFolders
if (folders.isEmpty()) return
Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly)")
Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)")
val workManager = WorkManager.getInstance(appContext)
val data = androidx.work.Data.Builder()
@ -1444,18 +1437,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (workInfo != null) {
when (workInfo.state) {
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
_internalState.update { it.copy(
isLoading = false,
isRefreshing = true,
bannerMessage = BannerMessage(msg)
) }
if (showFeedback) {
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
_internalState.update { it.copy(
isLoading = false,
isRefreshing = true,
bannerMessage = BannerMessage(msg)
) }
}
}
WorkInfo.State.SUCCEEDED -> {
_internalState.update { it.copy(
isLoading = false,
isRefreshing = false,
bannerMessage = BannerMessage("Folder Sync: Scan complete."),
bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage,
lastFolderScanTime = System.currentTimeMillis()
) }
}
@ -1463,7 +1458,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = "Sync failed."
errorMessage = if (showFeedback) "Sync failed." else it.errorMessage
) }
}
else -> Unit
@ -1917,12 +1912,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
val annotationFile =
pdfAnnotationRepository.getAnnotationFileForSync(bookId)
val localFileMissing = annotationFile == null
val fileLastModified = annotationFile?.lastModified() ?: 0L
val isFileStale =
remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
val anyLocalFileExists = (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists()
val localFileMissing = !anyLocalFileExists
val fileLastModified = maxOf(
inkFile?.lastModified() ?: 0L,
richTextFile.lastModified(),
layoutFile.lastModified(),
textBoxFile.lastModified(),
highlightFile.lastModified()
)
val isFileStale = remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) {
Timber.tag("AnnotationSync").d("Triggering download for $bookId.")
@ -2091,43 +2097,31 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
// Ensure directories exist
inkFile.parentFile?.mkdirs()
richTextFile.parentFile?.mkdirs()
layoutFile.parentFile?.mkdirs()
textBoxFile.parentFile?.mkdirs()
highlightFile.parentFile?.mkdirs()
if (isBundle) {
val bundle = JSONObject(jsonString)
// 1. Ink
if (bundle.has("ink")) {
inkFile.writeText(bundle.getJSONArray("ink").toString())
} else {
// If bundle exists but no ink key, implies ink was deleted or empty
if (inkFile.exists()) inkFile.delete()
fun writeSafe(key: String, file: File) {
if (bundle.has(key)) {
file.parentFile?.mkdirs()
file.writeText(bundle.get(key).toString())
} else {
if (file.exists()) file.delete()
}
}
// 2. Text
if (bundle.has("text")) {
richTextFile.writeText(bundle.getJSONArray("text").toString())
} else {
if (richTextFile.exists()) richTextFile.delete()
}
// 3. Layout
if (bundle.has("layout")) {
layoutFile.writeText(bundle.getJSONArray("layout").toString())
} else {
if (layoutFile.exists()) layoutFile.delete()
}
// 4. Text Boxes
if (bundle.has("textBoxes")) {
textBoxFile.writeText(bundle.getJSONArray("textBoxes").toString())
} else {
if (textBoxFile.exists()) textBoxFile.delete()
}
writeSafe("ink", inkFile)
writeSafe("text", richTextFile)
writeSafe("layout", layoutFile)
writeSafe("textBoxes", textBoxFile)
writeSafe("highlights", highlightFile)
Timber.tag("AnnotationSync").d("Unpacked unified bundle.")
} else {
@ -2751,7 +2745,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
if (hasFolder) {
syncFolderMetadata()
syncFolderMetadata(showFeedback = true)
}
} catch (e: Exception) {
Timber.e(e, "Refresh failed")