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:
parent
8c7c8cb48e
commit
ef1bfdc57a
11 changed files with 921 additions and 163 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue