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:
parent
acf282d4c7
commit
c61a264a65
11 changed files with 940 additions and 419 deletions
|
|
@ -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!
|
||||
|
||||
<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"/>
|
||||
|
|
|
|||
11
app/src/main/cpp/CMakeLists.txt
vendored
11
app/src/main/cpp/CMakeLists.txt
vendored
|
|
@ -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
|
||||
)
|
||||
88
app/src/main/cpp/pdfium_bridge.cpp
vendored
Normal file
88
app/src/main/cpp/pdfium_bridge.cpp
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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,10 +117,12 @@ 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")
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
PdfViewerScreen(
|
||||
pdfUri = pdfUri,
|
||||
initialPage = initialPage,
|
||||
|
|
@ -145,11 +145,25 @@ fun AppNavigation(
|
|||
},
|
||||
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 {
|
||||
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
|
||||
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EpubReaderScreen(
|
||||
epubBook = epubBook,
|
||||
renderMode = renderMode,
|
||||
|
|
@ -206,6 +221,18 @@ fun AppNavigation(
|
|||
onImportFont = viewModel::importFont,
|
||||
viewModel = viewModel
|
||||
)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RecentFileItem>) {
|
||||
|
|
@ -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<NavigationEvent>(Channel.BUFFERED)
|
||||
@Suppress("unused")
|
||||
val navigationEvent = _navigationEvent.receiveAsFlow()
|
||||
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
|
||||
|
||||
data class PageModificationResult(
|
||||
val layout: List<VirtualPage>,
|
||||
|
|
@ -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<ReaderScreenState> = 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,7 +1138,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.tag("AnnotationSync")
|
||||
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
|
||||
} else {
|
||||
Timber.tag("AnnotationSync").e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
|
||||
Timber.tag("AnnotationSync")
|
||||
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
|
@ -1141,8 +1151,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
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<FolderSyncWorker>(4, TimeUnit.HOURS)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
val syncRequest =
|
||||
PeriodicWorkRequestBuilder<FolderSyncWorker>(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<FolderSyncWorker>()
|
||||
.setInputData(data)
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>().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(
|
||||
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(
|
||||
_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(
|
||||
_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<WorkInfo?> = WorkManager.getInstance(appContext)
|
||||
.getWorkInfosByTagFlow(ReflowWorker.WORK_NAME)
|
||||
val reflowWorkInfo: Flow<WorkInfo?> =
|
||||
WorkManager.getInstance(appContext).getWorkInfosByTagFlow(ReflowWorker.WORK_NAME)
|
||||
.map { list ->
|
||||
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"
|
||||
|
||||
viewModelScope.launch {
|
||||
val existing = recentFilesRepository.getFileByBookId(reflowBookId)
|
||||
if (existing != null) {
|
||||
showBanner("Opening existing text view...")
|
||||
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)
|
||||
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()
|
||||
.putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle).build()
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<ReflowWorker>()
|
||||
.setInputData(inputData)
|
||||
.addTag(ReflowWorker.WORK_NAME)
|
||||
.addTag("book_$pdfBookId")
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<ReflowWorker>().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,9 +3610,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
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
|
||||
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 {
|
||||
fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) {
|
||||
|
|
@ -3383,7 +3625,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val newTs = newFile.lastModified()
|
||||
|
||||
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()
|
||||
return
|
||||
} else {
|
||||
|
|
@ -3397,7 +3640,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.tag("FolderAnnotationSync").w("Failed to rename $tag file.")
|
||||
}
|
||||
} else {
|
||||
Timber.tag("FolderAnnotationSync").w("Destination file for $tag is null. Skipping.")
|
||||
Timber.tag("FolderAnnotationSync")
|
||||
.w("Destination file for $tag is null. Skipping.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<CustomFontEntity>,
|
||||
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(
|
||||
|
|
|
|||
14
app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt
Normal file
14
app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt
Normal 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?
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
// Write directly to file stream (O(N) complexity)
|
||||
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
|
||||
} 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)
|
||||
|
||||
val rawPtr = textPage.page.pagePtr
|
||||
|
||||
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
|
||||
override fun endPage(page: PDPage?) {
|
||||
super.endPage(page)
|
||||
if (sizes == null || weights == null || flags == null) {
|
||||
return@use text
|
||||
}
|
||||
|
||||
try {
|
||||
// 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())
|
||||
buildMarkdown(text, sizes, weights, flags, actualCount)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error writing page delimiter")
|
||||
Timber.w(e, "Error extracting page $pageIdx")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun startPage(page: PDPage?) {
|
||||
currentPageBaseFontSize = 0f
|
||||
super.startPage(page)
|
||||
private data class TextSpan(
|
||||
val text: String,
|
||||
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>) {
|
||||
val sizeCounts = mutableMapOf<Float, Int>()
|
||||
textPositions.forEach { pos ->
|
||||
val size = pos.fontSizeInPt.roundToInt().toFloat()
|
||||
sizeCounts[size] = (sizeCounts[size] ?: 0) + 1
|
||||
private fun buildMarkdown(text: String, sizes: FloatArray, weights: IntArray, flags: IntArray, count: Int): String {
|
||||
if (count == 0) return ""
|
||||
|
||||
val sizeFrequency = HashMap<Int, Int>()
|
||||
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>?) {
|
||||
if (text.isNullOrBlank() || textPositions.isNullOrEmpty()) return
|
||||
val isSpace = c.isWhitespace()
|
||||
val size = sizes[i]
|
||||
val bold = weights[i] > 600
|
||||
val italic = (flags[i] and 64) != 0
|
||||
|
||||
if (currentPageBaseFontSize == 0f) {
|
||||
calculateBaseFontSize(textPositions)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
val firstPos = textPositions[0]
|
||||
val fontSize = firstPos.fontSizeInPt
|
||||
val fontDescriptor = firstPos.font?.fontDescriptor
|
||||
if (currentSpanText.isNotEmpty()) {
|
||||
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
|
||||
}
|
||||
if (currentSpans.isNotEmpty()) {
|
||||
lines.add(TextLine(currentSpans))
|
||||
}
|
||||
|
||||
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)
|
||||
val validLines = lines.filter { it.spans.isNotEmpty() }
|
||||
val lineLengths = validLines.map { line -> line.spans.sumOf { it.text.length } }.filter { it > 10 }.sorted()
|
||||
|
||||
// Header detection logic
|
||||
val isHeader = fontSize > currentPageBaseFontSize * 1.2
|
||||
val isBigHeader = fontSize > currentPageBaseFontSize * 1.5
|
||||
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()
|
||||
|
||||
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())
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue