diff --git a/README.md b/README.md index e115a01..a725ebb 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ This project is licensed under the **GNU Affero General Public License v3.0 (AGP ## 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! Sponsor on GitHub diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 7246d66..75af0bd 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -65,12 +65,12 @@ set_target_properties(mobi PROPERTIES C_VISIBILITY_PRESET default) # FINAL NATIVE LIBRARY FOR THE APP # =================================================================== # 6. Define our final JNI wrapper library. -# This single .so file will be loaded by the Android app. add_library( native-lib SHARED 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. @@ -88,8 +88,9 @@ find_library(z-lib z) target_link_libraries( native-lib PRIVATE - woff2dec # From woff2 - mobi # From libmobi + woff2dec + mobi ${log-lib} - ${z-lib} # libmobi requires zlib + ${z-lib} + dl ) \ No newline at end of file diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp new file mode 100644 index 0000000..103c071 --- /dev/null +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -0,0 +1,88 @@ +#include +#include +#include + +#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(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(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(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(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(textPagePtr), i, nullptr, 0, &flags); + fill[i] = (jint)flags; + } + env->SetIntArrayRegion(result, 0, count, fill); + delete[] fill; + return result; +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index f060607..c3c7f04 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -22,6 +22,7 @@ package com.aryan.reader import android.os.Build import timber.log.Timber import androidx.annotation.RequiresApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -69,6 +70,36 @@ fun AppNavigation( Timber.d("AppNavigation composable invoked.") 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) { composable(AppDestinations.MAIN_ROUTE) { Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") @@ -77,39 +108,6 @@ fun AppNavigation( windowSizeClass = windowSizeClass, 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 @@ -119,37 +117,53 @@ fun AppNavigation( val initialPage = uiState.initialPageInBook 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) { Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage") - PdfViewerScreen( - pdfUri = pdfUri, - initialPage = initialPage, - initialBookmarksJson = initialBookmarksJson, - isProUser = uiState.isProUser, - onNavigateBack = { - Timber.d("Back action triggered from PDF Viewer.") - viewModel.clearSelectedFile() - }, - onSavePosition = viewModel::savePdfReadingPosition, - onBookmarksChanged = { bookmarksJson -> - if (bookId != null) { - viewModel.saveBookmarks(bookId, bookmarksJson) - } else { - Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}") + Box(modifier = Modifier.fillMaxSize()) { + PdfViewerScreen( + pdfUri = pdfUri, + initialPage = initialPage, + initialBookmarksJson = initialBookmarksJson, + isProUser = uiState.isProUser, + onNavigateBack = { + Timber.d("Back action triggered from PDF Viewer.") + viewModel.clearSelectedFile() + }, + onSavePosition = viewModel::savePdfReadingPosition, + onBookmarksChanged = { bookmarksJson -> + if (bookId != null) { + viewModel.saveBookmarks(bookId, bookmarksJson) + } else { + Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}") + } + }, + onNavigateToPro = { + navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) + }, + viewModel = viewModel + ) + + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - }, - onNavigateToPro = { - navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) - }, - viewModel = viewModel - ) + } + } + } 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 { 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,40 +186,53 @@ fun AppNavigation( uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() - EpubReaderScreen( - epubBook = epubBook, - renderMode = renderMode, - initialLocator = initialLocator, - initialCfi = initialCfi, - initialBookmarksJson = initialBookmarksJson, - isProUser = uiState.isProUser, - coverImagePath = coverPath, - onNavigateBack = { - Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.") - viewModel.clearSelectedFile() - }, - onSavePosition = { locator, cfiForWebView, progress -> - Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%") - epubUri?.let { uri -> - viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress) + Box(modifier = Modifier.fillMaxSize()) { + EpubReaderScreen( + epubBook = epubBook, + renderMode = renderMode, + initialLocator = initialLocator, + initialCfi = initialCfi, + initialBookmarksJson = initialBookmarksJson, + isProUser = uiState.isProUser, + coverImagePath = coverPath, + onNavigateBack = { + Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.") + viewModel.clearSelectedFile() + }, + onSavePosition = { locator, cfiForWebView, progress -> + Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%") + epubUri?.let { uri -> + viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress) + } + }, + onBookmarksChanged = { bookmarksJson -> + val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId + if (bookId != null) { + viewModel.saveBookmarks(bookId, bookmarksJson) + } else { + Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}") + } + }, + onNavigateToPro = { + navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) + }, + onRenderModeChange = viewModel::setRenderMode, + customFonts = customFonts, + onImportFont = viewModel::importFont, + viewModel = viewModel + ) + + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - }, - onBookmarksChanged = { bookmarksJson -> - val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId - if (bookId != null) { - viewModel.saveBookmarks(bookId, bookmarksJson) - } else { - Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}") - } - }, - onNavigateToPro = { - navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) - }, - onRenderModeChange = viewModel::setRenderMode, - customFonts = customFonts, - onImportFont = viewModel::importFont, - viewModel = viewModel - ) + } + } } isLoading -> { Timber.d("EPUB Reader: Showing loading indicator.") @@ -231,9 +258,6 @@ fun AppNavigation( } else -> { 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) - } } } } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index f896b5a..efba026 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -80,15 +80,19 @@ import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.VirtualPage import com.tom_roush.pdfbox.android.PDFBoxResourceLoader +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -118,6 +122,10 @@ data class UserData( 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) { UNSHELVED("Unshelved"), ALL_BOOKS("All Books") } @@ -137,9 +145,7 @@ data class DeviceLimitReachedState( ) data class SyncedFolder( - val uriString: String, - val name: String, - val lastScanTime: Long + val uriString: String, val name: String, val lastScanTime: Long ) data class Shelf(val name: String, val books: List) { @@ -235,6 +241,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext) private val pdfTextBoxRepository = PdfTextBoxRepository(appContext) private val pdfHighlightRepository = PdfHighlightRepository(appContext) + private val _navigationEvent = Channel(Channel.BUFFERED) + @Suppress("unused") + val navigationEvent = _navigationEvent.receiveAsFlow() + private var pendingSwitchDeferred: CompletableDeferred? = null data class PageModificationResult( val layout: List, @@ -281,17 +291,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false), syncedFolders = loadSyncedFoldersFromPrefs(), lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( - KEY_LAST_FOLDER_SCAN_TIME, - 0L + KEY_LAST_FOLDER_SCAN_TIME, 0L ) else null ) ) open val uiState: StateFlow = combine( - _internalState, - recentFilesRepository.getRecentFilesFlow(), - _prefsUpdateFlow + _internalState, recentFilesRepository.getRecentFilesFlow(), _prefsUpdateFlow ) { internalState, recentFilesFromDb, _ -> val validContextualItems = internalState.contextualActionItems.filter { contextItem -> recentFilesFromDb.any { dbItem -> @@ -311,8 +318,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } else { recentFilesFromDb.filter { item -> item.displayName.contains(query, ignoreCase = true) || item.title?.contains( - query, - ignoreCase = true + query, ignoreCase = true ) == true || item.author?.contains(query, ignoreCase = true) == true } } @@ -601,7 +607,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } 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 { recentFilesRepository.detachAllFolderBooks() @@ -616,7 +623,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun getDisplayPathFromUri(context: Context, uriString: String): String { val uri = uriString.toUri() 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 split = documentId.split(":") if (split.size > 1) { @@ -1129,20 +1138,20 @@ 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. Skipping Firestore sync to prevent data loss.") + Timber.tag("AnnotationSync") + .e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.") return@launch } } } - } else { + } else { Timber.tag("AnnotationSync") .d("No local data (ink/text/layout) to upload for ${book.bookId}") } val newTimestamp = System.currentTimeMillis() val metadataToSync = book.toBookMetadata().copy( - lastModifiedTimestamp = newTimestamp, - hasAnnotations = hasAnyData + lastModifiedTimestamp = newTimestamp, hasAnnotations = hasAnyData ) firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId) @@ -1165,7 +1174,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio recentFilesRepository.markAsNotRecent(bookIdsToHide) _internalState.update { it.copy(contextualActionItems = emptySet()) } - if (uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext)) { + if (uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions( + appContext + ) + ) { bookIdsToHide.forEach { bookId -> val updatedItem = recentFilesRepository.getFileByBookId(bookId) if (updatedItem != null) { @@ -1246,7 +1258,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } 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 { recentFilesRepository.syncLocalMetadataToFolder(it.bookId) recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId) @@ -1342,7 +1355,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio viewModelScope.launch { try { 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()) @@ -1351,18 +1365,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio saveSyncedFoldersToPrefs(newStats) - _internalState.update { it.copy( - syncedFolders = newStats, - showFolderMigrationDialog = false - ) } + _internalState.update { + it.copy( + syncedFolders = newStats, showFolderMigrationDialog = false + ) + } scanSyncedFolder() val workManager = WorkManager.getInstance(appContext) val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() - val syncRequest = PeriodicWorkRequestBuilder(4, TimeUnit.HOURS) - .setConstraints(constraints) - .build() + val syncRequest = + PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).setConstraints( + constraints + ).build() workManager.enqueueUniquePeriodicWork( FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest ) @@ -1415,21 +1431,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val folders = _internalState.value.syncedFolders 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 data = androidx.work.Data.Builder() - .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly) - .build() + .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly).build() - val request = OneTimeWorkRequestBuilder() - .setInputData(data) - .build() + val request = OneTimeWorkRequestBuilder().setInputData(data).build() workManager.enqueueUniqueWork( - FolderSyncWorker.WORK_NAME_ONETIME, - ExistingWorkPolicy.REPLACE, - request + FolderSyncWorker.WORK_NAME_ONETIME, ExistingWorkPolicy.REPLACE, request ) viewModelScope.launch { @@ -1438,29 +1450,39 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio when (workInfo.state) { WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { 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) - ) } + 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 = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage, - lastFolderScanTime = System.currentTimeMillis() - ) } + _internalState.update { + it.copy( + isLoading = false, + isRefreshing = false, + bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage, + lastFolderScanTime = System.currentTimeMillis() + ) + } } + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - _internalState.update { it.copy( - isLoading = false, - isRefreshing = false, - errorMessage = if (showFeedback) "Sync failed." else it.errorMessage - ) } + _internalState.update { + it.copy( + isLoading = false, + isRefreshing = false, + errorMessage = if (showFeedback) "Sync failed." else it.errorMessage + ) + } } + else -> Unit } } @@ -1477,7 +1499,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio appContext.contentResolver.releasePersistableUriPermission( folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION ) - } catch (_: Exception) {} + } catch (_: Exception) { + } } prefs.edit { @@ -1772,7 +1795,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isLimitReached = true, registeredDevices = deviceItems.sortedByDescending { item -> item.lastSeen - })) + }) + ) } } ?: run { 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 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 fileLastModified = maxOf( @@ -1928,7 +1953,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio textBoxFile.lastModified(), highlightFile.lastModified() ) - val isFileStale = remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified) + val isFileStale = + remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified) if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) { Timber.tag("AnnotationSync").d("Triggering download for $bookId.") @@ -1949,9 +1975,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio when { local != null && remote == null -> firestoreRepository.syncShelf( - currentUser.uid, - local, - deviceId + currentUser.uid, local, deviceId ) local == null && remote != null -> { @@ -2091,8 +2115,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File( - appContext.filesDir, - "annotations/annotation_$bookId.json" + appContext.filesDir, "annotations/annotation_$bookId.json" ) val richTextFile = pdfRichTextRepository.getFileForSync(bookId) val layoutFile = pageLayoutRepository.getLayoutFile(bookId) @@ -2149,15 +2172,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio sourceFolderUri: String? = null ) = withContext(Dispatchers.IO) { 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) { recentFilesRepository.getFileByBookId(bookId) == null } val existingItem = recentFilesRepository.getFileByBookId(bookId) val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri( - uri, - appContext + uri, appContext ) ?: "Unknown File" 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)) { 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() try { importMutex.withLock { @@ -2184,7 +2208,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio FileType.MOBI -> { 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) { Timber.e( e, @@ -2208,13 +2234,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) 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 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 { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) @@ -2351,45 +2379,193 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - val reflowWorkInfo: Flow = WorkManager.getInstance(appContext) - .getWorkInfosByTagFlow(ReflowWorker.WORK_NAME) - .map { list -> - list.find { !it.state.isFinished } ?: list.firstOrNull() - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + val reflowWorkInfo: Flow = + WorkManager.getInstance(appContext).getWorkInfosByTagFlow(ReflowWorker.WORK_NAME) + .map { list -> + list.find { !it.state.isFinished } ?: list.firstOrNull() + }.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() + 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" viewModelScope.launch { val existing = recentFilesRepository.getFileByBookId(reflowBookId) if (existing != null) { showBanner("Opening existing text view...") - onRecentFileClicked(existing) + if (autoOpenPage != null) { + switchToFileSeamlessly(existing, autoOpenPage) + } else { + onRecentFileClicked(existing) + } return@launch } val workManager = WorkManager.getInstance(appContext) - val inputData = androidx.work.Data.Builder() - .putString(ReflowWorker.KEY_BOOK_ID, pdfBookId) - .putString(ReflowWorker.KEY_PDF_URI, pdfUri.toString()) - .putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle) - .build() + val inputData = + androidx.work.Data.Builder().putString(ReflowWorker.KEY_BOOK_ID, pdfBookId) + .putString(ReflowWorker.KEY_PDF_URI, pdfUri.toString()) + .putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle).build() - val request = OneTimeWorkRequestBuilder() - .setInputData(inputData) - .addTag(ReflowWorker.WORK_NAME) - .addTag("book_$pdfBookId") - .build() + val request = OneTimeWorkRequestBuilder().setInputData(inputData) + .addTag(ReflowWorker.WORK_NAME).addTag("book_$pdfBookId").build() workManager.enqueueUniqueWork( - "reflow_$pdfBookId", - ExistingWorkPolicy.KEEP, - request + "reflow_$pdfBookId", 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 ) { 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 { 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 size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L 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) { @@ -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 { it.copy( selectedPdfUri = uri, @@ -2465,7 +2644,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 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 = if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { Locator( @@ -2497,10 +2677,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio else -> { loadSingleFile( - uri, - bookId, - type, - customDisplayName = originalDisplayName + uri, 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() Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") viewModelScope.launch { @@ -2527,15 +2709,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio inputStream, type, originalBookNameHint = customDisplayName ?: getFileNameFromUri( - uri, - appContext + uri, appContext ) ?: "unknown_doc", 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}") addFileToRecent( uri, @@ -2548,7 +2730,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) _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) { Timber.e(e, "Error parsing file ($type) for URI: $uri") _internalState.update { @@ -2576,20 +2759,52 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio "text/markdown", "text/x-markdown" -> FileType.MD "text/html", "application/xhtml+xml" -> FileType.HTML "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 } else { FileType.TXT } } + else -> { when { fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF 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(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true -> FileType.MD + fileName?.endsWith( + ".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(".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 } } @@ -2611,8 +2826,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio mobiParser.createMobiBook( inputStream, originalBookNameHint = customDisplayName ?: getFileNameFromUri( - uri, - appContext + uri, appContext ) ?: "unknown.mobi" ) } @@ -2663,14 +2877,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio epubParser.createEpubBook( inputStream, originalBookNameHint = customDisplayName ?: getFileNameFromUri( - uri, - appContext + uri, appContext ) ?: "unknown.epub" ) } } 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( uri, @@ -2683,7 +2897,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) _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) { Timber.e(e, "Error parsing EPUB for URI: $uri") _internalState.update { @@ -2748,9 +2963,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio viewModelScope.launch { recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> recentFilesRepository.updatePdfReadingPosition( - uriString = currentPdfUri.toString(), - page = page, - progress = progress + uriString = currentPdfUri.toString(), page = page, progress = progress ) } } @@ -2815,10 +3028,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val exists = try { val uri = item.uriString.toUri() DocumentFile.fromSingleUri(appContext, uri)?.exists() == true - } catch (_: Exception) { false } + } catch (_: Exception) { + false + } 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)) showBanner("File deleted from folder. Removed from library.") return@launch @@ -3238,7 +3454,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(contextualActionItems = emptySet()) } 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 } @@ -3251,6 +3470,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio idsToDeleteLocally.add(item.bookId) pdfTextRepository.clearBookText(item.bookId) + clearImportedFileCache(item.bookId) + if (item.uriString != null) { try { val fileUri = item.uriString.toUri() @@ -3280,7 +3501,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio hiddenMeta?.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) { Timber.e(e, "Error deleting metadata file for ${item.bookId}") @@ -3302,8 +3524,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } try { - val accessToken = googleDriveRepository.getAccessToken(appContext) - ?: throw Exception("No token") + val accessToken = + googleDriveRepository.getAccessToken(appContext) ?: throw Exception( + "No token" + ) val deviceId = getInstallationId() val remoteFiles = withContext(Dispatchers.IO) { @@ -3314,9 +3538,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio for (item in managedBooks) { recentFilesRepository.markAsDeleted(listOf(item.bookId)) pdfTextRepository.clearBookText(item.bookId) + clearImportedFileCache(item.bookId) firestoreRepository.syncBookMetadata( - currentUser.uid, item.toBookMetadata().copy(isDeleted = true), deviceId + currentUser.uid, + item.toBookMetadata().copy(isDeleted = true), + deviceId ) val fileExtension = item.type.name.lowercase() @@ -3330,26 +3557,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } _internalState.update { - it.copy(isLoading = false, bannerMessage = BannerMessage("Deletion complete.")) + it.copy( + isLoading = false, + bannerMessage = BannerMessage("Deletion complete.") + ) } } catch (e: Exception) { Timber.e(e, "Error during permanent deletion") recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) managedBooks.forEach { item -> + clearImportedFileCache(item.bookId) pdfTextRepository.clearBookText(item.bookId) } _internalState.update { - it.copy(isLoading = false, errorMessage = "Cloud sync failed, deleted locally.") + it.copy( + isLoading = false, + errorMessage = "Cloud sync failed, deleted locally." + ) } } } else { 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 - _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 { Timber.w("Attempted to remove contextual items, but none were selected.") @@ -3357,9 +3599,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun navigateToFolderSync() { - // 1. Switch MainScreen to Library Tab (Index 1) setMainScreenPage(1) - // 2. Switch LibraryScreen to Folder Tab (Index 2) setLibraryScreenPage(2) } @@ -3370,70 +3610,74 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("ViewModel instance cleared (onCleared).") } - suspend fun checkAndMigrateLegacyBookId(legacyId: String, newId: String) = withContext(Dispatchers.IO) { - if (legacyId == newId) return@withContext - Timber.tag("FolderAnnotationSync").d("Checking migration from legacyId=$legacyId to newId=$newId") + suspend fun checkAndMigrateLegacyBookId(legacyId: String, newId: String) = + withContext(Dispatchers.IO) { + if (legacyId == newId) return@withContext + Timber.tag("FolderAnnotationSync") + .d("Checking migration from legacyId=$legacyId to newId=$newId") - try { - fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) { - if (legacyFile != null && legacyFile.exists()) { - if (newFile != null) { - if (newFile.exists()) { - val legacyTs = legacyFile.lastModified() - val newTs = newFile.lastModified() + try { + fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) { + if (legacyFile != null && legacyFile.exists()) { + if (newFile != null) { + if (newFile.exists()) { + val legacyTs = legacyFile.lastModified() + val newTs = newFile.lastModified() - if (newTs > legacyTs) { - Timber.tag("FolderAnnotationSync").i("Skipping migration for $tag: Destination ($newId) is newer than Legacy ($legacyId). Deleting legacy.") - legacyFile.delete() - return - } else { - newFile.delete() + if (newTs > legacyTs) { + Timber.tag("FolderAnnotationSync") + .i("Skipping migration for $tag: Destination ($newId) is newer than Legacy ($legacyId). Deleting legacy.") + legacyFile.delete() + return + } else { + newFile.delete() + } } - } - if (legacyFile.renameTo(newFile)) { - Timber.tag("FolderAnnotationSync").i("Migrated $tag successfully.") + if (legacyFile.renameTo(newFile)) { + Timber.tag("FolderAnnotationSync").i("Migrated $tag successfully.") + } else { + Timber.tag("FolderAnnotationSync").w("Failed to rename $tag file.") + } } else { - Timber.tag("FolderAnnotationSync").w("Failed to rename $tag file.") + Timber.tag("FolderAnnotationSync") + .w("Destination file for $tag is null. Skipping.") } - } else { - Timber.tag("FolderAnnotationSync").w("Destination file for $tag is null. Skipping.") } } + + // 1. Annotations + safeMigrate( + pdfAnnotationRepository.getAnnotationFileForSync(legacyId), + pdfAnnotationRepository.getAnnotationFileForSync(newId), + "annotations" + ) + + // 2. Rich Text + safeMigrate( + pdfRichTextRepository.getFileForSync(legacyId), + pdfRichTextRepository.getFileForSync(newId), + "rich text" + ) + + // 3. Layout + safeMigrate( + pageLayoutRepository.getLayoutFile(legacyId), + pageLayoutRepository.getLayoutFile(newId), + "layout" + ) + + // 4. Text Boxes + safeMigrate( + pdfTextBoxRepository.getFileForSync(legacyId), + pdfTextBoxRepository.getFileForSync(newId), + "text boxes" + ) + + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error migrating legacy book data") } - - // 1. Annotations - safeMigrate( - pdfAnnotationRepository.getAnnotationFileForSync(legacyId), - pdfAnnotationRepository.getAnnotationFileForSync(newId), - "annotations" - ) - - // 2. Rich Text - safeMigrate( - pdfRichTextRepository.getFileForSync(legacyId), - pdfRichTextRepository.getFileForSync(newId), - "rich text" - ) - - // 3. Layout - safeMigrate( - pageLayoutRepository.getLayoutFile(legacyId), - pageLayoutRepository.getLayoutFile(newId), - "layout" - ) - - // 4. Text Boxes - safeMigrate( - pdfTextBoxRepository.getFileForSync(legacyId), - pdfTextBoxRepository.getFileForSync(newId), - "text boxes" - ) - - } catch (e: Exception) { - Timber.tag("FolderAnnotationSync").e(e, "Error migrating legacy book data") } - } fun clearReflowCache() { viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt index 9974729..cf97922 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt @@ -118,7 +118,7 @@ fun DictionarySettingsDialog( color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface ) Text( - text = "Contextual definitions powered by AI.", + text = "Definitions powered by AI.", style = MaterialTheme.typography.bodySmall, color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index a52c9a4..fbc9163 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -337,11 +337,11 @@ fun EpubReaderScreen( val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true 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 } if (originalItem != null) { - viewModel.onRecentFileClicked(originalItem) + viewModel.switchToFileSeamlessly(originalItem, currentChapter) } else { viewModel.showBanner("Original PDF not found.", true) } @@ -388,7 +388,7 @@ fun EpubReaderHost( onRenderModeChange: (RenderMode) -> Unit, customFonts: List, onImportFont: (Uri) -> Unit, - onToggleReflow: (() -> Unit)? = null + onToggleReflow: ((Int) -> Unit)? = null ) { val view = LocalView.current val context = LocalContext.current @@ -3114,7 +3114,16 @@ fun EpubReaderHost( onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = 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( diff --git a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt new file mode 100644 index 0000000..6aef742 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt @@ -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? +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt index 4c21a4b..3fdd119 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt @@ -3,11 +3,9 @@ package com.aryan.reader.pdf import android.content.Context import android.net.Uri -import com.tom_roush.pdfbox.io.MemoryUsageSetting -import com.tom_roush.pdfbox.pdmodel.PDDocument -import com.tom_roush.pdfbox.pdmodel.PDPage -import com.tom_roush.pdfbox.text.PDFTextStripper -import com.tom_roush.pdfbox.text.TextPosition +import io.legere.pdfiumandroid.PdfiumCore +import io.legere.pdfiumandroid.suspend.PdfiumCoreKt +import io.legere.pdfiumandroid.suspend.PdfDocumentKt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -15,8 +13,6 @@ import java.io.File import kotlin.math.roundToInt object PdfToMarkdownGenerator { - - // Unique delimiter to split pages reliably const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n" suspend fun generateMarkdownFile( @@ -26,109 +22,252 @@ object PdfToMarkdownGenerator { startPage: Int = 1, onProgress: (Float) -> Unit ): 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 { - context.contentResolver.openInputStream(pdfUri)?.use { inputStream -> - // Setup mixed memory usage to handle larger files without OOM - PDDocument.load(inputStream, MemoryUsageSetting.setupMixed(50 * 1024 * 1024)).use { doc -> - val totalPages = doc.numberOfPages + val doc = pdfiumCore.newDocument(pfd) + val totalPages = doc.getPageCount() + Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages") - // Configure stripper for linear processing - val stripper = MarkdownStripper(totalPages, onProgress) - stripper.startPage = startPage - stripper.endPage = totalPages + destFile.bufferedWriter().use { writer -> + for (pageIdx in (startPage - 1) until totalPages) { + val pageMd = extractPageMarkdown(doc, pageIdx) + writer.write(pageMd) + writer.write(PAGE_DELIMITER) - // Write directly to file stream (O(N) complexity) - destFile.bufferedWriter().use { writer -> - stripper.writeText(doc, writer) + 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 } 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 } } - private class MarkdownStripper( - private val totalPages: Int, - private val onProgress: (Float) -> Unit - ) : PDFTextStripper() { - private var currentPageBaseFontSize = 0f + private suspend fun extractPageMarkdown(doc: PdfDocumentKt, pageIdx: Int): String { + return try { + doc.openPage(pageIdx).use { page -> + page.openTextPage().use { textPage -> + val charCount = textPage.textPageCountChars() + if (charCount <= 0) return@use "" - init { - sortByPosition = true - suppressDuplicateOverlappingText = true - paragraphStart = "" - paragraphEnd = "\n\n" - } + val text = textPage.textPageGetText(0, charCount) ?: "" + val actualCount = minOf(charCount, text.length) - // Override endPage to update progress and insert delimiter - override fun endPage(page: PDPage?) { - super.endPage(page) + val rawPtr = textPage.page.pagePtr - try { - // Insert our custom delimiter so importer can split chapters - output.write(PAGE_DELIMITER) + val sizes: FloatArray? + val weights: IntArray? + val flags: IntArray? - // Update progress - val current = currentPageNo // inherited from PDFTextStripper - if (totalPages > 0) { - onProgress(current.toFloat() / totalPages.toFloat()) + synchronized(PdfiumCore.lock) { + sizes = NativePdfiumBridge.getPageFontSizes(rawPtr, actualCount) + weights = NativePdfiumBridge.getPageFontWeights(rawPtr, actualCount) + flags = NativePdfiumBridge.getPageFontFlags(rawPtr, actualCount) + } + + if (sizes == null || weights == null || flags == null) { + return@use text + } + + buildMarkdown(text, sizes, weights, flags, actualCount) } - } catch (e: Exception) { - Timber.e(e, "Error writing page delimiter") } - } - - override fun startPage(page: PDPage?) { - currentPageBaseFontSize = 0f - super.startPage(page) - } - - private fun calculateBaseFontSize(textPositions: List) { - val sizeCounts = mutableMapOf() - textPositions.forEach { pos -> - val size = pos.fontSizeInPt.roundToInt().toFloat() - sizeCounts[size] = (sizeCounts[size] ?: 0) + 1 - } - currentPageBaseFontSize = sizeCounts.maxByOrNull { it.value }?.key ?: 12f - } - - override fun writeString(text: String?, textPositions: MutableList?) { - if (text.isNullOrBlank() || textPositions.isNullOrEmpty()) return - - if (currentPageBaseFontSize == 0f) { - calculateBaseFontSize(textPositions) - } - - val firstPos = textPositions[0] - val fontSize = firstPos.fontSizeInPt - val fontDescriptor = firstPos.font?.fontDescriptor - - val isBold = fontDescriptor?.isForceBold == true || - (firstPos.font?.name?.contains("Bold", ignoreCase = true) == true) - val isItalic = fontDescriptor?.isItalic == true || - (firstPos.font?.name?.contains("Italic", ignoreCase = true) == true) - - // Header detection logic - val isHeader = fontSize > currentPageBaseFontSize * 1.2 - val isBigHeader = fontSize > currentPageBaseFontSize * 1.5 - - val sb = StringBuilder() - - if (isBigHeader) sb.append("## ") - else if (isHeader) sb.append("### ") - - if (isBold && !isHeader) sb.append("**") - if (isItalic) sb.append("*") - - text.forEach { char -> sb.append(char) } - - if (isItalic) sb.append("*") - if (isBold && !isHeader) sb.append("**") - - writeString(sb.toString()) + } catch (e: Exception) { + Timber.w(e, "Error extracting page $pageIdx") + "" } } + + private data class TextSpan( + val text: String, + val size: Float, + val isBold: Boolean, + val isItalic: Boolean + ) + + private data class TextLine( + val spans: List + ) + + 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 buildMarkdown(text: String, sizes: FloatArray, weights: IntArray, flags: IntArray, count: Int): String { + if (count == 0) return "" + + val sizeFrequency = HashMap() + for (i in 0 until count) { + val s = sizes[i].roundToInt() + sizeFrequency[s] = (sizeFrequency[s] ?: 0) + 1 + } + val baseSize = sizeFrequency.maxByOrNull { it.value }?.key ?: 12 + + val lines = mutableListOf() + @Suppress("CanBeVal") var currentSpans = mutableListOf() + 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 + } + + val isSpace = c.isWhitespace() + val size = sizes[i] + val bold = weights[i] > 600 + val italic = (flags[i] and 64) != 0 + + if (currentSpanText.isEmpty()) { + 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) + } + } + + if (currentSpanText.isNotEmpty()) { + currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic)) + } + if (currentSpans.isNotEmpty()) { + lines.add(TextLine(currentSpans)) + } + + val validLines = lines.filter { it.spans.isNotEmpty() } + val lineLengths = validLines.map { line -> line.spans.sumOf { it.text.length } }.filter { it > 10 }.sorted() + + val typicalLineLen = if (lineLengths.isNotEmpty()) { + lineLengths[(lineLengths.size * 0.8).toInt().coerceAtMost(lineLengths.size - 1)] + } else { + 80 + } + + val wrapThreshold = (typicalLineLen * 0.85).toInt() + + val sb = StringBuilder() + + for (i in lines.indices) { + val line = lines[i] + if (line.spans.isEmpty()) { + sb.append("\n") + continue + } + + 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() + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 29db9b7..8788715 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -27,11 +27,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context 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 kotlin.math.max import android.graphics.RectF import android.net.Uri import android.os.Build @@ -59,8 +55,11 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border 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.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -137,10 +136,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -286,6 +283,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.net.HttpURLConnection import java.net.URL +import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import kotlin.random.Random @@ -1845,26 +1843,6 @@ fun PdfViewerScreen( 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(currentBookId) { @@ -4979,13 +4957,14 @@ fun PdfViewerScreen( if (hasReflowFile) { val item = uiState.recentFiles.find { it.bookId == reflowBookId } if (item != null) { - viewModel.onRecentFileClicked(item) + viewModel.switchToFileSeamlessly(item, currentPage) } } else { viewModel.generateAndImportReflowFile( pdfBookId = bookId, pdfUri = pdfUri, - originalTitle = originalFileName + originalTitle = originalFileName, + autoOpenPage = currentPage ) } }, diff --git a/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt b/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt index 09c7d0d..d2b1820 100644 --- a/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt +++ b/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt @@ -20,29 +20,49 @@ class ReflowWorker( ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure() - val pdfUriString = inputData.getString(KEY_PDF_URI) ?: return@withContext Result.failure() + val workStartTime = System.currentTimeMillis() + 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 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 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( applicationContext, pdfUri, destFile, - startPage = 1 // Always start from beginning for full regeneration + startPage = 1 ) { progress -> - // Report progress + if ((progress * 10).toInt() % 1 == 0) { + Timber.tag("PdfToMdPerf").d("Progress: ${(progress * 100).toInt()}%") + } setProgressAsync(workDataOf(KEY_PROGRESS to progress)) } + Timber.tag("PdfToMdPerf").d("generateMarkdownFile completed | success=$success | time=${System.currentTimeMillis() - genStartTime}ms") + 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) @@ -63,13 +83,16 @@ class ReflowWorker( ) repo.addRecentFile(newItem) + Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms") - // 100% Progress 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() } 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() } }