Pdf reflow rework (#51)

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

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

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

* Improved PDF to Markdown conversion and added cache cleanup.

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

* Implemented seamless file switching and enhanced reflow transition logic.

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

View file

@ -83,7 +83,7 @@ This project is licensed under the **GNU Affero General Public License v3.0 (AGP
## Support
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"/>

View file

@ -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
View file

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

View file

@ -22,6 +22,7 @@ package com.aryan.reader
import android.os.Build
import 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)
}
}
}
}

View file

@ -80,15 +80,19 @@ import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.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.")
}
}
}

View file

@ -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
)

View file

@ -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(

View file

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

View file

@ -3,11 +3,9 @@ package com.aryan.reader.pdf
import android.content.Context
import android.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("### ")
for (i in lines.indices) {
val line = lines[i]
if (line.spans.isEmpty()) {
sb.append("\n")
continue
}
if (isBold && !isHeader) sb.append("**")
if (isItalic) sb.append("*")
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
text.forEach { char -> sb.append(char) }
var prefix = ""
if (charBigHeader) prefix = "## "
else if (charHeader) prefix = "### "
if (isItalic) sb.append("*")
if (isBold && !isHeader) sb.append("**")
val rawLineText = line.spans.joinToString("") { it.text }
val trimmedRaw = rawLineText.trim()
val lineLen = trimmedRaw.length
writeString(sb.toString())
val isList = trimmedRaw.startsWith("") ||
trimmedRaw.startsWith("- ") ||
trimmedRaw.startsWith("") ||
trimmedRaw.matches(Regex("^[0-9]+\\.\\s.*")) ||
trimmedRaw.matches(Regex("^[a-zA-Z]\\)\\s.*"))
if (prefix.isNotEmpty() && !isList) {
sb.append(prefix)
}
for (span in line.spans) {
var spanText = span.text
spanText = fixKerning(spanText)
val leadingSpaces = spanText.takeWhile { it.isWhitespace() }
val trailingSpaces = spanText.takeLastWhile { it.isWhitespace() }
val trimmedText = spanText.trim()
if (trimmedText.isEmpty()) {
sb.append(spanText)
continue
}
sb.append(leadingSpaces)
var tag = ""
if (span.isBold && span.isItalic) tag = "***"
else if (span.isBold) tag = "**"
else if (span.isItalic) tag = "*"
sb.append(tag).append(trimmedText).append(tag)
sb.append(trailingSpaces)
}
var isParagraphBreak = false
if (prefix.isNotEmpty() || isList) {
isParagraphBreak = true
} else if (lineLen < wrapThreshold) {
isParagraphBreak = true
} else if (trimmedRaw.matches(Regex(".*[.!?\"'”’;:*]$"))) {
isParagraphBreak = true
} else {
val nextLine = lines.subList(i + 1, lines.size).firstOrNull { it.spans.isNotEmpty() }
if (nextLine != null) {
val nextRaw = nextLine.spans.joinToString("") { it.text }.trimStart()
if (nextRaw.startsWith("\"") || nextRaw.startsWith("") || nextRaw.startsWith("-")) {
isParagraphBreak = true
}
}
}
if (isParagraphBreak) {
sb.append("\n\n")
} else {
sb.append("\n")
}
}
return sb.toString().replace(Regex("\\n{3,}"), "\n\n").trim()
}
}

View file

@ -27,11 +27,7 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.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
)
}
},

View file

@ -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()
}
}