Pdf reflow rework (#51)

* perf(pdf): native Pdfium-based reflow engine with auto-open

Migrate PDF-to-Markdown reflow generation from PDFBox to a custom
native implementation using Pdfium. This transition improves
processing speed by ~10x and significantly reduces memory overhead.

- Native JNI Bridge: Implemented `pdfium_bridge.cpp` using `dlopen`
  to hook into the existing `libpdfium.so` memory space.
- Optimized Extraction: Replaced character-by-character JNI calls
  with bulk array retrieval (`getPageFontSizes`, `getPageFontWeights`),
  drastically reducing JNI boundary overhead.
- Enhanced Accuracy: Improved Markdown formatting logic by using
  native font weight (bold) and relative font size variance (headers).
- Thread Safety: Refactored generator to process pages sequentially
  while synchronized with the global `PdfiumCore.lock` to ensure
  stability across concurrent UI operations.
- Seamless UX: Implemented a reactive auto-open system in the
  PDF viewer that tracks user intent and navigates to the reflow
  view immediately upon background task completion.

* Improved PDF to Markdown conversion and added cache cleanup.

- Implemented automated cleanup of imported file caches when deleting books.
- Enhanced `PdfToMarkdownGenerator` with support for font flags (italics), improved kerning, and smarter paragraph wrapping.
- Updated `NativePdfiumBridge` and C++ JNI code to extract font information flags from PDFium.

* Implemented seamless file switching and enhanced reflow transition logic.

Key changes include:
- Added `switchToFileSeamlessly` to `MainViewModel` to handle state transitions and navigation when switching between PDF and reflowed text views.
- Updated `generateAndImportReflowFile` to support automatic opening of the generated file at a specific page/chapter.
- Integrated `NavigationEvent` and `CompletableDeferred` to manage asynchronous navigation and state updates during file switches.
- Modified `EpubReaderScreen` and `PdfViewerScreen` to pass the current position when toggling between PDF and text modes.
- Updated `AppNavigation` to move navigation logic out of the `NavHost` and added loading overlays to viewers to improve UI feedback during transitions.
This commit is contained in:
Aryan 2026-03-10 10:59:44 +05:30 committed by GitHub
parent acf282d4c7
commit c61a264a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 940 additions and 419 deletions

View file

@ -83,7 +83,7 @@ This project is licensed under the **GNU Affero General Public License v3.0 (AGP
## Support ## Support
If you find Episteme Reader useful and want to support its development, consider sponsoring. If you find Episteme Reader useful and want to support its development, please consider sponsoring. Thank you!
<a href="https://github.com/sponsors/Aryan-Raj3112"> <a href="https://github.com/sponsors/Aryan-Raj3112">
<img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-%23db61a2?logo=github" alt="Sponsor on GitHub"/> <img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-%23db61a2?logo=github" alt="Sponsor on GitHub"/>

View file

@ -65,12 +65,12 @@ set_target_properties(mobi PROPERTIES C_VISIBILITY_PRESET default)
# FINAL NATIVE LIBRARY FOR THE APP # FINAL NATIVE LIBRARY FOR THE APP
# =================================================================== # ===================================================================
# 6. Define our final JNI wrapper library. # 6. Define our final JNI wrapper library.
# This single .so file will be loaded by the Android app.
add_library( add_library(
native-lib native-lib
SHARED SHARED
Woff2Converter.cpp Woff2Converter.cpp
mobi_jni_bridge.c # The placeholder file you created mobi_jni_bridge.c
pdfium_bridge.cpp # Add this new file
) )
# 7. Tell our library where to find all necessary header files. # 7. Tell our library where to find all necessary header files.
@ -88,8 +88,9 @@ find_library(z-lib z)
target_link_libraries( target_link_libraries(
native-lib native-lib
PRIVATE PRIVATE
woff2dec # From woff2 woff2dec
mobi # From libmobi mobi
${log-lib} ${log-lib}
${z-lib} # libmobi requires zlib ${z-lib}
dl
) )

88
app/src/main/cpp/pdfium_bridge.cpp vendored Normal file
View file

@ -0,0 +1,88 @@
#include <jni.h>
#include <dlfcn.h>
#include <android/log.h>
#define LOG_TAG "PdfiumBridge"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index);
typedef int (*FPDFText_GetFontInfo_t)(void* text_page, int index, void* buffer, unsigned long buflen, int* flags);
static void* pdfium_handle = nullptr;
static FPDFText_GetFontSize_t get_font_size_func = nullptr;
static FPDFText_GetFontWeight_t get_font_weight_func = nullptr;
static FPDFText_GetFontInfo_t get_font_info_func = nullptr;
static bool init_pdfium() {
if (pdfium_handle) return true;
pdfium_handle = dlopen("libpdfium.so", RTLD_LAZY);
if (!pdfium_handle) {
LOGE("Failed to hook into libpdfium.so: %s", dlerror());
return false;
}
get_font_size_func = (FPDFText_GetFontSize_t) dlsym(pdfium_handle, "FPDFText_GetFontSize");
get_font_weight_func = (FPDFText_GetFontWeight_t) dlsym(pdfium_handle, "FPDFText_GetFontWeight");
get_font_info_func = (FPDFText_GetFontInfo_t) dlsym(pdfium_handle, "FPDFText_GetFontInfo");
return get_font_size_func != nullptr && get_font_weight_func != nullptr && get_font_info_func != nullptr;
}
extern "C" JNIEXPORT jdouble JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_size_func) return 0.0;
return get_font_size_func(reinterpret_cast<void*>(textPagePtr), index);
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_weight_func) return 0;
return get_font_weight_func(reinterpret_cast<void*>(textPagePtr), index);
}
// Bulk extraction for blazing fast formatting processing
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_size_func || count <= 0) return nullptr;
jfloatArray result = env->NewFloatArray(count);
jfloat *fill = new jfloat[count];
for(int i = 0; i < count; i++) {
fill[i] = (jfloat)get_font_size_func(reinterpret_cast<void*>(textPagePtr), i);
}
env->SetFloatArrayRegion(result, 0, count, fill);
delete[] fill;
return result;
}
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_weight_func || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count);
jint *fill = new jint[count];
for(int i = 0; i < count; i++) {
fill[i] = (jint)get_font_weight_func(reinterpret_cast<void*>(textPagePtr), i);
}
env->SetIntArrayRegion(result, 0, count, fill);
delete[] fill;
return result;
}
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_info_func || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count);
jint *fill = new jint[count];
for(int i = 0; i < count; i++) {
int flags = 0;
get_font_info_func(reinterpret_cast<void*>(textPagePtr), i, nullptr, 0, &flags);
fill[i] = (jint)flags;
}
env->SetIntArrayRegion(result, 0, count, fill);
delete[] fill;
return result;
}

View file

@ -22,6 +22,7 @@ package com.aryan.reader
import android.os.Build import android.os.Build
import timber.log.Timber import timber.log.Timber
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
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.Column import androidx.compose.foundation.layout.Column
@ -69,6 +70,36 @@ fun AppNavigation(
Timber.d("AppNavigation composable invoked.") Timber.d("AppNavigation composable invoked.")
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
when (uiState.selectedFileType) {
FileType.PDF -> {
if (uiState.selectedPdfUri != null) {
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) {
popUpTo(AppDestinations.MAIN_ROUTE)
}
}
}
}
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> {
if (uiState.selectedEpubBook != null) {
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
popUpTo(AppDestinations.MAIN_ROUTE)
}
}
}
}
null -> {
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
}
}
}
}
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
@ -77,39 +108,6 @@ fun AppNavigation(
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
navController = navController navController = navController
) )
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
when (uiState.selectedFileType) {
FileType.PDF -> {
if (uiState.selectedPdfUri != null) {
Timber.d("Navigating to PDF Viewer. Route: ${AppDestinations.PDF_VIEWER_ROUTE}")
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE)
}
}
}
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> {
if (uiState.selectedEpubBook != null) {
Timber.d("Navigating to EPUB Reader for ${uiState.selectedFileType}. Route: ${AppDestinations.EPUB_READER_ROUTE}")
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
navController.navigate(AppDestinations.EPUB_READER_ROUTE)
}
} else if (uiState.selectedEpubUri != null && uiState.errorMessage == null) {
Timber.d("${uiState.selectedFileType} selected, waiting for parsing/loading before navigation.")
} else if (uiState.errorMessage != null) {
Timber.w("${uiState.selectedFileType} loading failed, staying on Home. Error: ${uiState.errorMessage}")
}
}
null -> {
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) {
Timber.d("File cleared, ensuring navigation back to Main Screen.")
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
}
}
}
}
} }
// PDF Viewer Screen Composable // PDF Viewer Screen Composable
@ -119,10 +117,12 @@ fun AppNavigation(
val initialPage = uiState.initialPageInBook val initialPage = uiState.initialPageInBook
val initialBookmarksJson = uiState.initialBookmarksJson val initialBookmarksJson = uiState.initialBookmarksJson
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId val bookId =
uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId
if (pdfUri != null) { if (pdfUri != null) {
Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage") Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage")
Box(modifier = Modifier.fillMaxSize()) {
PdfViewerScreen( PdfViewerScreen(
pdfUri = pdfUri, pdfUri = pdfUri,
initialPage = initialPage, initialPage = initialPage,
@ -145,11 +145,25 @@ fun AppNavigation(
}, },
viewModel = viewModel viewModel = viewModel
) )
if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
} else if (uiState.isLoading) {
Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.")
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else { } else {
Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.") Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.")
LaunchedEffect(Unit) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
} }
} }
@ -172,6 +186,7 @@ fun AppNavigation(
uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
EpubReaderScreen( EpubReaderScreen(
epubBook = epubBook, epubBook = epubBook,
renderMode = renderMode, renderMode = renderMode,
@ -206,6 +221,18 @@ fun AppNavigation(
onImportFont = viewModel::importFont, onImportFont = viewModel::importFont,
viewModel = viewModel viewModel = viewModel
) )
if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
} }
isLoading -> { isLoading -> {
Timber.d("EPUB Reader: Showing loading indicator.") Timber.d("EPUB Reader: Showing loading indicator.")
@ -231,9 +258,6 @@ fun AppNavigation(
} }
else -> { else -> {
Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.") Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.")
LaunchedEffect(Unit) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
} }
} }
} }

View file

@ -80,15 +80,19 @@ import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -118,6 +122,10 @@ data class UserData(
val uid: String, val displayName: String?, val photoUrl: String?, val email: String? val uid: String, val displayName: String?, val photoUrl: String?, val email: String?
) )
data class NavigationEvent(
val route: String, val bookId: String? = null, val uri: Uri? = null
)
enum class AddBooksSource(val displayName: String) { enum class AddBooksSource(val displayName: String) {
UNSHELVED("Unshelved"), ALL_BOOKS("All Books") UNSHELVED("Unshelved"), ALL_BOOKS("All Books")
} }
@ -137,9 +145,7 @@ data class DeviceLimitReachedState(
) )
data class SyncedFolder( data class SyncedFolder(
val uriString: String, val uriString: String, val name: String, val lastScanTime: Long
val name: String,
val lastScanTime: Long
) )
data class Shelf(val name: String, val books: List<RecentFileItem>) { data class Shelf(val name: String, val books: List<RecentFileItem>) {
@ -235,6 +241,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext) private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext)
private val pdfTextBoxRepository = PdfTextBoxRepository(appContext) private val pdfTextBoxRepository = PdfTextBoxRepository(appContext)
private val pdfHighlightRepository = PdfHighlightRepository(appContext) private val pdfHighlightRepository = PdfHighlightRepository(appContext)
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
@Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow()
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
data class PageModificationResult( data class PageModificationResult(
val layout: List<VirtualPage>, val layout: List<VirtualPage>,
@ -281,17 +291,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false), isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false),
syncedFolders = loadSyncedFoldersFromPrefs(), syncedFolders = loadSyncedFoldersFromPrefs(),
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
KEY_LAST_FOLDER_SCAN_TIME, KEY_LAST_FOLDER_SCAN_TIME, 0L
0L
) )
else null else null
) )
) )
open val uiState: StateFlow<ReaderScreenState> = combine( open val uiState: StateFlow<ReaderScreenState> = combine(
_internalState, _internalState, recentFilesRepository.getRecentFilesFlow(), _prefsUpdateFlow
recentFilesRepository.getRecentFilesFlow(),
_prefsUpdateFlow
) { internalState, recentFilesFromDb, _ -> ) { internalState, recentFilesFromDb, _ ->
val validContextualItems = internalState.contextualActionItems.filter { contextItem -> val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
recentFilesFromDb.any { dbItem -> recentFilesFromDb.any { dbItem ->
@ -311,8 +318,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else { } else {
recentFilesFromDb.filter { item -> recentFilesFromDb.filter { item ->
item.displayName.contains(query, ignoreCase = true) || item.title?.contains( item.displayName.contains(query, ignoreCase = true) || item.title?.contains(
query, query, ignoreCase = true
ignoreCase = true
) == true || item.author?.contains(query, ignoreCase = true) == true ) == true || item.author?.contains(query, ignoreCase = true) == true
} }
} }
@ -601,7 +607,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun completeFolderMigration() { fun completeFolderMigration() {
Timber.tag("FolderSync").d("User acknowledged update. Detaching old books and starting fresh scan.") Timber.tag("FolderSync")
.d("User acknowledged update. Detaching old books and starting fresh scan.")
viewModelScope.launch { viewModelScope.launch {
recentFilesRepository.detachAllFolderBooks() recentFilesRepository.detachAllFolderBooks()
@ -616,7 +623,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun getDisplayPathFromUri(context: Context, uriString: String): String { private fun getDisplayPathFromUri(context: Context, uriString: String): String {
val uri = uriString.toUri() val uri = uriString.toUri()
val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder" val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder"
if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri).isNotEmpty()) { if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri)
.isNotEmpty()
) {
val documentId = DocumentsContract.getTreeDocumentId(uri) val documentId = DocumentsContract.getTreeDocumentId(uri)
val split = documentId.split(":") val split = documentId.split(":")
if (split.size > 1) { if (split.size > 1) {
@ -1129,7 +1138,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("AnnotationSync") Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}") .d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else { } else {
Timber.tag("AnnotationSync").e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.") Timber.tag("AnnotationSync")
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
return@launch return@launch
} }
} }
@ -1141,8 +1151,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val newTimestamp = System.currentTimeMillis() val newTimestamp = System.currentTimeMillis()
val metadataToSync = book.toBookMetadata().copy( val metadataToSync = book.toBookMetadata().copy(
lastModifiedTimestamp = newTimestamp, lastModifiedTimestamp = newTimestamp, hasAnnotations = hasAnyData
hasAnnotations = hasAnyData
) )
firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId) firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId)
@ -1165,7 +1174,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
recentFilesRepository.markAsNotRecent(bookIdsToHide) recentFilesRepository.markAsNotRecent(bookIdsToHide)
_internalState.update { it.copy(contextualActionItems = emptySet()) } _internalState.update { it.copy(contextualActionItems = emptySet()) }
if (uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext)) { if (uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(
appContext
)
) {
bookIdsToHide.forEach { bookId -> bookIdsToHide.forEach { bookId ->
val updatedItem = recentFilesRepository.getFileByBookId(bookId) val updatedItem = recentFilesRepository.getFileByBookId(bookId)
if (updatedItem != null) { if (updatedItem != null) {
@ -1246,7 +1258,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
if (it.sourceFolderUri != null) { if (it.sourceFolderUri != null) {
Timber.tag("FolderAnnotationSync").d("Book closed (Folder Linked), syncing metadata and annotations to folder: ${it.bookId}") Timber.tag("FolderAnnotationSync")
.d("Book closed (Folder Linked), syncing metadata and annotations to folder: ${it.bookId}")
viewModelScope.launch { viewModelScope.launch {
recentFilesRepository.syncLocalMetadataToFolder(it.bookId) recentFilesRepository.syncLocalMetadataToFolder(it.bookId)
recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId) recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId)
@ -1342,7 +1355,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch { viewModelScope.launch {
try { try {
appContext.contentResolver.takePersistableUriPermission( appContext.contentResolver.takePersistableUriPermission(
folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION folderUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
) )
val name = getDisplayPathFromUri(appContext, folderUri.toString()) val name = getDisplayPathFromUri(appContext, folderUri.toString())
@ -1351,18 +1365,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
saveSyncedFoldersToPrefs(newStats) saveSyncedFoldersToPrefs(newStats)
_internalState.update { it.copy( _internalState.update {
syncedFolders = newStats, it.copy(
showFolderMigrationDialog = false syncedFolders = newStats, showFolderMigrationDialog = false
) } )
}
scanSyncedFolder() scanSyncedFolder()
val workManager = WorkManager.getInstance(appContext) val workManager = WorkManager.getInstance(appContext)
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
val syncRequest = PeriodicWorkRequestBuilder<FolderSyncWorker>(4, TimeUnit.HOURS) val syncRequest =
.setConstraints(constraints) PeriodicWorkRequestBuilder<FolderSyncWorker>(4, TimeUnit.HOURS).setConstraints(
.build() constraints
).build()
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork(
FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest
) )
@ -1415,21 +1431,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val folders = _internalState.value.syncedFolders val folders = _internalState.value.syncedFolders
if (folders.isEmpty()) return if (folders.isEmpty()) return
Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)") Timber.tag("FolderSync")
.d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)")
val workManager = WorkManager.getInstance(appContext) val workManager = WorkManager.getInstance(appContext)
val data = androidx.work.Data.Builder() val data = androidx.work.Data.Builder()
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly) .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly).build()
.build()
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>() val request = OneTimeWorkRequestBuilder<FolderSyncWorker>().setInputData(data).build()
.setInputData(data)
.build()
workManager.enqueueUniqueWork( workManager.enqueueUniqueWork(
FolderSyncWorker.WORK_NAME_ONETIME, FolderSyncWorker.WORK_NAME_ONETIME, ExistingWorkPolicy.REPLACE, request
ExistingWorkPolicy.REPLACE,
request
) )
viewModelScope.launch { viewModelScope.launch {
@ -1438,29 +1450,39 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (workInfo.state) { when (workInfo.state) {
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
if (showFeedback) { if (showFeedback) {
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..." val msg =
_internalState.update { it.copy( if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
_internalState.update {
it.copy(
isLoading = false, isLoading = false,
isRefreshing = true, isRefreshing = true,
bannerMessage = BannerMessage(msg) bannerMessage = BannerMessage(msg)
) } )
} }
} }
}
WorkInfo.State.SUCCEEDED -> { WorkInfo.State.SUCCEEDED -> {
_internalState.update { it.copy( _internalState.update {
it.copy(
isLoading = false, isLoading = false,
isRefreshing = false, isRefreshing = false,
bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage, bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage,
lastFolderScanTime = System.currentTimeMillis() lastFolderScanTime = System.currentTimeMillis()
) } )
} }
}
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
_internalState.update { it.copy( _internalState.update {
it.copy(
isLoading = false, isLoading = false,
isRefreshing = false, isRefreshing = false,
errorMessage = if (showFeedback) "Sync failed." else it.errorMessage errorMessage = if (showFeedback) "Sync failed." else it.errorMessage
) } )
} }
}
else -> Unit else -> Unit
} }
} }
@ -1477,7 +1499,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
appContext.contentResolver.releasePersistableUriPermission( appContext.contentResolver.releasePersistableUriPermission(
folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION
) )
} catch (_: Exception) {} } catch (_: Exception) {
}
} }
prefs.edit { prefs.edit {
@ -1772,7 +1795,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isLimitReached = true, isLimitReached = true,
registeredDevices = deviceItems.sortedByDescending { item -> registeredDevices = deviceItems.sortedByDescending { item ->
item.lastSeen item.lastSeen
})) })
)
} }
} ?: run { } ?: run {
showBanner("Please sign in to test device management.", isError = true) showBanner("Please sign in to test device management.", isError = true)
@ -1918,7 +1942,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(bookId) val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
val anyLocalFileExists = (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists() val anyLocalFileExists =
(inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists()
val localFileMissing = !anyLocalFileExists val localFileMissing = !anyLocalFileExists
val fileLastModified = maxOf( val fileLastModified = maxOf(
@ -1928,7 +1953,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
textBoxFile.lastModified(), textBoxFile.lastModified(),
highlightFile.lastModified() highlightFile.lastModified()
) )
val isFileStale = remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified) val isFileStale =
remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) { if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) {
Timber.tag("AnnotationSync").d("Triggering download for $bookId.") Timber.tag("AnnotationSync").d("Triggering download for $bookId.")
@ -1949,9 +1975,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when { when {
local != null && remote == null -> firestoreRepository.syncShelf( local != null && remote == null -> firestoreRepository.syncShelf(
currentUser.uid, currentUser.uid, local, deviceId
local,
deviceId
) )
local == null && remote != null -> { local == null && remote != null -> {
@ -2091,8 +2115,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File( val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File(
appContext.filesDir, appContext.filesDir, "annotations/annotation_$bookId.json"
"annotations/annotation_$bookId.json"
) )
val richTextFile = pdfRichTextRepository.getFileForSync(bookId) val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(bookId) val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
@ -2149,15 +2172,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceFolderUri: String? = null sourceFolderUri: String? = null
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val addStart = System.currentTimeMillis() val addStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent START | type=$type | hasEpubBook=${epubBook != null}") Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent START | type=$type | hasEpubBook=${epubBook != null}")
val isNewBook = withContext(Dispatchers.IO) { val isNewBook = withContext(Dispatchers.IO) {
recentFilesRepository.getFileByBookId(bookId) == null recentFilesRepository.getFileByBookId(bookId) == null
} }
val existingItem = recentFilesRepository.getFileByBookId(bookId) val existingItem = recentFilesRepository.getFileByBookId(bookId)
val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri( val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri(
uri, uri, appContext
appContext
) ?: "Unknown File" ) ?: "Unknown File"
var coverPath: String? = null var coverPath: String? = null
@ -2167,7 +2190,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) { if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) {
Timber.d("Parsing downloaded book for cover/metadata: $displayName") Timber.d("Parsing downloaded book for cover/metadata: $displayName")
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
val parseStart = System.currentTimeMillis() val parseStart = System.currentTimeMillis()
try { try {
importMutex.withLock { importMutex.withLock {
@ -2184,7 +2208,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
FileType.MOBI -> { FileType.MOBI -> {
mobiParser.createMobiBook( mobiParser.createMobiBook(
inputStream = inputStream, originalBookNameHint = displayName inputStream = inputStream,
originalBookNameHint = displayName
) )
} }
@ -2200,7 +2225,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
} }
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent: Metadata parsing completed | elapsed=${System.currentTimeMillis() - parseStart}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent: Metadata parsing completed | elapsed=${System.currentTimeMillis() - parseStart}ms")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e( Timber.e(
e, e,
@ -2208,13 +2234,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
bookForMetadata = null bookForMetadata = null
} }
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent COMPLETE | totalElapsed=${System.currentTimeMillis() - addStart}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent COMPLETE | totalElapsed=${System.currentTimeMillis() - addStart}ms")
} }
val finalBookMetadata = bookForMetadata val finalBookMetadata = bookForMetadata
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) { if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) {
title = finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName title =
finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
author = finalBookMetadata.author.takeIf { author = finalBookMetadata.author.takeIf {
it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) it.isNotBlank() && !it.equals("Unknown", ignoreCase = true)
@ -2351,45 +2379,193 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
val reflowWorkInfo: Flow<WorkInfo?> = WorkManager.getInstance(appContext) val reflowWorkInfo: Flow<WorkInfo?> =
.getWorkInfosByTagFlow(ReflowWorker.WORK_NAME) WorkManager.getInstance(appContext).getWorkInfosByTagFlow(ReflowWorker.WORK_NAME)
.map { list -> .map { list ->
list.find { !it.state.isFinished } ?: list.firstOrNull() list.find { !it.state.isFinished } ?: list.firstOrNull()
} }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun generateAndImportReflowFile(pdfBookId: String, pdfUri: Uri, originalTitle: String) { fun switchToFileSeamlessly(item: RecentFileItem, syncPosition: Int) {
viewModelScope.launch {
Timber.tag("FileSwitch")
.d("Starting seamless switch to ${item.bookId}, position: $syncPosition")
val stateUpdateDeferred = CompletableDeferred<Boolean>()
pendingSwitchDeferred = stateUpdateDeferred
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
val uri = item.getUri() ?: run {
_internalState.update {
it.copy(
isLoading = false, errorMessage = "Could not find file location."
)
}
stateUpdateDeferred.complete(false)
pendingSwitchDeferred = null
return@launch
}
val type = item.type
val bookId = item.bookId
if (type == FileType.PDF) {
_internalState.update {
it.copy(
selectedEpubUri = null,
selectedEpubBook = null,
selectedFileType = type,
selectedBookId = bookId,
selectedPdfUri = uri,
initialPageInBook = syncPosition,
initialBookmarksJson = item.bookmarksJson,
isLoading = false
)
}
delay(50)
addFileToRecent(
uri,
type,
bookId,
customDisplayName = item.displayName,
isRecent = true,
sourceFolderUri = null
)
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
_navigationEvent.send(NavigationEvent("pdf_viewer", bookId, uri))
stateUpdateDeferred.complete(true)
} else {
val epubBook = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
singleFileImporter.importSingleFile(
inputStream, type, item.displayName, bookId
)
}
}
if (epubBook != null) {
_internalState.update {
it.copy(
selectedPdfUri = null,
selectedFileType = type,
selectedBookId = bookId,
selectedEpubUri = uri,
selectedEpubBook = epubBook,
initialLocator = Locator(
chapterIndex = syncPosition, blockIndex = 0, charOffset = 0
),
initialCfi = null,
initialBookmarksJson = item.bookmarksJson,
isLoading = false
)
}
delay(50)
addFileToRecent(
uri,
type,
bookId,
epubBook,
item.displayName,
isRecent = true,
sourceFolderUri = null
)
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
_navigationEvent.send(NavigationEvent("epub_reader", bookId, uri))
stateUpdateDeferred.complete(true)
} else {
_internalState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load generated text view.",
selectedFileType = null
)
}
stateUpdateDeferred.complete(false)
}
}
}
}
fun generateAndImportReflowFile(
pdfBookId: String,
pdfUri: Uri,
originalTitle: String,
autoOpenPage: Int? = null
) {
Timber.tag("PdfToMdPerf")
.d("generateAndImportReflowFile START | pdfBookId=$pdfBookId | pdfUri=$pdfUri")
val reflowBookId = "${pdfBookId}_reflow" val reflowBookId = "${pdfBookId}_reflow"
viewModelScope.launch { viewModelScope.launch {
val existing = recentFilesRepository.getFileByBookId(reflowBookId) val existing = recentFilesRepository.getFileByBookId(reflowBookId)
if (existing != null) { if (existing != null) {
showBanner("Opening existing text view...") showBanner("Opening existing text view...")
if (autoOpenPage != null) {
switchToFileSeamlessly(existing, autoOpenPage)
} else {
onRecentFileClicked(existing) onRecentFileClicked(existing)
}
return@launch return@launch
} }
val workManager = WorkManager.getInstance(appContext) val workManager = WorkManager.getInstance(appContext)
val inputData = androidx.work.Data.Builder() val inputData =
.putString(ReflowWorker.KEY_BOOK_ID, pdfBookId) androidx.work.Data.Builder().putString(ReflowWorker.KEY_BOOK_ID, pdfBookId)
.putString(ReflowWorker.KEY_PDF_URI, pdfUri.toString()) .putString(ReflowWorker.KEY_PDF_URI, pdfUri.toString())
.putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle) .putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle).build()
.build()
val request = OneTimeWorkRequestBuilder<ReflowWorker>() val request = OneTimeWorkRequestBuilder<ReflowWorker>().setInputData(inputData)
.setInputData(inputData) .addTag(ReflowWorker.WORK_NAME).addTag("book_$pdfBookId").build()
.addTag(ReflowWorker.WORK_NAME)
.addTag("book_$pdfBookId")
.build()
workManager.enqueueUniqueWork( workManager.enqueueUniqueWork(
"reflow_$pdfBookId", "reflow_$pdfBookId", ExistingWorkPolicy.KEEP, request
ExistingWorkPolicy.KEEP,
request
) )
showBanner("Text view generation started in background.") if (autoOpenPage != null) {
launch {
importMutex.withLock {
val finalInfo = workManager.getWorkInfoByIdFlow(request.id).filterNotNull()
.first { it.state.isFinished }
if (finalInfo.state == WorkInfo.State.SUCCEEDED) {
var retries = 0
var newItem = recentFilesRepository.getFileByBookId(reflowBookId)
while (newItem == null && retries < 10) {
delay(200)
newItem = recentFilesRepository.getFileByBookId(reflowBookId)
retries++
}
if (newItem != null) {
switchToFileSeamlessly(newItem, autoOpenPage)
} else {
showBanner("Failed to load generated text view.", true)
}
} else {
showBanner("Text view generation failed.", true)
}
}
}
}
}
}
private fun clearImportedFileCache(bookId: String) {
try {
val cacheDir = File(appContext.cacheDir, "imported_file_$bookId")
if (cacheDir.exists()) {
val deleted = cacheDir.deleteRecursively()
Timber.tag("FileCleanup").d("Deleted imported cache for $bookId: $deleted")
}
} catch (e: Exception) {
Timber.e(e, "Failed to clear imported file cache for $bookId")
} }
} }
@ -2397,7 +2573,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null
) { ) {
val openBookStartTime = System.currentTimeMillis() val openBookStartTime = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
try { try {
val cursor = appContext.contentResolver.query(uri, null, null, null, null) val cursor = appContext.contentResolver.query(uri, null, null, null, null)
@ -2407,7 +2584,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown" val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
Timber.tag("FileOpenPerf").d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}") Timber.tag("FileOpenPerf")
.d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -2439,7 +2617,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
Timber.tag("FileOpenPerf").d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
_internalState.update { _internalState.update {
it.copy( it.copy(
selectedPdfUri = uri, selectedPdfUri = uri,
@ -2465,7 +2644,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
recentFilesRepository.syncLocalMetadataToFolder(bookId) recentFilesRepository.syncLocalMetadataToFolder(bookId)
} }
} }
Timber.tag("FileOpenPerf").d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
val locator = val locator =
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
Locator( Locator(
@ -2497,10 +2677,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
else -> { else -> {
loadSingleFile( loadSingleFile(
uri, uri, bookId, type, customDisplayName = originalDisplayName
bookId,
type,
customDisplayName = originalDisplayName
) )
} }
} }
@ -2509,7 +2686,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private fun loadSingleFile(uri: Uri, bookId: String, type: FileType, customDisplayName: String? = null) { private fun loadSingleFile(
uri: Uri,
bookId: String,
type: FileType,
customDisplayName: String? = null
) {
val loadStart = System.currentTimeMillis() val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
viewModelScope.launch { viewModelScope.launch {
@ -2527,15 +2709,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
inputStream, inputStream,
type, type,
originalBookNameHint = customDisplayName ?: getFileNameFromUri( originalBookNameHint = customDisplayName ?: getFileNameFromUri(
uri, uri, appContext
appContext
) ?: "unknown_doc", ) ?: "unknown_doc",
bookId = bookId bookId = bookId
) )
} }
} }
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
Timber.i("Import successful ($type). Title: ${epubBook.title}") Timber.i("Import successful ($type). Title: ${epubBook.title}")
addFileToRecent( addFileToRecent(
uri, uri,
@ -2548,7 +2730,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] loadSingleFile COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error parsing file ($type) for URI: $uri") Timber.e(e, "Error parsing file ($type) for URI: $uri")
_internalState.update { _internalState.update {
@ -2576,20 +2759,52 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"text/markdown", "text/x-markdown" -> FileType.MD "text/markdown", "text/x-markdown" -> FileType.MD
"text/html", "application/xhtml+xml" -> FileType.HTML "text/html", "application/xhtml+xml" -> FileType.HTML
"text/plain" -> { "text/plain" -> {
if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) { if (fileName?.endsWith(
".md",
ignoreCase = true
) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true
) {
FileType.MD FileType.MD
} else { } else {
FileType.TXT FileType.TXT
} }
} }
else -> { else -> {
when { when {
fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
fileName?.endsWith(".mobi", ignoreCase = true) == true || fileName?.endsWith(".azw3", ignoreCase = true) == true || fileName?.endsWith(".prc", ignoreCase = true) == true -> FileType.MOBI fileName?.endsWith(
fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true -> FileType.MD ".mobi",
ignoreCase = true
) == true || fileName?.endsWith(
".azw3",
ignoreCase = true
) == true || fileName?.endsWith(
".prc",
ignoreCase = true
) == true -> FileType.MOBI
fileName?.endsWith(
".md",
ignoreCase = true
) == true || fileName?.endsWith(
".markdown",
ignoreCase = true
) == true -> FileType.MD
fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT
fileName?.endsWith(".html", ignoreCase = true) == true || fileName?.endsWith(".xhtml", ignoreCase = true) == true || fileName?.endsWith(".htm", ignoreCase = true) == true -> FileType.HTML fileName?.endsWith(
".html",
ignoreCase = true
) == true || fileName?.endsWith(
".xhtml",
ignoreCase = true
) == true || fileName?.endsWith(
".htm",
ignoreCase = true
) == true -> FileType.HTML
else -> null else -> null
} }
} }
@ -2611,8 +2826,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
mobiParser.createMobiBook( mobiParser.createMobiBook(
inputStream, inputStream,
originalBookNameHint = customDisplayName ?: getFileNameFromUri( originalBookNameHint = customDisplayName ?: getFileNameFromUri(
uri, uri, appContext
appContext
) ?: "unknown.mobi" ) ?: "unknown.mobi"
) )
} }
@ -2663,14 +2877,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
epubParser.createEpubBook( epubParser.createEpubBook(
inputStream, inputStream,
originalBookNameHint = customDisplayName ?: getFileNameFromUri( originalBookNameHint = customDisplayName ?: getFileNameFromUri(
uri, uri, appContext
appContext
) ?: "unknown.epub" ) ?: "unknown.epub"
) )
} }
} }
Timber.i("EPUB parsing successful. Title: ${epubBook.title}") Timber.i("EPUB parsing successful. Title: ${epubBook.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
addFileToRecent( addFileToRecent(
uri, uri,
@ -2683,7 +2897,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms") Timber.tag("FileOpenPerf")
.d("[$bookId] loadEpub COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error parsing EPUB for URI: $uri") Timber.e(e, "Error parsing EPUB for URI: $uri")
_internalState.update { _internalState.update {
@ -2748,9 +2963,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch { viewModelScope.launch {
recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ ->
recentFilesRepository.updatePdfReadingPosition( recentFilesRepository.updatePdfReadingPosition(
uriString = currentPdfUri.toString(), uriString = currentPdfUri.toString(), page = page, progress = progress
page = page,
progress = progress
) )
} }
} }
@ -2815,10 +3028,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val exists = try { val exists = try {
val uri = item.uriString.toUri() val uri = item.uriString.toUri()
DocumentFile.fromSingleUri(appContext, uri)?.exists() == true DocumentFile.fromSingleUri(appContext, uri)?.exists() == true
} catch (_: Exception) { false } } catch (_: Exception) {
false
}
if (!exists) { if (!exists) {
Timber.tag("FolderSync").i("LazyCleanup: File ${item.displayName} missing. Removing.") Timber.tag("FolderSync")
.i("LazyCleanup: File ${item.displayName} missing. Removing.")
recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
showBanner("File deleted from folder. Removed from library.") showBanner("File deleted from folder. Removed from library.")
return@launch return@launch
@ -3238,7 +3454,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(contextualActionItems = emptySet()) } _internalState.update { it.copy(contextualActionItems = emptySet()) }
viewModelScope.launch { viewModelScope.launch {
val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext) val canSync =
uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(
appContext
)
val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null } val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null }
@ -3251,6 +3470,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
idsToDeleteLocally.add(item.bookId) idsToDeleteLocally.add(item.bookId)
pdfTextRepository.clearBookText(item.bookId) pdfTextRepository.clearBookText(item.bookId)
clearImportedFileCache(item.bookId)
if (item.uriString != null) { if (item.uriString != null) {
try { try {
val fileUri = item.uriString.toUri() val fileUri = item.uriString.toUri()
@ -3280,7 +3501,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
hiddenMeta?.delete() hiddenMeta?.delete()
legacyVisibleMeta?.delete() legacyVisibleMeta?.delete()
Timber.tag("FolderSync").d("Deleted metadata for ${item.bookId} from root.") Timber.tag("FolderSync")
.d("Deleted metadata for ${item.bookId} from root.")
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error deleting metadata file for ${item.bookId}") Timber.e(e, "Error deleting metadata file for ${item.bookId}")
@ -3302,8 +3524,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
} }
try { try {
val accessToken = googleDriveRepository.getAccessToken(appContext) val accessToken =
?: throw Exception("No token") googleDriveRepository.getAccessToken(appContext) ?: throw Exception(
"No token"
)
val deviceId = getInstallationId() val deviceId = getInstallationId()
val remoteFiles = withContext(Dispatchers.IO) { val remoteFiles = withContext(Dispatchers.IO) {
@ -3314,9 +3538,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
for (item in managedBooks) { for (item in managedBooks) {
recentFilesRepository.markAsDeleted(listOf(item.bookId)) recentFilesRepository.markAsDeleted(listOf(item.bookId))
pdfTextRepository.clearBookText(item.bookId) pdfTextRepository.clearBookText(item.bookId)
clearImportedFileCache(item.bookId)
firestoreRepository.syncBookMetadata( firestoreRepository.syncBookMetadata(
currentUser.uid, item.toBookMetadata().copy(isDeleted = true), deviceId currentUser.uid,
item.toBookMetadata().copy(isDeleted = true),
deviceId
) )
val fileExtension = item.type.name.lowercase() val fileExtension = item.type.name.lowercase()
@ -3330,26 +3557,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
_internalState.update { _internalState.update {
it.copy(isLoading = false, bannerMessage = BannerMessage("Deletion complete.")) it.copy(
isLoading = false,
bannerMessage = BannerMessage("Deletion complete.")
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error during permanent deletion") Timber.e(e, "Error during permanent deletion")
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
managedBooks.forEach { item -> managedBooks.forEach { item ->
clearImportedFileCache(item.bookId)
pdfTextRepository.clearBookText(item.bookId) pdfTextRepository.clearBookText(item.bookId)
} }
_internalState.update { _internalState.update {
it.copy(isLoading = false, errorMessage = "Cloud sync failed, deleted locally.") it.copy(
isLoading = false,
errorMessage = "Cloud sync failed, deleted locally."
)
} }
} }
} else { } else {
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
managedBooks.forEach { item -> pdfTextRepository.clearBookText(item.bookId) } managedBooks.forEach { item ->
clearImportedFileCache(item.bookId)
pdfTextRepository.clearBookText(item.bookId)
}
} }
} }
val totalRemoved = folderBooks.size + managedBooks.size val totalRemoved = folderBooks.size + managedBooks.size
_internalState.update { it.copy(isLoading = false, bannerMessage = BannerMessage("$totalRemoved books removed from library.")) } _internalState.update {
it.copy(
isLoading = false,
bannerMessage = BannerMessage("$totalRemoved book(s) removed from library.")
)
}
} }
} else { } else {
Timber.w("Attempted to remove contextual items, but none were selected.") Timber.w("Attempted to remove contextual items, but none were selected.")
@ -3357,9 +3599,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun navigateToFolderSync() { fun navigateToFolderSync() {
// 1. Switch MainScreen to Library Tab (Index 1)
setMainScreenPage(1) setMainScreenPage(1)
// 2. Switch LibraryScreen to Folder Tab (Index 2)
setLibraryScreenPage(2) setLibraryScreenPage(2)
} }
@ -3370,9 +3610,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("ViewModel instance cleared (onCleared).") Timber.d("ViewModel instance cleared (onCleared).")
} }
suspend fun checkAndMigrateLegacyBookId(legacyId: String, newId: String) = withContext(Dispatchers.IO) { suspend fun checkAndMigrateLegacyBookId(legacyId: String, newId: String) =
withContext(Dispatchers.IO) {
if (legacyId == newId) return@withContext if (legacyId == newId) return@withContext
Timber.tag("FolderAnnotationSync").d("Checking migration from legacyId=$legacyId to newId=$newId") Timber.tag("FolderAnnotationSync")
.d("Checking migration from legacyId=$legacyId to newId=$newId")
try { try {
fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) { fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) {
@ -3383,7 +3625,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val newTs = newFile.lastModified() val newTs = newFile.lastModified()
if (newTs > legacyTs) { if (newTs > legacyTs) {
Timber.tag("FolderAnnotationSync").i("Skipping migration for $tag: Destination ($newId) is newer than Legacy ($legacyId). Deleting legacy.") Timber.tag("FolderAnnotationSync")
.i("Skipping migration for $tag: Destination ($newId) is newer than Legacy ($legacyId). Deleting legacy.")
legacyFile.delete() legacyFile.delete()
return return
} else { } else {
@ -3397,7 +3640,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FolderAnnotationSync").w("Failed to rename $tag file.") Timber.tag("FolderAnnotationSync").w("Failed to rename $tag file.")
} }
} else { } else {
Timber.tag("FolderAnnotationSync").w("Destination file for $tag is null. Skipping.") Timber.tag("FolderAnnotationSync")
.w("Destination file for $tag is null. Skipping.")
} }
} }
} }

View file

@ -118,7 +118,7 @@ fun DictionarySettingsDialog(
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Contextual definitions powered by AI.", text = "Definitions powered by AI.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
) )

View file

@ -337,11 +337,11 @@ fun EpubReaderScreen(
val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true
val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null
val onOpenOriginal: (() -> Unit)? = if (originalBookId != null) { val onOpenOriginal: ((Int) -> Unit)? = if (originalBookId != null) {
{ { currentChapter ->
val originalItem = uiState.recentFiles.find { it.bookId == originalBookId } val originalItem = uiState.recentFiles.find { it.bookId == originalBookId }
if (originalItem != null) { if (originalItem != null) {
viewModel.onRecentFileClicked(originalItem) viewModel.switchToFileSeamlessly(originalItem, currentChapter)
} else { } else {
viewModel.showBanner("Original PDF not found.", true) viewModel.showBanner("Original PDF not found.", true)
} }
@ -388,7 +388,7 @@ fun EpubReaderHost(
onRenderModeChange: (RenderMode) -> Unit, onRenderModeChange: (RenderMode) -> Unit,
customFonts: List<CustomFontEntity>, customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit, onImportFont: (Uri) -> Unit,
onToggleReflow: (() -> Unit)? = null onToggleReflow: ((Int) -> Unit)? = null
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
@ -3114,7 +3114,16 @@ fun EpubReaderHost(
onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }, onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onToggleReflow = onToggleReflow, onToggleReflow = if (onToggleReflow != null) {
{
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
currentChapterInPaginatedMode ?: currentChapterIndex
} else {
currentChapterIndex
}
onToggleReflow(activeChapter)
}
} else null,
) )
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState( val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(

View file

@ -0,0 +1,14 @@
package com.aryan.reader.pdf
object NativePdfiumBridge {
init {
System.loadLibrary("native-lib")
}
@JvmStatic external fun getFontSize(textPagePtr: Long, index: Int): Double
@JvmStatic external fun getFontWeight(textPagePtr: Long, index: Int): Int
@JvmStatic external fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
@JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
@JvmStatic external fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray?
}

View file

@ -3,11 +3,9 @@ package com.aryan.reader.pdf
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.tom_roush.pdfbox.io.MemoryUsageSetting import io.legere.pdfiumandroid.PdfiumCore
import com.tom_roush.pdfbox.pdmodel.PDDocument import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import com.tom_roush.pdfbox.pdmodel.PDPage import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import com.tom_roush.pdfbox.text.PDFTextStripper
import com.tom_roush.pdfbox.text.TextPosition
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@ -15,8 +13,6 @@ import java.io.File
import kotlin.math.roundToInt import kotlin.math.roundToInt
object PdfToMarkdownGenerator { object PdfToMarkdownGenerator {
// Unique delimiter to split pages reliably
const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n" const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n"
suspend fun generateMarkdownFile( suspend fun generateMarkdownFile(
@ -26,109 +22,252 @@ object PdfToMarkdownGenerator {
startPage: Int = 1, startPage: Int = 1,
onProgress: (Float) -> Unit onProgress: (Float) -> Unit
): Boolean = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
val methodStartTime = System.currentTimeMillis()
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE START | uri=$pdfUri | startPage=$startPage")
val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
val pfd = context.contentResolver.openFileDescriptor(pdfUri, "r")
if (pfd == null) {
Timber.tag("PdfToMdPerf").e("Failed to open ParcelFileDescriptor")
return@withContext false
}
try { try {
context.contentResolver.openInputStream(pdfUri)?.use { inputStream -> val doc = pdfiumCore.newDocument(pfd)
// Setup mixed memory usage to handle larger files without OOM val totalPages = doc.getPageCount()
PDDocument.load(inputStream, MemoryUsageSetting.setupMixed(50 * 1024 * 1024)).use { doc -> Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages")
val totalPages = doc.numberOfPages
// Configure stripper for linear processing
val stripper = MarkdownStripper(totalPages, onProgress)
stripper.startPage = startPage
stripper.endPage = totalPages
// Write directly to file stream (O(N) complexity)
destFile.bufferedWriter().use { writer -> destFile.bufferedWriter().use { writer ->
stripper.writeText(doc, writer) for (pageIdx in (startPage - 1) until totalPages) {
val pageMd = extractPageMarkdown(doc, pageIdx)
writer.write(pageMd)
writer.write(PAGE_DELIMITER)
if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) {
onProgress((pageIdx + 1).toFloat() / totalPages.toFloat())
} }
} }
} }
doc.close()
pfd.close()
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE SUCCESS | totalTime=${System.currentTimeMillis() - methodStartTime}ms")
return@withContext true return@withContext true
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to generate Markdown from PDF") Timber.e(e, "Failed to generate Markdown from PDF natively")
pfd.close()
return@withContext false return@withContext false
} }
} }
private class MarkdownStripper( private suspend fun extractPageMarkdown(doc: PdfDocumentKt, pageIdx: Int): String {
private val totalPages: Int, return try {
private val onProgress: (Float) -> Unit doc.openPage(pageIdx).use { page ->
) : PDFTextStripper() { page.openTextPage().use { textPage ->
private var currentPageBaseFontSize = 0f val charCount = textPage.textPageCountChars()
if (charCount <= 0) return@use ""
init { val text = textPage.textPageGetText(0, charCount) ?: ""
sortByPosition = true val actualCount = minOf(charCount, text.length)
suppressDuplicateOverlappingText = true
paragraphStart = "" val rawPtr = textPage.page.pagePtr
paragraphEnd = "\n\n"
val sizes: FloatArray?
val weights: IntArray?
val flags: IntArray?
synchronized(PdfiumCore.lock) {
sizes = NativePdfiumBridge.getPageFontSizes(rawPtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(rawPtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(rawPtr, actualCount)
} }
// Override endPage to update progress and insert delimiter if (sizes == null || weights == null || flags == null) {
override fun endPage(page: PDPage?) { return@use text
super.endPage(page) }
try { buildMarkdown(text, sizes, weights, flags, actualCount)
// Insert our custom delimiter so importer can split chapters }
output.write(PAGE_DELIMITER)
// Update progress
val current = currentPageNo // inherited from PDFTextStripper
if (totalPages > 0) {
onProgress(current.toFloat() / totalPages.toFloat())
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error writing page delimiter") Timber.w(e, "Error extracting page $pageIdx")
""
} }
} }
override fun startPage(page: PDPage?) { private data class TextSpan(
currentPageBaseFontSize = 0f val text: String,
super.startPage(page) val size: Float,
val isBold: Boolean,
val isItalic: Boolean
)
private data class TextLine(
val spans: List<TextSpan>
)
private fun fixKerning(text: String): String {
val pattern = Regex("\\b(?:[A-Za-z0-9] ){2,}[A-Za-z0-9]\\b")
return pattern.replace(text) { matchResult ->
matchResult.value.replace(" ", "")
}
} }
private fun calculateBaseFontSize(textPositions: List<TextPosition>) { private fun buildMarkdown(text: String, sizes: FloatArray, weights: IntArray, flags: IntArray, count: Int): String {
val sizeCounts = mutableMapOf<Float, Int>() if (count == 0) return ""
textPositions.forEach { pos ->
val size = pos.fontSizeInPt.roundToInt().toFloat() val sizeFrequency = HashMap<Int, Int>()
sizeCounts[size] = (sizeCounts[size] ?: 0) + 1 for (i in 0 until count) {
val s = sizes[i].roundToInt()
sizeFrequency[s] = (sizeFrequency[s] ?: 0) + 1
} }
currentPageBaseFontSize = sizeCounts.maxByOrNull { it.value }?.key ?: 12f val baseSize = sizeFrequency.maxByOrNull { it.value }?.key ?: 12
val lines = mutableListOf<TextLine>()
@Suppress("CanBeVal") var currentSpans = mutableListOf<TextSpan>()
val currentSpanText = StringBuilder()
var currentSize = -1f
var currentBold = false
var currentItalic = false
for (i in 0 until count) {
val c = text[i]
if (c == '\u0000') continue
if (c == '\n' || c == '\r') {
if (c == '\n' && i > 0 && text[i - 1] == '\r') continue
if (currentSpanText.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
}
lines.add(TextLine(currentSpans.toList()))
currentSpans.clear()
continue
} }
override fun writeString(text: String?, textPositions: MutableList<TextPosition>?) { val isSpace = c.isWhitespace()
if (text.isNullOrBlank() || textPositions.isNullOrEmpty()) return val size = sizes[i]
val bold = weights[i] > 600
val italic = (flags[i] and 64) != 0
if (currentPageBaseFontSize == 0f) { if (currentSpanText.isEmpty()) {
calculateBaseFontSize(textPositions) currentSize = size
currentBold = bold
currentItalic = italic
currentSpanText.append(c)
} else {
if (!isSpace && (currentSize != size || currentBold != bold || currentItalic != italic)) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
currentSize = size
currentBold = bold
currentItalic = italic
}
currentSpanText.append(c)
}
} }
val firstPos = textPositions[0] if (currentSpanText.isNotEmpty()) {
val fontSize = firstPos.fontSizeInPt currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
val fontDescriptor = firstPos.font?.fontDescriptor }
if (currentSpans.isNotEmpty()) {
lines.add(TextLine(currentSpans))
}
val isBold = fontDescriptor?.isForceBold == true || val validLines = lines.filter { it.spans.isNotEmpty() }
(firstPos.font?.name?.contains("Bold", ignoreCase = true) == true) val lineLengths = validLines.map { line -> line.spans.sumOf { it.text.length } }.filter { it > 10 }.sorted()
val isItalic = fontDescriptor?.isItalic == true ||
(firstPos.font?.name?.contains("Italic", ignoreCase = true) == true)
// Header detection logic val typicalLineLen = if (lineLengths.isNotEmpty()) {
val isHeader = fontSize > currentPageBaseFontSize * 1.2 lineLengths[(lineLengths.size * 0.8).toInt().coerceAtMost(lineLengths.size - 1)]
val isBigHeader = fontSize > currentPageBaseFontSize * 1.5 } else {
80
}
val wrapThreshold = (typicalLineLen * 0.85).toInt()
val sb = StringBuilder() val sb = StringBuilder()
if (isBigHeader) sb.append("## ") for (i in lines.indices) {
else if (isHeader) sb.append("### ") val line = lines[i]
if (line.spans.isEmpty()) {
if (isBold && !isHeader) sb.append("**") sb.append("\n")
if (isItalic) sb.append("*") continue
text.forEach { char -> sb.append(char) }
if (isItalic) sb.append("*")
if (isBold && !isHeader) sb.append("**")
writeString(sb.toString())
} }
val maxFontSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize.toFloat()
val charBigHeader = maxFontSize > baseSize * 1.5f
val charHeader = maxFontSize > baseSize * 1.2f
var prefix = ""
if (charBigHeader) prefix = "## "
else if (charHeader) prefix = "### "
val rawLineText = line.spans.joinToString("") { it.text }
val trimmedRaw = rawLineText.trim()
val lineLen = trimmedRaw.length
val isList = trimmedRaw.startsWith("") ||
trimmedRaw.startsWith("- ") ||
trimmedRaw.startsWith("") ||
trimmedRaw.matches(Regex("^[0-9]+\\.\\s.*")) ||
trimmedRaw.matches(Regex("^[a-zA-Z]\\)\\s.*"))
if (prefix.isNotEmpty() && !isList) {
sb.append(prefix)
}
for (span in line.spans) {
var spanText = span.text
spanText = fixKerning(spanText)
val leadingSpaces = spanText.takeWhile { it.isWhitespace() }
val trailingSpaces = spanText.takeLastWhile { it.isWhitespace() }
val trimmedText = spanText.trim()
if (trimmedText.isEmpty()) {
sb.append(spanText)
continue
}
sb.append(leadingSpaces)
var tag = ""
if (span.isBold && span.isItalic) tag = "***"
else if (span.isBold) tag = "**"
else if (span.isItalic) tag = "*"
sb.append(tag).append(trimmedText).append(tag)
sb.append(trailingSpaces)
}
var isParagraphBreak = false
if (prefix.isNotEmpty() || isList) {
isParagraphBreak = true
} else if (lineLen < wrapThreshold) {
isParagraphBreak = true
} else if (trimmedRaw.matches(Regex(".*[.!?\"'”’;:*]$"))) {
isParagraphBreak = true
} else {
val nextLine = lines.subList(i + 1, lines.size).firstOrNull { it.spans.isNotEmpty() }
if (nextLine != null) {
val nextRaw = nextLine.spans.joinToString("") { it.text }.trimStart()
if (nextRaw.startsWith("\"") || nextRaw.startsWith("") || nextRaw.startsWith("-")) {
isParagraphBreak = true
}
}
}
if (isParagraphBreak) {
sb.append("\n\n")
} else {
sb.append("\n")
}
}
return sb.toString().replace(Regex("\\n{3,}"), "\n\n").trim()
} }
} }

View file

@ -27,11 +27,7 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import android.graphics.Bitmap import android.graphics.Bitmap
import kotlin.math.max
import android.graphics.RectF import android.graphics.RectF
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -59,8 +55,11 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -137,10 +136,8 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
@ -286,6 +283,7 @@ import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.random.Random import kotlin.random.Random
@ -1845,26 +1843,6 @@ fun PdfViewerScreen(
onToggleBookmark(currentPage) onToggleBookmark(currentPage)
} }
LaunchedEffect(reflowInfo) {
if (reflowInfo?.state == WorkInfo.State.SUCCEEDED &&
reflowInfo?.tags?.contains("book_$bookId") == true) {
val result = snackbarHostState.showSnackbar(
message = "Text View generation complete!",
actionLabel = "OPEN",
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
snackbarHostState.currentSnackbarData?.dismiss()
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.onRecentFileClicked(item)
}
}
}
}
LaunchedEffect(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) } LaunchedEffect(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) }
LaunchedEffect(currentBookId) { LaunchedEffect(currentBookId) {
@ -4979,13 +4957,14 @@ fun PdfViewerScreen(
if (hasReflowFile) { if (hasReflowFile) {
val item = uiState.recentFiles.find { it.bookId == reflowBookId } val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) { if (item != null) {
viewModel.onRecentFileClicked(item) viewModel.switchToFileSeamlessly(item, currentPage)
} }
} else { } else {
viewModel.generateAndImportReflowFile( viewModel.generateAndImportReflowFile(
pdfBookId = bookId, pdfBookId = bookId,
pdfUri = pdfUri, pdfUri = pdfUri,
originalTitle = originalFileName originalTitle = originalFileName,
autoOpenPage = currentPage
) )
} }
}, },

View file

@ -20,29 +20,49 @@ class ReflowWorker(
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure() val workStartTime = System.currentTimeMillis()
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: return@withContext Result.failure() Timber.tag("PdfToMdPerf").d("=== ReflowWorker START ===")
val bookId = inputData.getString(KEY_BOOK_ID) ?: run {
Timber.tag("PdfToMdPerf").e("FAILURE: KEY_BOOK_ID is null")
return@withContext Result.failure()
}
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: run {
Timber.tag("PdfToMdPerf").e("FAILURE: KEY_PDF_URI is null | bookId=$bookId")
return@withContext Result.failure()
}
val originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document" val originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document"
val reflowBookId = "${bookId}_reflow" val reflowBookId = "${bookId}_reflow"
Timber.tag("PdfToMdPerf").d("Input data | bookId=$bookId | reflowBookId=$reflowBookId | pdfUri=$pdfUriString | originalTitle=$originalTitle")
val destFile = File(applicationContext.filesDir, "${bookId}_reflow.md") val destFile = File(applicationContext.filesDir, "${bookId}_reflow.md")
val pdfUri = pdfUriString.toUri() val pdfUri = pdfUriString.toUri()
Timber.tag("ReflowWorker").d("Starting background reflow for $originalTitle.") Timber.tag("PdfToMdPerf").d("Dest file path: ${destFile.absolutePath} | exists=${destFile.exists()}")
Timber.tag("PdfToMdPerf").d("Starting PdfToMarkdownGenerator.generateMarkdownFile...")
val genStartTime = System.currentTimeMillis()
// Delegate entire process to Generator (it now handles the loop and progress)
val success = PdfToMarkdownGenerator.generateMarkdownFile( val success = PdfToMarkdownGenerator.generateMarkdownFile(
applicationContext, applicationContext,
pdfUri, pdfUri,
destFile, destFile,
startPage = 1 // Always start from beginning for full regeneration startPage = 1
) { progress -> ) { progress ->
// Report progress if ((progress * 10).toInt() % 1 == 0) {
Timber.tag("PdfToMdPerf").d("Progress: ${(progress * 100).toInt()}%")
}
setProgressAsync(workDataOf(KEY_PROGRESS to progress)) setProgressAsync(workDataOf(KEY_PROGRESS to progress))
} }
Timber.tag("PdfToMdPerf").d("generateMarkdownFile completed | success=$success | time=${System.currentTimeMillis() - genStartTime}ms")
if (success && destFile.exists()) { if (success && destFile.exists()) {
Timber.tag("ReflowWorker").d("Reflow complete. Importing to database.") val fileSizeKB = destFile.length() / 1024
Timber.tag("PdfToMdPerf").d("Reflow SUCCESS | outputFileSize=${fileSizeKB}KB")
Timber.tag("PdfToMdPerf").d("Starting database import...")
val dbStartTime = System.currentTimeMillis()
val repo = RecentFilesRepository(applicationContext) val repo = RecentFilesRepository(applicationContext)
@ -63,13 +83,16 @@ class ReflowWorker(
) )
repo.addRecentFile(newItem) repo.addRecentFile(newItem)
Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms")
// 100% Progress
setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f)) setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f))
val totalTime = System.currentTimeMillis() - workStartTime
Timber.tag("PdfToMdPerf").d("=== ReflowWorker SUCCESS === | totalTime=${totalTime}ms | totalTimeSec=${totalTime / 1000}s")
return@withContext Result.success() return@withContext Result.success()
} else { } else {
Timber.e("Reflow failed or was incomplete.") val totalTime = System.currentTimeMillis() - workStartTime
Timber.tag("PdfToMdPerf").e("=== ReflowWorker FAILURE === | success=$success | fileExists=${destFile.exists()} | totalTime=${totalTime}ms")
return@withContext Result.failure() return@withContext Result.failure()
} }
} }