Folder import\sync rework (#18)

* feat: rework Folder Sync architecture to separate Managed vs Linked books

- Introduced "Managed" (imported to app storage) vs "Linked" (SAF URI) book logic.
- Disabled Google Drive/Metadata synchronization for Folder-Linked books to respect privacy and storage.
- Updated deletion logic: Folder books are now marked as deleted in the DB to blacklist them from future auto-scans, while Managed books are permanently purged from local and cloud storage.
- Enhanced folder sync discoverability by adding a direct "Sync Folder" navigation button in Home screen.
- Implemented reactive pager navigation in MainScreen and LibraryScreen via LaunchedEffect to allow programmatic tab switching from the ViewModel.

* Implemented a local folder synchronization system to track and update book metadata across devices.

Key changes:
- Added `FolderBookMetadata` and `LocalSyncUtils` for serializing and managing metadata files in a `.episteme` directory.
- Implemented bidirectional sync in `FolderSyncWorker` to reconcile local database state with folder-based metadata using timestamps and Syncthing conflict resolution.
- Updated `MainViewModel` to trigger folder metadata sync when closing a book and verify for remote updates upon opening.
- Enhanced folder management with manual scan support via `WorkManager` and automatic cleanup of database entries when a folder is disconnected.
- Added `RecentFileDao` methods to support bulk deletion and prefix-based file lookups.

* Removed API 34 restriction for chapter processing and updated local folder sync directory name

* Increased TTS timeouts, added edge-to-edge support in MainScreen, and improved TtsChunk mapping safety

* Improved folder synchronization and metadata management.

- Added metadata-only sync option and triggered it on app start.
- Implemented hidden metadata filenames (prefixed with `.`) to reduce file clutter.
- Added automatic creation of `.nomedia` files in the sync directory.
- Enhanced sync conflict resolution by picking the latest metadata and cleaning up obsolete or orphaned files.
- Added "Sync Metadata" button to the Library screen.
- Updated UI labels for local folder sync to clarify Google Drive integration.
- Refactored `FolderSyncWorker` to support targeted metadata-only synchronization.

* Implement folder sync migration and refactoring

- Added a migration dialog to inform users about the folder sync refactor, where books are now read directly from external storage.
- Updated `MainViewModel` to handle migration state and trigger a full scan upon completion or dismissal of the dialog.
- Modified `FolderSyncWorker` to support legacy book matching during migration, updating file URIs and cleaning up internal app storage for migrated books.
- Improved synchronization logic between local and remote metadata, including cleanup of orphaned metadata files.
- Refined `RecentFileItem` updates during sync to preserve progress and bookmarks based on last modified timestamps.

* Implemented pull-to-refresh for library sync and enhanced folder file deletion.

- Added `isRefreshing` state to `MainViewModel` and integrated `PullToRefreshBox` in `HomeScreen`.
- Implemented `refreshLibrary` to trigger cloud and folder metadata synchronization.
- Updated deletion logic to physically remove files and metadata from synced local folders.
- Added a warning to the delete confirmation dialog when removing folder-synced items.
- Improved folder sync reliability by performing lazy cleanup of missing files during interaction and sync.
- Fixed a bug where sync loading indicators would not retract on failure or cancellation.

* Improved bookmark navigation and scroll synchronization in EPUB reader

- Refined bookmark navigation logic for vertical scroll mode to handle chunk injection more reliably.
- Added `isNavigatingToBookmark` state to show a loading overlay during long jumps.
- Implemented `onScrollFinished` callback in `CfiJsBridge` to synchronize UI state with WebView scroll completion.
- Updated `ChapterWebView` to use `rememberUpdatedState` for JavaScript bridge callbacks to ensure data consistency.
- Improved `getCurrentCfi` and `scrollToCfi` in `epub_reader.js` with better visibility probing and retry logic for detached nodes.

* Implemented background metadata extraction for folder sync.

Key changes:
- Added `MetadataExtractionWorker` to handle heavy metadata and cover extraction for files in the background.
- Refactored `FolderSyncWorker` to perform fast file discovery using placeholders, enqueuing the metadata worker upon completion.
- Added `getFolderBooksWithoutCovers` query to `RecentFileDao`.
- Updated UI components (`EmptyState`, `MainViewModel`) to improve user feedback during sync and provide clear setup actions.
- Enhanced `EmptyState` composable to support secondary actions and custom button text.

* Updated Library Screen UI
This commit is contained in:
Aryan 2026-03-01 22:57:53 +05:30 committed by GitHub
parent 60f6566b31
commit a81df6921d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3378 additions and 2478 deletions

View file

@ -24,8 +24,6 @@ class EpubTestActivity : ComponentActivity() {
initialCfi = null,
initialBookmarksJson = null,
isProUser = false,
pendingSyncUpdate = null,
onClearPendingSyncUpdate = {},
onNavigateBack = {},
onSavePosition = { _, _, _ -> },
onBookmarksChanged = {},

File diff suppressed because it is too large Load diff

View file

@ -128,8 +128,6 @@ fun AppNavigation(
initialPage = initialPage,
initialBookmarksJson = initialBookmarksJson,
isProUser = uiState.isProUser,
pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId },
onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate,
onNavigateBack = {
Timber.d("Back action triggered from PDF Viewer.")
viewModel.clearSelectedFile()
@ -182,8 +180,6 @@ fun AppNavigation(
initialBookmarksJson = initialBookmarksJson,
isProUser = uiState.isProUser,
coverImagePath = coverPath,
pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId },
onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate,
onNavigateBack = {
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
viewModel.clearSelectedFile()

View file

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// FolderSyncWorker.kt
package com.aryan.reader
import android.content.Context
@ -24,8 +25,10 @@ import android.net.Uri
import timber.log.Timber
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
@ -33,8 +36,11 @@ import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import androidx.core.content.edit
import com.aryan.reader.data.LocalSyncUtils
class FolderSyncWorker(
private val appContext: Context,
@ -42,121 +48,319 @@ class FolderSyncWorker(
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext)
private val bookImporter = BookImporter(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val bookImporter = BookImporter(appContext)
companion object {
const val WORK_NAME = "FolderSyncWorker"
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
const val KEY_METADATA_ONLY = "key_metadata_only"
private val syncMutex = Mutex()
}
override suspend fun doWork(): Result {
Timber.d("Worker starting folder sync check.")
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...")
return withContext(Dispatchers.IO) {
syncMutex.withLock {
Timber.tag("FolderSync").d("Worker: Lock acquired. Starting Sync.")
performSync(isMetadataOnly)
}
}
}
private suspend fun performSync(metadataOnly: Boolean): Result {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
if (folderUriString.isNullOrBlank()) {
Timber.d("No sync folder configured. Worker stopping.")
return Result.success()
}
if (folderUriString.isNullOrBlank()) return Result.success()
val folderUri = folderUriString.toUri()
return withContext(Dispatchers.IO) {
try {
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
if (documentTree == null || !documentTree.isDirectory) {
Timber.e("Could not read the synced folder URI: $folderUriString. Cancelling worker.")
WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME)
return@withContext Result.failure()
try {
appContext.contentResolver.takePersistableUriPermission(
folderUri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (_: SecurityException) {
return Result.failure()
}
val filesToScan = mutableListOf<DocumentFile>()
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
if (documentTree == null || !documentTree.isDirectory) {
return Result.failure()
}
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
if (!metadataOnly) {
val currentDiskFiles = mutableListOf<DocumentFile>()
val fileQueue = ArrayDeque<DocumentFile>()
documentTree.listFiles().let { fileQueue.addAll(it) }
while (fileQueue.isNotEmpty()) {
val file = fileQueue.removeAt(0)
if (file.isDirectory) {
if (file.name == ".episteme") continue
file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) {
val fileName = file.name ?: ""
if (fileName.endsWith(".pdf", true) || fileName.endsWith(".epub", true) || fileName.endsWith(".mobi", true) || fileName.endsWith(".azw3", true)) {
filesToScan.add(file)
val name = file.name ?: ""
if (isValidExtension(name)) {
currentDiskFiles.add(file)
}
}
}
var importedCount = 0
for (file in filesToScan) {
val importResult = prepareBookForImport(file.uri)
if (importResult != null) {
val (internalUri, bookId, type) = importResult
val displayName = file.name ?: "Unknown File"
addBookToDatabase(internalUri, type, bookId, displayName, folderUriString)
importedCount++
val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val legacyLookup = activeDbBooks.associateBy { it.displayName }
val foundBookIds = mutableSetOf<String>()
for (file in currentDiskFiles) {
val stableId = "local_${file.name}_${file.length()}"
var existingItem = recentFilesRepository.getFileByBookId(stableId)
var bookIdToUse = stableId
var isMigration = false
if (existingItem == null) {
val legacyMatch = legacyLookup[file.name]
if (legacyMatch != null) {
Timber.tag("FolderSync").i("Migration: Found legacy match for ${file.name}. ID: ${legacyMatch.bookId}")
existingItem = legacyMatch
bookIdToUse = legacyMatch.bookId
isMigration = true
}
}
if (importedCount > 0) {
Timber.d("Worker successfully imported $importedCount new book(s).")
} else {
Timber.d("Worker found no new books to import.")
}
foundBookIds.add(bookIdToUse)
prefs.edit {
putLong(
MainViewModel.KEY_LAST_FOLDER_SCAN_TIME,
System.currentTimeMillis()
if (existingItem == null) {
val remoteMeta = folderMetadataMap[stableId]
val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB
// --- CHANGED: Removed extractFileInfo() call ---
// We use placeholders. The MetadataExtractionWorker will fix this later.
val placeholderTitle = file.name ?: "Unknown"
val placeholderAuthor = null
val placeholderCover = null
if (remoteMeta != null) {
Timber.tag("FolderSync").d("Worker: Importing existing book from Metadata + File: ${file.name}")
// We prefer remoteMeta if available because it might have the correct title/author from a previous sync
val tempItem = RecentFileItem(
bookId = stableId,
uriString = file.uri.toString(),
type = type,
displayName = file.name ?: "Unknown",
timestamp = remoteMeta.lastModifiedTimestamp,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
coverImagePath = null, // Will be fetched by MetadataWorker if needed
title = remoteMeta.title ?: placeholderTitle,
author = remoteMeta.author,
isAvailable = true,
isDeleted = false,
isRecent = false,
sourceFolderUri = folderUriString,
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson
)
recentFilesRepository.addRecentFile(tempItem)
} else {
// FAST PATH: Insert barebones item
val newItem = RecentFileItem(
bookId = stableId,
uriString = file.uri.toString(),
type = type,
displayName = file.name ?: "Unknown",
timestamp = System.currentTimeMillis(),
coverImagePath = null, // Background worker will fill this
title = placeholderTitle,
author = null,
isAvailable = true,
lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false,
isRecent = false,
sourceFolderUri = folderUriString
)
recentFilesRepository.addRecentFile(newItem)
}
} else {
if (isMigration) {
val oldUriString = existingItem.uriString
val newUriString = file.uri.toString()
if (oldUriString != newUriString) {
Timber.tag("FolderSync").i("Migration: Updating URI and cleaning up internal storage for $bookIdToUse")
if (oldUriString != null) {
bookImporter.deleteBookByUriString(oldUriString)
}
Result.success()
existingItem = existingItem.copy(
uriString = newUriString,
isAvailable = true
)
recentFilesRepository.addRecentFile(existingItem)
}
} else if (existingItem.isDeleted) {
val resurrected = existingItem.copy(isDeleted = false, isAvailable = true)
recentFilesRepository.addRecentFile(resurrected)
}
val remoteMeta = folderMetadataMap[bookIdToUse]
if (remoteMeta == null) {
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
} else {
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("Worker: Remote metadata newer for ${file.name}")
val updatedItem = existingItem.copy(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson,
locatorBlockIndex = remoteMeta.locatorBlockIndex,
locatorCharOffset = remoteMeta.locatorCharOffset,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
timestamp = remoteMeta.lastModifiedTimestamp
)
recentFilesRepository.addRecentFile(updatedItem)
} else if (existingItem.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
}
}
}
}
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
if (idsToRemove.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
recentFilesRepository.deleteFilePermanently(idsToRemove)
}
val orphanedMetadataIds = folderMetadataMap.keys.filter { !foundBookIds.contains(it) }
if (orphanedMetadataIds.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${orphanedMetadataIds.size} orphaned metadata files.")
try {
val docTree = DocumentFile.fromTreeUri(appContext, folderUri)
val syncDir = docTree?.findFile("episteme")
if (syncDir != null) {
val allFiles = syncDir.listFiles()
orphanedMetadataIds.forEach { orphanId ->
allFiles.filter {
val name = it.name ?: ""
name.contains(orphanId) && (name.endsWith(".json") || name.contains(".sync-conflict"))
}.forEach { fileToDelete ->
try {
fileToDelete.delete()
} catch (_: Exception) { }
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error during folder sync worker execution.")
Result.failure()
Timber.tag("FolderSync").e(e, "Error during orphan cleanup")
}
}
}
private suspend fun prepareBookForImport(externalUri: Uri): Triple<Uri, String, FileType>? {
val type = getFileTypeFromUri(externalUri, appContext) ?: return null
// Reconcile Metadata (Write-back)
val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val hash = FileHasher.calculateSha256 {
appContext.contentResolver.openInputStream(externalUri)
} ?: return null
val booksToDelete = mutableListOf<String>()
if (recentFilesRepository.getFileByBookId(hash) != null) {
return null // Already exists
for (localBook in activeDbBooks) {
val remoteMeta = folderMetadataMap[localBook.bookId]
if (remoteMeta == null) {
val exists = try {
val uri = localBook.uriString?.toUri()
if (uri != null) {
DocumentFile.fromSingleUri(appContext, uri)?.exists() == true
} else false
} catch (e: Exception) { false }
if (exists) {
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
} else {
Timber.tag("FolderSync").i("Metadata Sync: Book ${localBook.displayName} missing from disk. Scheduling removal.")
booksToDelete.add(localBook.bookId)
}
} else {
if (remoteMeta.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("SyncDecision: Remote NEWER for ${localBook.displayName}. Updating local DB.")
val updatedItem = localBook.copy(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson,
locatorBlockIndex = remoteMeta.locatorBlockIndex,
locatorCharOffset = remoteMeta.locatorCharOffset,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
timestamp = remoteMeta.lastModifiedTimestamp
)
recentFilesRepository.addRecentFile(updatedItem)
} else if (localBook.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
}
}
}
val internalFile = bookImporter.importBook(externalUri) ?: return null
return Triple(internalFile.toUri(), hash, type)
if (booksToDelete.isNotEmpty()) {
recentFilesRepository.deleteFilePermanently(booksToDelete)
}
private fun getFileNameFromUri(uri: Uri): String? {
return DocumentFile.fromSingleUri(appContext, uri)?.name
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
WorkManager.getInstance(appContext).enqueueUniqueWork(
MetadataExtractionWorker.WORK_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE,
metaRequest
)
return Result.success()
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
return Result.failure()
}
}
private suspend fun addBookToDatabase(
uri: Uri,
type: FileType,
bookId: String,
displayName: String,
sourceFolderUri: String
) {
private data class ExtractedInfo(
val title: String? = null,
val author: String? = null,
val coverPath: String? = null
)
private suspend fun extractFileInfo(uri: Uri, type: FileType, displayName: String): ExtractedInfo {
var coverPath: String? = null
var title: String? = null
var author: String? = null
try {
if (type == FileType.EPUB || type == FileType.MOBI) {
val book = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
if (type == FileType.EPUB) {
epubParser.createEpubBook(
inputStream = inputStream,
originalBookNameHint = displayName
originalBookNameHint = displayName,
parseContent = false
)
} else {
mobiParser.createMobiBook(
@ -167,57 +371,39 @@ class FolderSyncWorker(
}
}
if (book != null) {
title = book.title.takeIf { it.isNotBlank() } ?: displayName
title = book.title.takeIf { it.isNotBlank() }
author = book.author.takeIf { it.isNotBlank() }
book.coverImage?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
} else if (type == FileType.PDF) {
title = displayName
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
val newItem = RecentFileItem(
bookId = bookId,
uriString = uri.toString(),
type = type,
displayName = displayName,
timestamp = System.currentTimeMillis(),
coverImagePath = coverPath,
title = title,
author = author,
isAvailable = true,
lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false,
isRecent = false, // Books from folder sync should not appear on the Home screen
sourceFolderUri = sourceFolderUri
)
recentFilesRepository.addRecentFile(newItem)
Timber.i("Worker added new book to database: $displayName")
} catch (e: Exception) {
Timber.e(e, "Failed to extract info for file: $displayName")
}
return ExtractedInfo(title, author, coverPath)
}
private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? {
val mimeType = context.contentResolver.getType(uri)
return when (mimeType) {
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-mobipocket-ebook",
"application/vnd.amazon.ebook",
"application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
else -> {
val path = getFileNameFromUri(uri)
when {
path?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
path?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
path?.endsWith(".mobi", ignoreCase = true) == true -> FileType.MOBI
path?.endsWith(".azw3", ignoreCase = true) == true -> FileType.MOBI
path?.endsWith(".prc", ignoreCase = true) == true -> FileType.MOBI
private fun isValidExtension(name: String): Boolean {
return name.endsWith(".pdf", true) ||
name.endsWith(".epub", true) ||
name.endsWith(".mobi", true) ||
name.endsWith(".azw3", true) ||
name.endsWith(".md", true)
}
private fun getFileType(name: String, mimeType: String?): FileType? {
return when {
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
name.endsWith(".md", true) -> FileType.MD
name.endsWith(".txt", true) -> FileType.TXT
else -> null
}
}
}
}
}

View file

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// HomeScreen
@file:Suppress("DEPRECATION")
package com.aryan.reader
@ -53,6 +54,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
@ -83,6 +86,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
@ -253,6 +257,7 @@ fun HomeScreen(
}
},
navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
)
}) {
Scaffold(
@ -271,7 +276,8 @@ fun HomeScreen(
drawerState.open()
}
},
onShowDeviceManagement = viewModel::showDeviceManagementForDebug
onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
)
} else {
ContextualTopAppBar(
@ -291,14 +297,16 @@ fun HomeScreen(
if (uiState.recentFiles.isEmpty()) {
EmptyState(
title = "Your Library is Empty",
message = "Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.",
message = "Select a file to read, or sync a local folder to automatically import books.",
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
secondaryButtonText = "Setup Folder Sync",
onSecondaryClick = { viewModel.navigateToFolderSync() }
)
} else {
EmptyState(
title = "No Recent Files",
message = "Open a file from your library to see it here, or select a new file to add.",
message = "Open a file from your library to see it here.",
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f)
)
@ -310,8 +318,13 @@ fun HomeScreen(
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
onSelectFileClick = onSelectFileClick,
onNavigateToFolderSync = { viewModel.navigateToFolderSync() },
windowSizeClass = windowSizeClass,
downloadingBookIds = uiState.downloadingBookIds
downloadingBookIds = uiState.downloadingBookIds,
onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolderUri != null
)
}
}
@ -389,9 +402,15 @@ fun HomeScreen(
)
}
}
if (uiState.showFolderMigrationDialog) {
FolderMigrationDialog(
onConfirm = { viewModel.completeFolderMigration() }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RecentFilesContent(
recentFiles: List<RecentFileItem>,
@ -399,29 +418,59 @@ private fun RecentFilesContent(
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onSelectFileClick: () -> Unit,
onNavigateToFolderSync: () -> Unit,
windowSizeClass: WindowSizeClass,
downloadingBookIds: Set<String>,
onRefresh: () -> Unit,
isRefreshing: Boolean,
isSyncEnabled: Boolean,
hasSyncedFolder: Boolean
) {
val canRefresh = isSyncEnabled || hasSyncedFolder
val content = @Composable {
Box(modifier = Modifier.fillMaxSize()) {
RecentFilesGrid(
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
recentFiles = recentFiles,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
onItemClick = onItemClick,
onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass,
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
downloadingBookIds = downloadingBookIds
)
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(vertical = 24.dp), contentAlignment = Alignment.Center
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
SelectFileButton(onClick = onSelectFileClick, text = "Select Another File")
androidx.compose.material3.Button(onClick = onSelectFileClick) {
Text("Select File")
}
androidx.compose.material3.OutlinedButton(onClick = onNavigateToFolderSync) {
Text("Sync Folder")
}
}
}
}
if (canRefresh) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize()
) {
content()
}
} else {
content()
}
}
@ -509,6 +558,26 @@ fun RecentFileCard(
.fillMaxWidth(),
)
if (item.sourceFolderUri != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.9f),
shape = CircleShape
)
.padding(4.dp)
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = "Local Folder",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
if (!item.isAvailable) {
Box(
modifier = Modifier
@ -576,7 +645,8 @@ fun DefaultTopAppBar(
onClearCloudData: () -> Unit,
onDrawerClick: () -> Unit,
onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit
onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
@ -636,7 +706,8 @@ private fun AppDrawerContent(
onUpgradeClick: () -> Unit,
onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit,
navController: NavHostController
navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit
) {
val isOss = BuildConfig.FLAVOR == "oss"
@ -753,6 +824,35 @@ private fun AppDrawerContent(
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
if (uiState.currentUser != null && uiState.isSyncEnabled) {
NavigationDrawerItem(
icon = {
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = "Backup Local Folders"
)
},
label = {
Column {
Text("Cloud sync for Local Folders")
Text(
"Upload books from your synced folders to Google Drive).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
badge = {
Switch(
checked = uiState.isFolderSyncEnabled,
onCheckedChange = { onFolderSyncToggle(it) }
)
},
selected = false,
onClick = { onFolderSyncToggle(!uiState.isFolderSyncEnabled) },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
} else {
// OSS Header
Column(
@ -1036,3 +1136,30 @@ fun FpsMonitor(modifier: Modifier = Modifier) {
.padding(4.dp)
)
}
@Composable
private fun FolderMigrationDialog(onConfirm: () -> Unit) {
AlertDialog(
onDismissRequest = { }, // Force acknowledgment
icon = { Icon(Icons.Default.FolderSpecial, contentDescription = null) },
title = { Text("Folder Sync Refactored") },
text = {
Column {
Text(
"We've completely rebuilt how Folder Sync works! Books from the folder are now read directly from your folder instead of being copied to app storage."
)
Spacer(modifier = Modifier.height(12.dp))
Text(
"We'll now perform a one-time scan to migrate your existing progress and bookmarks. This will also free up internal storage space on your device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Start Migration")
}
}
)
}

View file

@ -17,13 +17,17 @@
*
* mail: epistemereader@gmail.com
*/
// LibraryScreen.kt
package com.aryan.reader
import android.content.Context
import android.provider.DocumentsContract
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -49,14 +53,20 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -67,6 +77,7 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -79,6 +90,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@ -88,6 +100,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.request.ImageRequest
@ -96,20 +110,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.documentfile.provider.DocumentFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import androidx.core.net.toUri
private fun getBookCountString(count: Int): String {
return if (count == 1) "1 book" else "$count books"
@ -130,6 +133,17 @@ fun LibraryScreen(
initialPage = uiState.libraryScreenStartPage,
pageCount = { 3 }
)
val containsFolderItems = remember(selectedItems) {
selectedItems.any { it.sourceFolderUri != null }
}
LaunchedEffect(uiState.libraryScreenStartPage) {
if (pagerState.currentPage != uiState.libraryScreenStartPage) {
pagerState.animateScrollToPage(uiState.libraryScreenStartPage)
}
}
val scope = rememberCoroutineScope()
var isSearchActive by remember { mutableStateOf(false) }
@ -160,8 +174,11 @@ fun LibraryScreen(
pickFileLauncher.launch(arrayOf("*/*"))
}
LaunchedEffect(pagerState.currentPage) {
viewModel.setLibraryScreenPage(pagerState.currentPage)
LaunchedEffect(pagerState) {
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
.collect { page ->
viewModel.setLibraryScreenPage(page)
}
}
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
@ -217,6 +234,7 @@ fun LibraryScreen(
onNewShelfClick = viewModel::showCreateShelfDialog,
onSelectFileClick = onSelectFileClick,
onScanNowClick = viewModel::scanSyncedFolder,
onSyncMetadataClick = viewModel::syncFolderMetadata,
onSelectSyncFolderClick = onSelectSyncFolderClick,
onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder,
downloadingBookIds = uiState.downloadingBookIds,
@ -241,9 +259,11 @@ fun LibraryScreen(
showDeleteConfirmDialog = false
},
onDismiss = { showDeleteConfirmDialog = false },
isPermanentDelete = true
isPermanentDelete = true,
containsFolderItems = containsFolderItems
)
}
if (showDeleteShelvesDialog) {
DeleteShelvesConfirmationDialog(
count = selectedShelves.size,
@ -409,6 +429,7 @@ fun LibraryScreenContent(
onNewShelfClick: () -> Unit,
onSelectFileClick: () -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onSelectSyncFolderClick: () -> Unit,
onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>,
@ -621,6 +642,7 @@ fun LibraryScreenContent(
lastScanTime = lastFolderScanTime,
onSelectFolderClick = onSelectSyncFolderClick,
onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
onChangeFolderClick = onSelectSyncFolderClick,
onDisconnectClick = onDisconnectSyncFolderClick,
isLoading = isLoading
@ -1147,6 +1169,16 @@ private fun LibraryListItem(
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (item.sourceFolderUri != null) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = item.title ?: item.displayName,
style = MaterialTheme.typography.titleMedium,
@ -1326,6 +1358,7 @@ private fun FolderSyncScreen(
lastScanTime: Long?,
onSelectFolderClick: () -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onChangeFolderClick: () -> Unit,
onDisconnectClick: () -> Unit,
isLoading: Boolean
@ -1334,9 +1367,10 @@ private fun FolderSyncScreen(
if (syncedFolderUri == null) {
EmptyState(
title = "Import files from a Folder",
message = "Select a folder on your device. Episteme will automatically find and import any new books you add to it.",
title = "Sync Local Folder",
message = "Connect a folder to create a live library. Episteme will automatically monitor your files for new additions and keep your reading progress and other book metadata in sync with your folder.",
onSelectFileClick = onSelectFolderClick,
primaryButtonText = "Select Folder",
modifier = Modifier.fillMaxSize()
)
} else {
@ -1344,86 +1378,224 @@ private fun FolderSyncScreen(
getDisplayPathFromUri(context, syncedFolderUri)
}
// Calculate times
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = remember(lastScanTime) {
if (lastScanTime == null || lastScanTime == 0L) {
"Never scanned"
} else {
"Last scan: ${
SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()).format(
Date(lastScanTime)
)
}"
if (lastScanTime == null || lastScanTime == 0L) "Never"
else dateFormat.format(Date(lastScanTime))
}
val nextScanText = remember(lastScanTime) {
if (lastScanTime == null || lastScanTime == 0L) "Pending first scan..."
else {
// Adding 4 hours (4 * 60 * 60 * 1000) to match the Worker interval
val nextTime = lastScanTime + (4 * 60 * 60 * 1000)
dateFormat.format(Date(nextTime))
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Icon(
imageVector = Icons.Default.FolderSpecial, // Using a standard icon
contentDescription = "Synced Folder",
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
// 1. Status Dashboard Card
androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Folder Sync is Active",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() // Add this modifier
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Monitoring folder:",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
Text(
text = folderPath,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
if (isLoading) {
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header Row with Status
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Scanning...",
style = MaterialTheme.typography.bodyMedium,
text = "Active Sync",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// Status Indicator
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = androidx.compose.foundation.shape.CircleShape
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"Scanning...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
} else {
Box(
modifier = Modifier
.size(8.dp)
.background(
Color(0xFF4CAF50), // Green for active
androidx.compose.foundation.shape.CircleShape
)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = lastScanText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() // Add this modifier
"Monitoring",
style = MaterialTheme.typography.labelSmall
)
}
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onScanNowClick, enabled = !isLoading) {
Text("Scan for New Books")
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = onChangeFolderClick, enabled = !isLoading) {
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// Folder Path
Column {
Text(
text = "LOCATION",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = folderPath,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
// Times Grid
Row(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "LAST CHECK",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = "NEXT AUTO SYNC",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = nextScanText, style = MaterialTheme.typography.bodySmall)
}
}
}
}
// 2. Main Actions
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Actions",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
androidx.compose.material3.FilledTonalButton(
onClick = onScanNowClick,
enabled = !isLoading,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan Files")
}
androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick,
enabled = !isLoading,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
Icon(
painter = painterResource(id = R.drawable.sync),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Sync Data")
}
}
}
Spacer(modifier = Modifier.weight(1f))
Column {
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onChangeFolderClick, enabled = !isLoading) {
Text("Change Folder")
}
Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = onDisconnectClick, enabled = !isLoading) {
Text("Disconnect Folder")
TextButton(
onClick = onDisconnectClick,
enabled = !isLoading,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Disconnect")
}
}
}
}
}

View file

@ -17,8 +17,11 @@
*
* mail: epistemereader@gmail.com
*/
// MainScreen.kt
package com.aryan.reader
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
@ -31,13 +34,15 @@ import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) {
object Home : BottomBarScreen("home", "Home", R.drawable.home)
@ -55,6 +60,13 @@ fun MainScreen(
windowSizeClass: WindowSizeClass,
navController: NavHostController
) {
val context = LocalContext.current
SideEffect {
val activity = context as? ComponentActivity
activity?.enableEdgeToEdge()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val viewingShelfName = uiState.viewingShelfName
@ -67,6 +79,12 @@ fun MainScreen(
)
val scope = rememberCoroutineScope()
LaunchedEffect(uiState.mainScreenStartPage) {
if (pagerState.currentPage != uiState.mainScreenStartPage) {
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
}
}
LaunchedEffect(pagerState.currentPage) {
viewModel.setMainScreenPage(pagerState.currentPage)
}

View file

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// MainViewModel.kt
@file:Suppress("DEPRECATION")
package com.aryan.reader
@ -39,7 +40,10 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.aryan.reader.data.CloudflareRepository
import com.aryan.reader.data.CustomFontEntity
@ -99,6 +103,8 @@ import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit
private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
private const val KEY_FOLDER_MIGRATION_COMPLETED = "folder_migration_completed_v2"
data class BannerMessage(val message: String, val isError: Boolean = false)
@ -176,17 +182,19 @@ data class ReaderScreenState(
val isAuthMenuExpanded: Boolean = false,
val isProUser: Boolean = false,
val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false,
val bannerMessage: BannerMessage? = null,
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
val isReplacingDevice: Boolean = false,
val isRequestingDrivePermission: Boolean = false,
val downloadingBookIds: Set<String> = emptySet(),
val uploadingBookIds: Set<String> = emptySet(),
val pendingSyncUpdate: SyncUpdateInfo? = null,
val syncedFolderUri: String? = null,
val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "",
val showFolderMigrationDialog: Boolean = false,
val isRefreshing: Boolean = false,
)
open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -263,6 +271,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
},
currentUser = authRepository.getSignedInUser(),
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false),
syncedFolderUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null),
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
KEY_LAST_FOLDER_SCAN_TIME,
@ -520,6 +529,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
remoteConfigRepository.init()
if (_internalState.value.syncedFolderUri != null) {
Timber.d("App Start: Triggering local folder metadata-only sync.")
syncFolderMetadata()
}
viewModelScope.launch { billingClientWrapper.initializeConnection() }
viewModelScope.launch {
@ -569,6 +583,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
}
val folderUri = _internalState.value.syncedFolderUri
val migrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
if (folderUri != null && !migrationCompleted) {
Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.")
_internalState.update { it.copy(showFolderMigrationDialog = true) }
}
}
fun completeFolderMigration() {
Timber.tag("FolderSync").d("User accepted migration. Marking completed and starting scan.")
prefs.edit { putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) }
_internalState.update { it.copy(showFolderMigrationDialog = false) }
scanSyncedFolder()
}
private val fontsRepository = FontsRepository(appContext)
@ -1003,8 +1032,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun uploadSingleBookMetadata(book: RecentFileItem) {
if (!uiState.value.isSyncEnabled) {
Timber.tag("AnnotationSync").d("Sync disabled. Skipping upload.")
if (!uiState.value.isSyncEnabled) return
if (book.sourceFolderUri != null) {
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
}
val currentUser = uiState.value.currentUser ?: return
@ -1184,102 +1215,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isLoading = false,
errorMessage = null,
initialLocator = null,
initialPageInBook = null,
pendingSyncUpdate = null
initialPageInBook = null
)
}
bookToSync?.let {
if (uiState.value.uploadingBookIds.contains(it.bookId)) {
Timber.d(
"Book closed, but initial upload is still in progress. Metadata will be synced upon upload completion."
)
return
}
if (uiState.value.isSyncEnabled) {
Timber.d("Book closed, triggering metadata sync for ${it.bookId}")
uploadSingleBookMetadata(it)
}
}
private fun syncSingleBookMetadataOnOpen(bookId: String) {
if (!uiState.value.isSyncEnabled) return
val currentUser = uiState.value.currentUser ?: return
if (it.sourceFolderUri != null) {
Timber.d("Book closed (Folder Linked), syncing metadata to folder: ${it.bookId}")
viewModelScope.launch {
try {
val remoteBookMetadata =
firestoreRepository.getBookMetadata(currentUser.uid, bookId) ?: return@launch
val localBook = recentFilesRepository.getFileByBookId(bookId)
val showUpdatePrompt = if (localBook == null) {
true
} else if (remoteBookMetadata.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
val remoteLocator =
if (remoteBookMetadata.lastChapterIndex != null && remoteBookMetadata.locatorBlockIndex != null && remoteBookMetadata.locatorCharOffset != null) {
Locator(
remoteBookMetadata.lastChapterIndex,
remoteBookMetadata.locatorBlockIndex,
remoteBookMetadata.locatorCharOffset
)
} else null
val localLocator =
if (localBook.lastChapterIndex != null && localBook.locatorBlockIndex != null && localBook.locatorCharOffset != null) {
Locator(
localBook.lastChapterIndex,
localBook.locatorBlockIndex,
localBook.locatorCharOffset
)
} else null
val positionChanged = when (localBook.type) {
FileType.PDF -> remoteBookMetadata.lastPage != localBook.lastPage
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> remoteLocator != localLocator
recentFilesRepository.syncLocalMetadataToFolder(it.bookId)
}
val bookmarksChanged =
remoteBookMetadata.bookmarksJson != localBook.bookmarksJson
positionChanged || bookmarksChanged
} else {
false
}
if (showUpdatePrompt) {
Timber.d("Remote metadata is newer for $bookId. Proposing update to user.")
recentFilesRepository.addRecentFile(remoteBookMetadata.toRecentFileItem())
val locator =
if (remoteBookMetadata.lastChapterIndex != null && remoteBookMetadata.locatorBlockIndex != null && remoteBookMetadata.locatorCharOffset != null) {
Locator(
remoteBookMetadata.lastChapterIndex,
remoteBookMetadata.locatorBlockIndex,
remoteBookMetadata.locatorCharOffset
)
} else null
_internalState.update {
it.copy(
pendingSyncUpdate = SyncUpdateInfo(
bookId = bookId,
locator = locator,
page = remoteBookMetadata.lastPage,
cfi = remoteBookMetadata.lastPositionCfi,
bookmarksJson = remoteBookMetadata.bookmarksJson
)
)
}
} else {
Timber.d(
"Local metadata is up-to-date for $bookId or remote data is identical. No prompt."
)
if (localBook != null && remoteBookMetadata.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
recentFilesRepository.addRecentFile(remoteBookMetadata.toRecentFileItem())
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to sync single book metadata on open for bookId: $bookId")
}
}
}
@ -1312,12 +1265,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
)
Timber.d("Persistable URI permission taken for folder: $folderUri")
prefs.edit { putString(KEY_SYNCED_FOLDER_URI, folderUri.toString()) }
_internalState.update { it.copy(syncedFolderUri = folderUri.toString()) }
// Trigger an initial scan
prefs.edit {
putString(KEY_SYNCED_FOLDER_URI, folderUri.toString())
putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true)
}
_internalState.update { it.copy(
syncedFolderUri = folderUri.toString(),
showFolderMigrationDialog = false
) }
scanSyncedFolder()
// Schedule periodic sync
val workManager = WorkManager.getInstance(appContext)
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
@ -1339,20 +1298,77 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun syncFolderMetadata() {
triggerFolderSyncWorker(metadataOnly = true)
}
fun scanSyncedFolder() {
triggerFolderSyncWorker(metadataOnly = false)
}
private fun triggerFolderSyncWorker(metadataOnly: Boolean) {
@Suppress("UnusedVariable", "Unused") val folderUriString = _internalState.value.syncedFolderUri ?: return
Timber.tag("FolderSync").d("Requesting folder sync (metadataOnly=$metadataOnly)")
val workManager = WorkManager.getInstance(appContext)
val data = androidx.work.Data.Builder()
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly)
.build()
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>()
.setInputData(data)
.build()
workManager.enqueueUniqueWork(
FolderSyncWorker.WORK_NAME_ONETIME,
ExistingWorkPolicy.REPLACE,
request
)
viewModelScope.launch {
workManager.getWorkInfoByIdFlow(request.id).collect { workInfo ->
if (workInfo != null) {
when (workInfo.state) {
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Folder Sync: Scanning files..."
_internalState.update { it.copy(isLoading = true, bannerMessage = BannerMessage(msg)) }
}
WorkInfo.State.SUCCEEDED -> {
_internalState.update { it.copy(
isLoading = false,
isRefreshing = false,
bannerMessage = BannerMessage("Folder Sync: Scan complete."),
lastFolderScanTime = System.currentTimeMillis()
) }
}
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
_internalState.update { it.copy(isLoading = false, isRefreshing = false, errorMessage = "Sync failed.") } // ADD isRefreshing = false
}
else -> Unit
}
}
}
}
}
fun disconnectSyncedFolder() {
viewModelScope.launch {
val folderUriString = _internalState.value.syncedFolderUri
if (folderUriString != null) {
Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.")
recentFilesRepository.deleteFilesBySourceFolder(folderUriString) // New DAO method call
try {
val uri = folderUriString.toUri()
val contentResolver = appContext.contentResolver
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.releasePersistableUriPermission(uri, takeFlags)
Timber.d("Released persistable URI permission for folder: $uri")
Timber.tag("FolderSync").d("Released permission for: $uri")
} catch (e: Exception) {
Timber.e(e, "Failed to release persistable URI permission for $folderUriString")
Timber.e(e, "Failed to release permission")
}
}
prefs.edit {
remove(KEY_SYNCED_FOLDER_URI)
remove(KEY_LAST_FOLDER_SCAN_TIME)
@ -1360,90 +1376,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
Timber.d("Cancelled folder sync worker.")
}
}
fun scanSyncedFolder() {
val folderUriString = _internalState.value.syncedFolderUri ?: return
val folderUri = folderUriString.toUri()
_internalState.update {
it.copy(
isLoading = true, bannerMessage = BannerMessage("Scanning folder for new books...")
)
}
viewModelScope.launch(Dispatchers.IO) {
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
if (documentTree == null || !documentTree.isDirectory) {
withContext(Dispatchers.Main) {
_internalState.update {
it.copy(
isLoading = false,
errorMessage = "Could not read the synced folder. Please select it again."
)
}
}
return@launch
}
val filesToImport = mutableListOf<DocumentFile>()
val fileQueue = ArrayDeque<DocumentFile>()
documentTree.listFiles().let { fileQueue.addAll(it) }
while (fileQueue.isNotEmpty()) {
val file = fileQueue.removeAt(0)
if (file.isDirectory) {
file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) {
val fileName = file.name ?: ""
if (fileName.endsWith(".pdf", true) ||
fileName.endsWith(".epub", true) ||
fileName.endsWith(".mobi", true) ||
fileName.endsWith(".azw3", true) ||
fileName.endsWith(".md", true)
) {
filesToImport.add(file)
}
}
}
var importedCount = 0
for (file in filesToImport) {
val importResult = prepareBookForImport(file.uri)
if (importResult != null) {
val (internalUri, bookId, type) = importResult
val displayName = getFileNameFromUri(file.uri, appContext) ?: "Unknown File"
addFileToRecent(
internalUri,
type,
bookId,
customDisplayName = displayName,
isRecent = false,
sourceFolderUri = folderUriString
)
importedCount++
}
}
val scanTime = System.currentTimeMillis()
prefs.edit { putLong(KEY_LAST_FOLDER_SCAN_TIME, scanTime) }
withContext(Dispatchers.Main) {
val message = if (importedCount > 0) {
"Successfully imported $importedCount new book(s) from your folder."
} else {
"No new books found to import."
}
_internalState.update {
it.copy(
isLoading = false,
bannerMessage = BannerMessage(message),
lastFolderScanTime = scanTime
)
}
}
}
}
@ -1543,10 +1475,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun clearPendingSyncUpdate() {
_internalState.update { it.copy(pendingSyncUpdate = null) }
}
fun deleteAllCloudAndLocalData() {
if (!uiState.value.isSyncEnabled) {
_internalState.update { it.copy(errorMessage = "Enable sync to clear cloud data.") }
@ -1772,6 +1700,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun setFolderSyncEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) }
_internalState.update { it.copy(isFolderSyncEnabled = enabled) }
if (enabled && uiState.value.isSyncEnabled) {
viewModelScope.launch { syncWithCloud(showBanner = false) }
}
}
private fun syncWithCloud(showBanner: Boolean = false) = viewModelScope.launch {
val hasPermissions = googleDriveRepository.hasDrivePermissions(appContext)
val currentUser = _internalState.value.currentUser
@ -1785,7 +1722,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (showBanner) {
_internalState.update {
it.copy(bannerMessage = BannerMessage("Syncing library and fonts..."))
it.copy(bannerMessage = BannerMessage("Cloud Sync: Checking for updates..."))
}
}
@ -1800,7 +1737,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
firestoreRepository.getAllShelves(currentUser.uid)
}
val localBooks = withContext(Dispatchers.IO) {
recentFilesRepository.getAllFilesForSync()
val allFiles = recentFilesRepository.getAllFilesForSync()
if (_internalState.value.isFolderSyncEnabled) {
allFiles
} else {
allFiles.filter { it.sourceFolderUri == null }
}
}
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
@ -1988,7 +1930,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (showBanner) {
_internalState.update {
it.copy(
isLoading = false, bannerMessage = BannerMessage("Sync complete.")
isLoading = false, bannerMessage = BannerMessage("Cloud Sync: Complete.")
)
}
}
@ -2087,7 +2029,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun addFileToRecent(
private suspend fun addFileToRecent(
uri: Uri,
type: FileType,
bookId: String,
@ -2095,8 +2037,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
customDisplayName: String? = null,
isRecent: Boolean,
sourceFolderUri: String? = null
) {
viewModelScope.launch {
) = withContext(Dispatchers.IO) {
val isNewBook = withContext(Dispatchers.IO) {
recentFilesRepository.getFileByBookId(bookId) == null
}
@ -2199,7 +2140,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uploadNewBookAndMetadata(newItem)
}
}
}
fun setSortOrder(sortOrder: SortOrder) {
_internalState.update { it.copy(sortOrder = sortOrder) }
@ -2555,9 +2495,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) {
Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator")
viewModelScope.launch {
recentFilesRepository.getFileByUri(uri.toString())?.let {
recentFilesRepository.getFileByUri(uri.toString())?.let { _ ->
recentFilesRepository.updateEpubReadingPosition(
uri.toString(), locator, cfiForWebView, progress
uriString = uri.toString(),
locator = locator,
cfiForWebView = cfiForWebView,
progress = progress
)
}
}
@ -2600,15 +2543,51 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
Timber.d("Saving PDF position locally: URI=$currentPdfUri, Page=$page")
viewModelScope.launch {
recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let {
recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ ->
recentFilesRepository.updatePdfReadingPosition(
currentPdfUri.toString(), page, progress
uriString = currentPdfUri.toString(),
page = page,
progress = progress
)
}
}
}
}
fun refreshLibrary() {
val syncEnabled = _internalState.value.isSyncEnabled
val hasFolder = _internalState.value.syncedFolderUri != null // Check for URI instead of toggle
if (!syncEnabled && !hasFolder) {
Timber.d("Refresh skipped: No sync methods active.")
_internalState.update { it.copy(isRefreshing = false) } // Ensure indicator retracts immediately
return
}
viewModelScope.launch {
_internalState.update { it.copy(isRefreshing = true) }
try {
if (syncEnabled) {
syncWithCloud(showBanner = false).join()
}
if (hasFolder) {
// This triggers the worker which we observe above to clear isRefreshing
syncFolderMetadata()
}
} catch (e: Exception) {
Timber.e(e, "Refresh failed")
_internalState.update { it.copy(isRefreshing = false) }
} finally {
// If folder sync isn't running, we must close the indicator here
if (!hasFolder) {
_internalState.update { it.copy(isRefreshing = false) }
}
}
}
}
fun clearBookCache() {
viewModelScope.launch {
bookCacheDao.clearAllCache()
@ -2630,23 +2609,43 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(contextualActionItems = newSelection) }
Timber.d("New selection size: ${newSelection.size}")
} else {
if (item.sourceFolderUri != null && item.uriString != null) {
viewModelScope.launch {
val exists = try {
val uri = item.uriString.toUri()
DocumentFile.fromSingleUri(appContext, uri)?.exists() == true
} catch (_: Exception) { false }
if (!exists) {
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
}
Timber.d("Recent file clicked (opening): ${item.displayName}")
if (item.isAvailable) {
item.getUri()?.let { uri ->
openBook(uri, item.bookId, item.type, item.displayName)
} ?: run {
_internalState.update {
it.copy(
errorMessage = "Could not find file location for ${item.displayName}."
)
_internalState.update { it.copy(errorMessage = "Could not find file location.") }
}
} else {
downloadBook(item, openWhenComplete = true)
}
}
return
}
syncSingleBookMetadataOnOpen(item.bookId)
Timber.d("Recent file clicked (opening): ${item.displayName}")
if (item.isAvailable) {
item.getUri()?.let { uri ->
openBook(uri, item.bookId, item.type, item.displayName)
} ?: run {
_internalState.update { it.copy(errorMessage = "Could not find file location.") }
return
}
} else {
Timber.w(
"Clicked on a book that is not available locally: ${item.displayName}, starting download."
)
downloadBook(item, openWhenComplete = true)
}
}
@ -3035,11 +3034,62 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun deleteContextualItemsPermanently() {
val itemsToRemove = _internalState.value.contextualActionItems
if (itemsToRemove.isNotEmpty()) {
val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext)
_internalState.update { it.copy(contextualActionItems = emptySet()) }
viewModelScope.launch {
val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext)
val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null }
if (folderBooks.isNotEmpty()) {
Timber.d("Processing ${folderBooks.size} folder books for deletion.")
val idsToDeleteLocally = mutableListOf<String>()
folderBooks.forEach { item ->
idsToDeleteLocally.add(item.bookId)
pdfTextRepository.clearBookText(item.bookId)
if (item.uriString != null) {
try {
val fileUri = item.uriString.toUri()
val fileDoc = DocumentFile.fromSingleUri(appContext, fileUri)
if (fileDoc != null && fileDoc.exists()) {
if (fileDoc.delete()) {
Timber.i("Physically deleted folder file: ${item.displayName}")
} else {
Timber.e("Failed to delete folder file via SAF: ${item.displayName}")
}
}
} catch (e: Exception) {
Timber.e(e, "Error deleting physical file for ${item.bookId}")
}
}
// 2. Try to delete the metadata JSON (.bookId.json)
if (item.sourceFolderUri != null) {
try {
val rootUri = item.sourceFolderUri.toUri()
val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri)
val syncDir = rootDoc?.findFile("episteme") ?: rootDoc?.findFile(".episteme")
if (syncDir != null) {
// Try hidden first, then legacy
val metaFile = syncDir.findFile(".${item.bookId}.json")
?: syncDir.findFile("${item.bookId}.json")
metaFile?.delete()
}
} catch (e: Exception) {
Timber.e(e, "Error deleting metadata file for ${item.bookId}")
}
}
}
recentFilesRepository.deleteFilePermanently(idsToDeleteLocally)
}
if (managedBooks.isNotEmpty()) {
val currentUser = uiState.value.currentUser
if (canSync && currentUser != null) {
@ -3050,21 +3100,21 @@ 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) {
googleDriveRepository.getFiles(accessToken)?.files.orEmpty()
.associateBy { it.name }
}
for (item in itemsToRemove) {
for (item in managedBooks) {
recentFilesRepository.markAsDeleted(listOf(item.bookId))
pdfTextRepository.clearBookText(item.bookId)
val deletedItem =
recentFilesRepository.getFileByBookId(item.bookId) ?: continue
firestoreRepository.syncBookMetadata(
currentUser.uid, deletedItem.toBookMetadata(), deviceId
currentUser.uid, item.toBookMetadata().copy(isDeleted = true), deviceId
)
val fileExtension = item.type.name.lowercase()
@ -3076,37 +3126,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
}
_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(itemsToRemove.map { it.bookId })
itemsToRemove.forEach { item ->
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
managedBooks.forEach { item ->
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(itemsToRemove.map { it.bookId })
itemsToRemove.forEach { item -> pdfTextRepository.clearBookText(item.bookId) }
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
managedBooks.forEach { item -> pdfTextRepository.clearBookText(item.bookId) }
}
}
val totalRemoved = folderBooks.size + managedBooks.size
_internalState.update { it.copy(isLoading = false, bannerMessage = BannerMessage("$totalRemoved books removed from library.")) }
}
} else {
Timber.w("Attempted to remove contextual items, but none were selected.")
}
}
fun navigateToFolderSync() {
// 1. Switch MainScreen to Library Tab (Index 1)
setMainScreenPage(1)
// 2. Switch LibraryScreen to Folder Tab (Index 2)
setLibraryScreenPage(2)
}
override fun onCleared() {
super.onCleared()
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)

View file

@ -0,0 +1,115 @@
// MetadataExtractionWorker.kt
package com.aryan.reader
import android.content.Context
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
class MetadataExtractionWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
companion object {
const val WORK_NAME = "MetadataExtractionWorker"
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
// Fetch all books that are from a folder but don't have a cover yet (implies metadata likely missing/basic)
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers()
if (filesToProcess.isEmpty()) {
return@withContext Result.success()
}
Timber.tag("MetadataWorker").i("Starting background metadata extraction for ${filesToProcess.size} books.")
filesToProcess.forEach { item ->
if (isStopped) return@forEach
try {
val uri = item.uriString?.toUri() ?: return@forEach
val type = item.type
var coverPath: String? = null
var title: String? = null
var author: String? = null
// We open the stream briefly to extract metadata
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
when (type) {
FileType.EPUB -> {
val book = epubParser.createEpubBook(
inputStream = inputStream,
originalBookNameHint = item.displayName,
parseContent = false
)
book.let {
title = it.title.takeIf { t -> t.isNotBlank() }
author = it.author.takeIf { a -> a.isNotBlank() }
it.coverImage?.let { img ->
coverPath = recentFilesRepository.saveCoverToCache(img, uri)
}
}
}
FileType.MOBI -> {
val book = mobiParser.createMobiBook(
inputStream = inputStream,
originalBookNameHint = item.displayName
)
book?.let {
title = it.title.takeIf { t -> t.isNotBlank() }
author = it.author.takeIf { a -> a.isNotBlank() }
it.coverImage?.let { img ->
coverPath = recentFilesRepository.saveCoverToCache(img, uri)
}
}
}
FileType.PDF -> {
// PDF cover generation is heavy, but necessary
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
title = item.displayName.substringBeforeLast(".") // Clean filename
}
else -> { /* Text/MD files usually don't have covers */ }
}
}
// Only update if we actually found something useful
if (coverPath != null || title != null || author != null) {
val updatedItem = item.copy(
coverImagePath = coverPath ?: item.coverImagePath,
title = title ?: item.title ?: item.displayName,
author = author ?: item.author
)
recentFilesRepository.addRecentFile(updatedItem)
Timber.tag("MetadataWorker").d("Updated metadata for: ${item.displayName}")
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
}
}
return@withContext Result.success()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
return@withContext Result.failure()
}
}
}

View file

@ -86,6 +86,7 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@ -261,20 +262,43 @@ fun CustomTopAppBar(
}
@Composable
fun DeleteConfirmationDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit, isPermanentDelete: Boolean = false) {
fun DeleteConfirmationDialog(
count: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
isPermanentDelete: Boolean = false,
containsFolderItems: Boolean = false // New parameter
) {
val title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents"
val text = if (isPermanentDelete) {
if (containsFolderItems) {
"Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone."
} else {
"Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone."
}
} else {
"Do you want to remove $count selected file(s) from the recent files list? It will reappear if you open it again from the library."
}
val confirmText = if (isPermanentDelete) "Delete" else "Remove"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(text) },
text = {
Text(
text,
color = if (containsFolderItems && isPermanentDelete) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
)
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirmText) }
TextButton(
onClick = onConfirm,
colors = if (containsFolderItems && isPermanentDelete) ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) else ButtonDefaults.textButtonColors()
) {
Text(confirmText)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
@ -430,7 +454,10 @@ fun EmptyState(
title: String,
message: String,
onSelectFileClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
primaryButtonText: String = "Select a File",
secondaryButtonText: String? = null,
onSecondaryClick: (() -> Unit)? = null
) {
Column(
modifier = modifier
@ -449,7 +476,8 @@ fun EmptyState(
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
@ -459,7 +487,15 @@ fun EmptyState(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
SelectFileButton(onClick = onSelectFileClick, text = "Select a File")
SelectFileButton(onClick = onSelectFileClick, text = primaryButtonText)
if (secondaryButtonText != null && onSecondaryClick != null) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = onSecondaryClick) {
Text(secondaryButtonText)
}
}
}
}

View file

@ -0,0 +1,102 @@
// FolderBookMetadata.kt
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.json.JSONObject
data class FolderBookMetadata(
val bookId: String,
val title: String?,
val author: String?,
val displayName: String,
val type: String,
val lastChapterIndex: Int?,
val lastPage: Int?,
val lastPositionCfi: String?,
val progressPercentage: Float,
val isRecent: Boolean,
// REMOVED: val isDeleted: Boolean,
val lastModifiedTimestamp: Long,
val bookmarksJson: String?,
val locatorBlockIndex: Int?,
val locatorCharOffset: Int?
) {
fun toJsonString(): String {
val json = JSONObject()
json.put("bookId", bookId)
json.put("title", title)
json.put("author", author)
json.put("displayName", displayName)
json.put("type", type)
json.put("lastChapterIndex", lastChapterIndex ?: -1)
json.put("lastPage", lastPage ?: -1)
json.put("lastPositionCfi", lastPositionCfi)
json.put("progressPercentage", progressPercentage.toDouble())
json.put("isRecent", isRecent)
// REMOVED: json.put("isDeleted", isDeleted)
json.put("lastModifiedTimestamp", lastModifiedTimestamp)
json.put("bookmarksJson", bookmarksJson)
json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
json.put("locatorCharOffset", locatorCharOffset ?: -1)
return json.toString()
}
companion object {
fun fromJsonString(jsonString: String): FolderBookMetadata {
val json = JSONObject(jsonString)
fun JSONObject.optStringNull(key: String): String? {
return if (has(key) && !isNull(key)) getString(key) else null
}
fun JSONObject.optIntNull(key: String): Int? {
val value = optInt(key, -1)
return if (value == -1) null else value
}
return FolderBookMetadata(
bookId = json.getString("bookId"),
title = json.optStringNull("title"),
author = json.optStringNull("author"),
displayName = json.optString("displayName", "Unknown"),
type = json.optString("type", "PDF"),
lastChapterIndex = json.optIntNull("lastChapterIndex"),
lastPage = json.optIntNull("lastPage"),
lastPositionCfi = json.optStringNull("lastPositionCfi"),
progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(),
isRecent = json.optBoolean("isRecent", true),
// REMOVED: isDeleted deserialization
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
bookmarksJson = json.optStringNull("bookmarksJson"),
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
locatorCharOffset = json.optIntNull("locatorCharOffset")
)
}
}
}
// Update the converter
fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
uriString = uriString,
type = try { FileType.valueOf(this.type) } catch (_: Exception) { FileType.EPUB },
displayName = this.displayName,
timestamp = System.currentTimeMillis(),
coverImagePath = coverPath,
title = this.title,
author = this.author,
lastChapterIndex = this.lastChapterIndex,
lastPage = this.lastPage,
lastPositionCfi = this.lastPositionCfi,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isAvailable = true,
lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = false, // ALWAYS FALSE for folder sync
bookmarksJson = this.bookmarksJson,
sourceFolderUri = sourceFolderUri
)
}

View file

@ -0,0 +1,260 @@
// LocalSyncUtils.kt
package com.aryan.reader.data
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
object LocalSyncUtils {
private const val SYNC_DIR_NAME = "episteme"
private const val TAG = "FolderSync"
suspend fun saveMetadataToFolder(
context: Context,
sourceFolderUri: Uri,
metadata: FolderBookMetadata
) = withContext(Dispatchers.IO) {
try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
val syncDir = getOrCreateSyncDir(rootTree)
if (syncDir == null) {
Timber.tag(TAG).e("Could not create/find $SYNC_DIR_NAME directory in $sourceFolderUri")
return@withContext
}
// Ensure .nomedia exists to prevent gallery clutter
ensureNoMedia(syncDir)
// Use hidden filename to avoid "Recents" clutter
val hiddenFileName = ".${metadata.bookId}.json"
val legacyFileName = "${metadata.bookId}.json"
// Check for existing files (Hidden OR Legacy)
val existingHidden = syncDir.findFile(hiddenFileName)
val existingLegacy = syncDir.findFile(legacyFileName)
// Prefer hidden, fallback to legacy for conflict check
val existingFile = existingHidden ?: existingLegacy
if (existingFile != null && existingFile.exists()) {
try {
val existingContent = context.contentResolver.openInputStream(existingFile.uri)?.use { input ->
input.bufferedReader().use { it.readText() }
}
if (existingContent != null) {
val existingMeta = FolderBookMetadata.fromJsonString(existingContent)
val diff = existingMeta.lastModifiedTimestamp - metadata.lastModifiedTimestamp
// Clobber Protection
if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
Timber.tag(TAG).w("ClobberCheck: ABORTING save for ${metadata.bookId}. Folder has newer data.")
return@withContext
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to read existing metadata for conflict check")
}
// Delete the existing file (whether hidden or legacy) before writing new one
try {
existingFile.delete()
} catch (e: Exception) {
Timber.tag(TAG).w("Failed to delete existing metadata file: ${e.message}")
}
}
// If we had a legacy file that wasn't the 'existingFile' (edge case), delete it too
if (existingLegacy != null && existingLegacy.exists()) {
try { existingLegacy.delete() } catch (_: Exception) {}
}
val newFile = syncDir.createFile("application/json", hiddenFileName)
if (newFile == null) {
Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}")
return@withContext
}
val jsonString = metadata.toJsonString()
try {
context.contentResolver.openOutputStream(newFile.uri)?.use { output ->
output.write(jsonString.toByteArray())
}
Timber.tag(TAG).d("Saved metadata for ${metadata.bookId} (Hidden)")
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to write content to metadata file for ${metadata.bookId}")
try { newFile.delete() } catch (_: Exception) {}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to save local metadata to folder.")
}
}
suspend fun getBookMetadata(
context: Context,
sourceFolderUri: Uri,
bookId: String
): FolderBookMetadata? = withContext(Dispatchers.IO) {
try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null
val syncDir = findSyncDir(rootTree) ?: return@withContext null
// Find all related files: hidden, legacy, and conflicts
val relatedFiles = syncDir.listFiles().filter { file ->
val name = file.name ?: ""
// Match: .bookId.json, bookId.json, or containing .sync-conflict
(name.contains(bookId)) && (name.endsWith(".json") || name.contains(".sync-conflict"))
}
if (relatedFiles.isEmpty()) return@withContext null
return@withContext resolveAndCleanConflicts(context, relatedFiles, bookId)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error resolving book metadata for $bookId")
}
return@withContext null
}
/**
* Reads all candidate files, picks the winner (highest timestamp),
* and deletes the losers (cleanup).
*/
private fun resolveAndCleanConflicts(
context: Context,
files: List<DocumentFile>,
bookId: String
): FolderBookMetadata? {
var bestMeta: FolderBookMetadata? = null
var bestFile: DocumentFile? = null
// 1. Find the winner
files.forEach { file ->
try {
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
input.bufferedReader().use { it.readText() }
}
if (jsonString != null) {
val meta = FolderBookMetadata.fromJsonString(jsonString)
// Ensure this file actually belongs to the book (defensive check against partial name matches)
if (meta.bookId == bookId) {
if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp) {
bestMeta = meta
bestFile = file
}
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to parse conflict file: ${file.name}")
}
}
// 2. Clean up losers
if (bestMeta != null && bestFile != null) {
val filesToDelete = files.filter { it.uri != bestFile!!.uri }
if (filesToDelete.isNotEmpty()) {
Timber.tag(TAG).i("Resolving conflicts for $bookId. Winner: ${bestFile!!.name}. Deleting ${filesToDelete.size} obsolete files.")
filesToDelete.forEach {
try { it.delete() } catch(_: Exception) {}
}
}
// 3. Migrate Legacy to Hidden if needed
val winnerName = bestFile!!.name ?: ""
if (!winnerName.startsWith(".")) {
Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName")
// We can't always rename easily with DocumentFile, so we allow 'saveMetadataToFolder'
// to handle the actual file swap next time a write happens, OR we could force a rewrite.
// For now, we leave it. The clutter is reduced by deleting conflicts.
// The next save operation will create the hidden file and delete this one.
}
}
return bestMeta
}
suspend fun getAllFolderMetadata(
context: Context,
sourceFolderUri: Uri
): Map<String, FolderBookMetadata> = withContext(Dispatchers.IO) {
val finalResults = mutableMapOf<String, FolderBookMetadata>()
try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
val syncDir = findSyncDir(rootTree) ?: return@withContext finalResults
// Ensure .nomedia exists while scanning
ensureNoMedia(syncDir)
val allFiles = syncDir.listFiles()
// Group files by bookId.
// Filename formats:
// 1. hidden: .[bookId].json
// 2. legacy: [bookId].json
// 3. conflict: .[bookId].sync-conflict... or [bookId].sync-conflict...
val groupedFiles = allFiles
.filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true }
.groupBy { file ->
var name = file.name ?: ""
// Remove leading dot
if (name.startsWith(".")) name = name.substring(1)
// Remove conflict suffix
name = name.substringBefore(".sync-conflict")
// Remove extension
name.substringBefore(".json")
}
groupedFiles.forEach { (bookId, files) ->
val winner = resolveAndCleanConflicts(context, files, bookId)
if (winner != null) {
finalResults[bookId] = winner
}
}
Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records.")
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error scanning .episteme folder")
}
return@withContext finalResults
}
private fun findSyncDir(root: DocumentFile): DocumentFile? {
// Look for exact match first
val standardDir = root.findFile(SYNC_DIR_NAME)
if (standardDir != null && standardDir.isDirectory) return standardDir
// Fallback search
val files = root.listFiles()
return files.firstOrNull {
it.isDirectory && (it.name == SYNC_DIR_NAME)
}
}
private fun getOrCreateSyncDir(root: DocumentFile): DocumentFile? {
val existing = findSyncDir(root)
if (existing != null) return existing
return root.createDirectory(SYNC_DIR_NAME)
}
private fun ensureNoMedia(dir: DocumentFile) {
if (dir.findFile(".nomedia") == null) {
try {
dir.createFile("application/octet-stream", ".nomedia")
} catch (e: Exception) {
Timber.tag(TAG).w("Failed to create .nomedia file")
}
}
}
}

View file

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// RecentFileDao.kt
package com.aryan.reader.data
import androidx.room.Dao
@ -33,6 +34,9 @@ interface RecentFileDao {
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileEntity>>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileEntity>
@Query("SELECT * FROM recent_files")
suspend fun getAllFiles(): List<RecentFileEntity>
@ -57,6 +61,12 @@ interface RecentFileDao {
@Query("SELECT * FROM recent_files WHERE uriString = :uriString")
suspend fun getFileByUri(uriString: String): RecentFileEntity?
@Query("DELETE FROM recent_files WHERE sourceFolderUri = :sourceFolderUri")
suspend fun deleteFilesBySourceFolder(sourceFolderUri: String)
@Query("SELECT * FROM recent_files WHERE bookId LIKE :prefix || '%'")
suspend fun getFilesWithIdPrefix(prefix: String): List<RecentFileEntity>
@Query("DELETE FROM recent_files")
suspend fun clearAll()
@ -74,4 +84,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
@Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0")
suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity>
}

View file

@ -17,11 +17,13 @@
*
* mail: epistemereader@gmail.com
*/
// RecentFilesRepository.kt
package com.aryan.reader.data
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.net.toUri
import timber.log.Timber
import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator
@ -34,7 +36,7 @@ import java.io.FileOutputStream
private const val COVER_CACHE_DIR = "cover_cache"
class RecentFilesRepository(context: Context) {
class RecentFilesRepository(private val context: Context) {
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
@ -60,6 +62,10 @@ class RecentFilesRepository(context: Context) {
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem()
}
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFilesBySourceFolder(sourceFolderUri).map { it.toRecentFileItem() }
}
suspend fun getAllFilesForSync(): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() }
}
@ -109,6 +115,42 @@ class RecentFilesRepository(context: Context) {
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
}
suspend fun syncLocalMetadataToFolder(bookId: String) = withContext(Dispatchers.IO) {
val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext
val folderUriString = entity.sourceFolderUri
if (folderUriString != null) {
Timber.d("Syncing metadata to local folder for book: $bookId")
val metadata = FolderBookMetadata(
bookId = entity.bookId,
title = entity.title,
author = entity.author,
displayName = entity.displayName,
type = entity.type.name,
lastChapterIndex = entity.lastChapterIndex,
lastPage = entity.lastPage,
lastPositionCfi = entity.lastPositionCfi,
progressPercentage = entity.progressPercentage ?: 0f,
isRecent = entity.isRecent,
lastModifiedTimestamp = entity.lastModifiedTimestamp,
bookmarksJson = entity.bookmarks,
locatorBlockIndex = entity.locatorBlockIndex,
locatorCharOffset = entity.locatorCharOffset
)
LocalSyncUtils.saveMetadataToFolder(
context = context, // Now correctly references the property
sourceFolderUri = folderUriString.toUri(),
metadata = metadata
)
}
}
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
recentFileDao.deleteFilesBySourceFolder(folderUriString)
}
suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
val item = recentFileDao.getFileByUri(uriString)
if (item != null) {
@ -126,6 +168,10 @@ class RecentFilesRepository(context: Context) {
}
}
suspend fun getFolderBooksWithoutCovers(): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() }
}
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
@ -171,7 +217,11 @@ class RecentFilesRepository(context: Context) {
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
itemsToRemove.forEach { item ->
item.coverImagePath?.let { deleteCachedCover(it) }
try {
item.uriString?.let { bookImporter.deleteBookByUriString(it) }
} catch (e: Exception) {
Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}")
}
}
recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
Timber.d("Permanently removed recent files from DB.")

View file

@ -67,6 +67,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -174,11 +175,11 @@ class ContentBridge(
@Suppress("unused")
class CfiJsBridge(
private val onCfiReady: (String) -> Unit,
private val onCfiForBookmarkReady: (String) -> Unit
private val onCfiForBookmarkReady: (String) -> Unit,
private val onScrollFinishedCallback: (Boolean) -> Unit
) {
@JavascriptInterface
fun onCfiExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI and diagnostics
try {
val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi", "/4")
@ -200,13 +201,12 @@ class CfiJsBridge(
}
} catch (e: Exception) {
Timber.e(e, "Error parsing CFI JSON response: $jsonResponse")
// Still call back with a fallback CFI so the app doesn't hang
onCfiReady("/4")
}
}
@JavascriptInterface
fun onCfiForBookmarkExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI for a bookmark action
try {
val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi")
@ -230,6 +230,12 @@ class CfiJsBridge(
Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse")
}
}
@JavascriptInterface
fun onScrollFinished(success: Boolean) {
Timber.tag("BookmarkDiagnosis").d("JS reported scroll finished. Success: $success")
onScrollFinishedCallback(success)
}
}
@Suppress("unused")
@ -303,6 +309,7 @@ fun ChapterWebView(
currentFontSize: Float,
currentLineHeight: Float,
onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier,
onTap: () -> Unit,
onPotentialScroll: () -> Unit,
onOverScrollTop: (dragAmount: Float) -> Unit,
@ -314,9 +321,9 @@ fun ChapterWebView(
onCfiGenerated: (cfi: String) -> Unit,
onBookmarkCfiGenerated: (cfi: String) -> Unit,
onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit,
onScrollFinished: (Boolean) -> Unit = {},
ttsScope: CoroutineScope,
tocFragments: List<String>,
modifier: Modifier = Modifier,
initialFragmentId: String? = null,
onTtsTextReady: suspend (String) -> Unit,
isProUser: Boolean,
@ -347,6 +354,11 @@ fun ChapterWebView(
var showPaletteManager by remember { mutableStateOf(false) }
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated)
val currentOnBookmarkCfiGenerated by rememberUpdatedState(onBookmarkCfiGenerated)
val currentOnScrollFinished by rememberUpdatedState(onScrollFinished)
LaunchedEffect(currentFontSize, currentLineHeight) {
localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
@ -499,6 +511,10 @@ fun ChapterWebView(
consoleMessage?.let {
val message = it.message()
when {
message.startsWith("BookmarkDiagnosis") -> {
Timber.tag("BookmarkDiagnosis").d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}")
}
message.startsWith("CFI_DIAGNOSIS:") -> {
Timber.d(
"JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}"
@ -549,15 +565,17 @@ fun ChapterWebView(
}
addJavascriptInterface(
CfiJsBridge(
onCfiReady = { cfi -> onCfiGenerated(cfi) },
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
), "CfiBridge")
addJavascriptInterface(SnippetJsBridge { cfi, snippet ->
onSnippetForBookmarkReady(
cfi,
snippet
onCfiReady = { cfi -> currentOnCfiGenerated(cfi) },
onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) },
onScrollFinishedCallback = { success -> currentOnScrollFinished(success) }
), "CfiBridge"
)
addJavascriptInterface(
SnippetJsBridge { cfi, snippet ->
currentOnSnippetForBookmarkReady(cfi, snippet)
}, "SnippetBridge"
)
}, "SnippetBridge")
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")
addJavascriptInterface(
AiJsBridge(ttsScope, onContentReadyForSummarization),

View file

@ -31,7 +31,6 @@ import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import timber.log.Timber
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@ -77,10 +76,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
@ -103,6 +100,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
@ -117,6 +115,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
@ -131,7 +130,6 @@ import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.SyncUpdateInfo
import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
@ -165,14 +163,13 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import androidx.compose.ui.BiasAlignment
import androidx.core.content.edit
private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked"
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
@ -206,8 +203,6 @@ fun EpubReaderScreen(
initialCfi: String?,
initialBookmarksJson: String?,
isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -230,8 +225,6 @@ fun EpubReaderScreen(
onNavigateToPro = onNavigateToPro,
coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange,
pendingSyncUpdate = pendingSyncUpdate,
onClearPendingSyncUpdate = onClearPendingSyncUpdate,
customFonts = customFonts,
onImportFont = onImportFont
)
@ -249,8 +242,6 @@ fun EpubReaderHost(
initialCfi: String?,
initialBookmarksJson: String?,
isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -269,6 +260,7 @@ fun EpubReaderHost(
val focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = remember { FocusRequester() }
var isNavigatingToBookmark by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
@ -559,62 +551,6 @@ fun EpubReaderHost(
}
}
LaunchedEffect(pendingSyncUpdate) {
if (pendingSyncUpdate != null) {
val locator = pendingSyncUpdate.locator
val message = if (locator != null) {
val chapterTitle = chapters.getOrNull(locator.chapterIndex)?.title ?: "another location"
"Newer reading position found in '$chapterTitle'. Sync now?"
} else {
"Bookmarks updated on another device. Sync now?"
}
val result = withTimeoutOrNull(10_000L) {
snackbarHostState.showSnackbar(
message = message,
actionLabel = "Sync",
withDismissAction = true,
duration = SnackbarDuration.Indefinite
)
}
if (result == SnackbarResult.ActionPerformed) {
if (locator != null) {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
val cfi = locatorConverter.getCfiFromLocator(epubBook.title, locator)
if (cfi != null) {
val targetChunk = locator.blockIndex / 20
if (currentChapterIndex != locator.chapterIndex) {
chunkTargetOverride = targetChunk
currentChapterIndex = locator.chapterIndex
} else {
if (targetChunk >= loadedChunkCount) {
loadUpToChunkIndex = targetChunk
}
}
cfiToLoad = cfi
} else {
Timber.w("Could not get CFI from locator for sync.")
}
}
RenderMode.PAGINATED -> {
(paginator as? BookPaginator)?.findPageForLocator(locator)?.let { page ->
scope.launch {
paginatedPagerState.scrollToPage(page)
}
}
}
}
}
pendingSyncUpdate.bookmarksJson?.let { newBookmarksJson ->
bookmarks = loadBookmarks(context, epubBook.title, chapters, newBookmarksJson)
}
}
onClearPendingSyncUpdate()
}
}
LaunchedEffect(skipChapterRequest) {
if (skipChapterRequest) {
skipChapterRequest = false
@ -1261,32 +1197,95 @@ fun EpubReaderHost(
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
cfiToLoad = bookmark.cfi
val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
val targetChunk = locator?.let { it.blockIndex / 20 }
// FIX: Try to extract chunk index directly from CFI for Vertical Mode
// Vertical Mode CFIs are relative to content-container, so the first number
// usually represents the chunk (2->Chunk0, 4->Chunk1, 6->Chunk2...)
val directChunkIndex = try {
val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() }
if (parts.isNotEmpty()) {
val firstIndex = parts[0]
// Standard EPUB CFI: indices are 1-based steps (2, 4, 6...)
(firstIndex - 2) / 2
} else null
} catch (e: Exception) {
null
}
val locator = if (directChunkIndex == null) {
locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
} else {
null
}
val targetChunk = directChunkIndex ?: locator?.let { it.blockIndex / 20 }
if (bookmark.chapterIndex != currentChapterIndex) {
if (targetChunk != null) {
chunkTargetOverride = targetChunk
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) {
targetChunk
} else {
chunkTargetOverride = 0
Timber.w("Could not get locator for bookmark CFI, will navigate to start of chapter.")
0
}
currentChapterIndex = bookmark.chapterIndex
} else {
if (targetChunk != null) {
}
else {
if (targetChunk != null && targetChunk >= 0) {
isNavigatingToBookmark = true
// FIX: Ensure we don't reload if we already have it,
// but do ensure the WebView has the content injected.
if (targetChunk >= loadedChunkCount) {
Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk")
val chunksToInject = (loadedChunkCount..targetChunk)
chunksToInject.forEach { idx ->
val content = chapterChunks.getOrNull(idx)
if (content != null) {
val escaped = escapeJsString(content)
webViewRefForTts?.evaluateJavascript(
"javascript:window.virtualization.appendChunk($idx, '$escaped');",
null
)
}
}
loadUpToChunkIndex = targetChunk
loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
} else {
webViewRefForTts?.evaluateJavascript("javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", null)
}
} else {
Timber.w("Could not get locator for bookmark CFI in current chapter, loading all chunks as fallback.")
loadUpToChunkIndex = if (chapterChunks.isNotEmpty()) chapterChunks.size - 1 else 0
}
// Even if loadedChunkCount is high enough in Kotlin state,
// ensure the specific chunk for the bookmark is actually in the DOM.
// (Sometimes rapid jumps might leave gaps if logic was loose)
val content = chapterChunks.getOrNull(targetChunk)
if (content != null) {
val escaped = escapeJsString(content)
webViewRefForTts?.evaluateJavascript(
"javascript:window.virtualization.appendChunk($targetChunk, '$escaped');",
null
)
}
}
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
null
)
scope.launch {
delay(3000)
if (isNavigatingToBookmark) {
isNavigatingToBookmark = false
}
}
} else {
// Fallback if we couldn't determine chunk
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
null
)
}
}
}
RenderMode.PAGINATED -> {
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
val locator = locatorConverter.getLocatorFromCfi(
@ -1588,7 +1587,7 @@ fun EpubReaderHost(
"ControlFlowWithEmptyBody"
)
ChapterWebView(
key = "$chapterKeyForWebView-$loadUpToChunkIndex",
key = "$chapterKeyForWebView",
chapterTitle = chapterToRender.title,
isDarkTheme = isDarkTheme,
initialScrollTarget = initialScrollTargetForChapter,
@ -1795,6 +1794,10 @@ fun EpubReaderHost(
null
)
},
onScrollFinished = { success ->
Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success")
isNavigatingToBookmark = false
},
ttsScope = scope,
onTtsTextReady = { jsonString ->
scope.launch {
@ -2959,6 +2962,26 @@ fun EpubReaderHost(
isTtsSessionActive = isTtsSessionActive
)
if (isNavigatingToBookmark) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f))
.clickable(enabled = true) {},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Navigating to bookmark...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
if (showPermissionRationaleDialog) {
AlertDialog(
onDismissRequest = { showPermissionRationaleDialog = false },

View file

@ -48,7 +48,6 @@ private fun Color.luminance(): Float {
return (0.299f * red + 0.587f * green + 0.114f * blue)
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
object CssParser {
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()

View file

@ -20,9 +20,7 @@
package com.aryan.reader.paginatedreader
import android.content.Context
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
@ -51,7 +49,6 @@ class LocatorConverter(
private val proto: ProtoBuf,
private val context: Context
) {
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
@ -114,12 +111,7 @@ class LocatorConverter(
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else {
Timber.w("getLocatorFromCfi: Chapter $chapterIndex not in DB. Triggering on-demand processing.")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
processAndCacheChapter(book, chapterIndex)
} else {
Timber.e("On-demand processing requires API 34+, cannot proceed.")
null
}
}
if (allBlocks == null) {
@ -238,9 +230,7 @@ class LocatorConverter(
val allBlocks = if (processedChapter != null) {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
processAndCacheChapter(book, locator.chapterIndex)
} else null
} ?: return@withContext null
var offset = 0

View file

@ -565,8 +565,6 @@ fun PdfViewerScreen(
initialPage: Int?,
initialBookmarksJson: String?,
isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit,
onSavePosition: (page: Int, totalPages: Int) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -1553,39 +1551,6 @@ fun PdfViewerScreen(
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
LaunchedEffect(pendingSyncUpdate) {
if (pendingSyncUpdate != null) {
val newPage = pendingSyncUpdate.page
val message = if (newPage != null) {
"Newer reading position found. Sync to page ${newPage + 1}?"
} else {
"Bookmarks updated on another device. Sync now?"
}
val result = withTimeoutOrNull(10_000L) {
snackbarHostState.showSnackbar(
message = message,
actionLabel = "Sync",
withDismissAction = true,
duration = SnackbarDuration.Indefinite
)
}
if (result == SnackbarResult.ActionPerformed) {
if (newPage != null) {
when (displayMode) {
DisplayMode.PAGINATION -> pagerState.scrollToPage(newPage)
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.scrollToPage(newPage)
}
}
pendingSyncUpdate.bookmarksJson?.let { newBookmarksJson ->
bookmarks = loadPdfBookmarksFromJson(newBookmarksJson)
}
}
onClearPendingSyncUpdate()
}
}
var isOcrModelDownloading by remember { mutableStateOf(false) }
LaunchedEffect(isOcrModelDownloading) {

View file

@ -38,9 +38,9 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.delay
private const val START_TIMEOUT_FAST_MS = 750L
private const val START_TIMEOUT_RETRY_MS = 2500L
private const val PROCESS_TIMEOUT_MS = 4000L
private const val START_TIMEOUT_FAST_MS = 3000L
private const val START_TIMEOUT_RETRY_MS = 4000L
private const val PROCESS_TIMEOUT_MS = 15000L
private const val MAX_RETRY_ATTEMPTS = 3
class BaseTtsSynthesizer(private val context: Context) {

View file

@ -180,9 +180,11 @@ class TtsPlaybackManager(
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
chunks.mapIndexed { index, text ->
TtsChunk(text, cfis[index], offsets[index])
val safeOffset = offsets.getOrNull(index) ?: -1
TtsChunk(text, cfis[index], safeOffset)
}
} else {
chunks.map { TtsChunk(it, "", -1) }