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, initialCfi = null,
initialBookmarksJson = null, initialBookmarksJson = null,
isProUser = false, isProUser = false,
pendingSyncUpdate = null,
onClearPendingSyncUpdate = {},
onNavigateBack = {}, onNavigateBack = {},
onSavePosition = { _, _, _ -> }, onSavePosition = { _, _, _ -> },
onBookmarksChanged = {}, onBookmarksChanged = {},

File diff suppressed because it is too large Load diff

View file

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

View file

@ -17,6 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// FolderSyncWorker.kt
package com.aryan.reader package com.aryan.reader
import android.content.Context import android.content.Context
@ -24,8 +25,10 @@ import android.net.Uri
import timber.log.Timber import timber.log.Timber
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository 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.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.data.LocalSyncUtils
class FolderSyncWorker( class FolderSyncWorker(
private val appContext: Context, private val appContext: Context,
@ -42,182 +48,362 @@ class FolderSyncWorker(
) : CoroutineWorker(appContext, workerParams) { ) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext) private val recentFilesRepository = RecentFilesRepository(appContext)
private val bookImporter = BookImporter(appContext)
private val epubParser = EpubParser(appContext) private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext) private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext) private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val bookImporter = BookImporter(appContext)
companion object { companion object {
const val WORK_NAME = "FolderSyncWorker" 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 { 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 prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null) val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
if (folderUriString.isNullOrBlank()) { if (folderUriString.isNullOrBlank()) return Result.success()
Timber.d("No sync folder configured. Worker stopping.")
return Result.success()
}
val folderUri = folderUriString.toUri() val folderUri = folderUriString.toUri()
return withContext(Dispatchers.IO) { try {
try { try {
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) appContext.contentResolver.takePersistableUriPermission(
if (documentTree == null || !documentTree.isDirectory) { folderUri,
Timber.e("Could not read the synced folder URI: $folderUriString. Cancelling worker.") android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME) )
return@withContext Result.failure() } 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>() val fileQueue = ArrayDeque<DocumentFile>()
documentTree.listFiles().let { fileQueue.addAll(it) } documentTree.listFiles().let { fileQueue.addAll(it) }
while (fileQueue.isNotEmpty()) { while (fileQueue.isNotEmpty()) {
val file = fileQueue.removeAt(0) val file = fileQueue.removeAt(0)
if (file.isDirectory) { if (file.isDirectory) {
if (file.name == ".episteme") continue
file.listFiles().let { fileQueue.addAll(it) } file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) { } else if (file.isFile) {
val fileName = file.name ?: "" val name = file.name ?: ""
if (fileName.endsWith(".pdf", true) || fileName.endsWith(".epub", true) || fileName.endsWith(".mobi", true) || fileName.endsWith(".azw3", true)) { if (isValidExtension(name)) {
filesToScan.add(file) currentDiskFiles.add(file)
} }
} }
} }
var importedCount = 0 val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
for (file in filesToScan) {
val importResult = prepareBookForImport(file.uri) val legacyLookup = activeDbBooks.associateBy { it.displayName }
if (importResult != null) {
val (internalUri, bookId, type) = importResult val foundBookIds = mutableSetOf<String>()
val displayName = file.name ?: "Unknown File"
addBookToDatabase(internalUri, type, bookId, displayName, folderUriString) for (file in currentDiskFiles) {
importedCount++ 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
}
}
foundBookIds.add(bookIdToUse)
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)
}
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)
}
}
} }
} }
if (importedCount > 0) { val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
Timber.d("Worker successfully imported $importedCount new book(s).") val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
} else {
Timber.d("Worker found no new books to import.") if (idsToRemove.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
recentFilesRepository.deleteFilePermanently(idsToRemove)
} }
prefs.edit { val orphanedMetadataIds = folderMetadataMap.keys.filter { !foundBookIds.contains(it) }
putLong(
MainViewModel.KEY_LAST_FOLDER_SCAN_TIME,
System.currentTimeMillis()
)
}
Result.success() if (orphanedMetadataIds.isNotEmpty()) {
} catch (e: Exception) { Timber.tag("FolderSync").i("Cleaning up ${orphanedMetadataIds.size} orphaned metadata files.")
Timber.e(e, "Error during folder sync worker execution.")
Result.failure() 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.tag("FolderSync").e(e, "Error during orphan cleanup")
}
}
} }
// Reconcile Metadata (Write-back)
val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val booksToDelete = mutableListOf<String>()
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)
}
}
}
if (booksToDelete.isNotEmpty()) {
recentFilesRepository.deleteFilePermanently(booksToDelete)
}
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 prepareBookForImport(externalUri: Uri): Triple<Uri, String, FileType>? { private data class ExtractedInfo(
val type = getFileTypeFromUri(externalUri, appContext) ?: return null val title: String? = null,
val author: String? = null,
val coverPath: String? = null
)
val hash = FileHasher.calculateSha256 { private suspend fun extractFileInfo(uri: Uri, type: FileType, displayName: String): ExtractedInfo {
appContext.contentResolver.openInputStream(externalUri)
} ?: return null
if (recentFilesRepository.getFileByBookId(hash) != null) {
return null // Already exists
}
val internalFile = bookImporter.importBook(externalUri) ?: return null
return Triple(internalFile.toUri(), hash, type)
}
private fun getFileNameFromUri(uri: Uri): String? {
return DocumentFile.fromSingleUri(appContext, uri)?.name
}
private suspend fun addBookToDatabase(
uri: Uri,
type: FileType,
bookId: String,
displayName: String,
sourceFolderUri: String
) {
var coverPath: String? = null var coverPath: String? = null
var title: String? = null var title: String? = null
var author: String? = null var author: String? = null
if (type == FileType.EPUB || type == FileType.MOBI) { try {
val book = withContext(Dispatchers.IO) { if (type == FileType.EPUB || type == FileType.MOBI) {
appContext.contentResolver.openInputStream(uri)?.use { inputStream -> val book = withContext(Dispatchers.IO) {
if (type == FileType.EPUB) { appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
epubParser.createEpubBook( if (type == FileType.EPUB) {
inputStream = inputStream, epubParser.createEpubBook(
originalBookNameHint = displayName inputStream = inputStream,
) originalBookNameHint = displayName,
} else { parseContent = false
mobiParser.createMobiBook( )
inputStream = inputStream, } else {
originalBookNameHint = displayName mobiParser.createMobiBook(
) inputStream = inputStream,
originalBookNameHint = displayName
)
}
} }
} }
} if (book != null) {
if (book != null) { title = book.title.takeIf { it.isNotBlank() }
title = book.title.takeIf { it.isNotBlank() } ?: displayName author = book.author.takeIf { it.isNotBlank() }
author = book.author.takeIf { it.isNotBlank() } book.coverImage?.let {
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
} else if (type == FileType.PDF) {
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri) coverPath = recentFilesRepository.saveCoverToCache(it, uri)
} }
} }
} else if (type == FileType.PDF) { } catch (e: Exception) {
title = displayName Timber.e(e, "Failed to extract info for file: $displayName")
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
} }
return ExtractedInfo(title, author, coverPath)
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")
} }
private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? { private fun isValidExtension(name: String): Boolean {
val mimeType = context.contentResolver.getType(uri) return name.endsWith(".pdf", true) ||
return when (mimeType) { name.endsWith(".epub", true) ||
"application/pdf" -> FileType.PDF name.endsWith(".mobi", true) ||
"application/epub+zip" -> FileType.EPUB name.endsWith(".azw3", true) ||
"application/x-mobipocket-ebook", name.endsWith(".md", true)
"application/vnd.amazon.ebook", }
"application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
else -> { private fun getFileType(name: String, mimeType: String?): FileType? {
val path = getFileNameFromUri(uri) return when {
when { mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
path?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB
path?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
path?.endsWith(".mobi", ignoreCase = true) == true -> FileType.MOBI name.endsWith(".md", true) -> FileType.MD
path?.endsWith(".azw3", ignoreCase = true) == true -> FileType.MOBI name.endsWith(".txt", true) -> FileType.TXT
path?.endsWith(".prc", ignoreCase = true) == true -> FileType.MOBI else -> null
else -> null
}
}
} }
} }
} }

View file

@ -17,6 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// HomeScreen
@file:Suppress("DEPRECATION") @file:Suppress("DEPRECATION")
package com.aryan.reader 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.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Info
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert 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.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
@ -253,6 +257,7 @@ fun HomeScreen(
} }
}, },
navController = navController, navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
) )
}) { }) {
Scaffold( Scaffold(
@ -271,7 +276,8 @@ fun HomeScreen(
drawerState.open() drawerState.open()
} }
}, },
onShowDeviceManagement = viewModel::showDeviceManagementForDebug onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
@ -291,14 +297,16 @@ fun HomeScreen(
if (uiState.recentFiles.isEmpty()) { if (uiState.recentFiles.isEmpty()) {
EmptyState( EmptyState(
title = "Your Library is Empty", 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, onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
secondaryButtonText = "Setup Folder Sync",
onSecondaryClick = { viewModel.navigateToFolderSync() }
) )
} else { } else {
EmptyState( EmptyState(
title = "No Recent Files", 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, onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@ -310,8 +318,13 @@ fun HomeScreen(
onItemClick = { item -> viewModel.onRecentFileClicked(item) }, onItemClick = { item -> viewModel.onRecentFileClicked(item) },
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) }, onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
onSelectFileClick = onSelectFileClick, onSelectFileClick = onSelectFileClick,
onNavigateToFolderSync = { viewModel.navigateToFolderSync() },
windowSizeClass = windowSizeClass, 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 @Composable
private fun RecentFilesContent( private fun RecentFilesContent(
recentFiles: List<RecentFileItem>, recentFiles: List<RecentFileItem>,
@ -399,30 +418,60 @@ private fun RecentFilesContent(
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit,
onSelectFileClick: () -> Unit, onSelectFileClick: () -> Unit,
onNavigateToFolderSync: () -> Unit,
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
onRefresh: () -> Unit,
isRefreshing: Boolean,
isSyncEnabled: Boolean,
hasSyncedFolder: Boolean
) { ) {
Box(modifier = Modifier.fillMaxSize()) { val canRefresh = isSyncEnabled || hasSyncedFolder
RecentFilesGrid(
modifier = Modifier.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),
downloadingBookIds = downloadingBookIds
)
Box( val content = @Composable {
modifier = Modifier Box(modifier = Modifier.fillMaxSize()) {
.fillMaxWidth() RecentFilesGrid(
.align(Alignment.BottomCenter) modifier = Modifier
.padding(vertical = 24.dp), contentAlignment = Alignment.Center .fillMaxSize()
) { .padding(horizontal = 16.dp),
SelectFileButton(onClick = onSelectFileClick, text = "Select Another File") recentFiles = recentFiles,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
onItemClick = onItemClick,
onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass,
contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
downloadingBookIds = downloadingBookIds
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
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()
}
} }
@Composable @Composable
@ -509,6 +558,26 @@ fun RecentFileCard(
.fillMaxWidth(), .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) { if (!item.isAvailable) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -576,7 +645,8 @@ fun DefaultTopAppBar(
onClearCloudData: () -> Unit, onClearCloudData: () -> Unit,
onDrawerClick: () -> Unit, onDrawerClick: () -> Unit,
onAboutClick: () -> Unit, onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
@ -636,7 +706,8 @@ private fun AppDrawerContent(
onUpgradeClick: () -> Unit, onUpgradeClick: () -> Unit,
onSyncUpsellClick: () -> Unit, onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit, onFontsClick: () -> Unit,
navController: NavHostController navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit
) { ) {
val isOss = BuildConfig.FLAVOR == "oss" val isOss = BuildConfig.FLAVOR == "oss"
@ -682,10 +753,10 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
NavigationDrawerItem( NavigationDrawerItem(
icon = { icon = {
Icon( Icon(
Icons.Outlined.AccountCircle, contentDescription = "Sign In" Icons.Outlined.AccountCircle, contentDescription = "Sign In"
) )
}, },
label = { Text("Sign in with Google") }, label = { Text("Sign in with Google") },
selected = false, selected = false,
onClick = onSignInClick, onClick = onSignInClick,
@ -705,10 +776,10 @@ private fun AppDrawerContent(
NavigationDrawerItem( NavigationDrawerItem(
icon = { icon = {
Icon( Icon(
Icons.Default.VerifiedUser, contentDescription = "Episteme Pro" Icons.Default.VerifiedUser, contentDescription = "Episteme Pro"
) )
}, },
label = { label = {
val text = val text =
if (uiState.isProUser) "Episteme Pro" else "Upgrade to Episteme Pro" if (uiState.isProUser) "Episteme Pro" else "Upgrade to Episteme Pro"
@ -723,34 +794,63 @@ private fun AppDrawerContent(
if (uiState.currentUser != null) { if (uiState.currentUser != null) {
NavigationDrawerItem( NavigationDrawerItem(
icon = { icon = {
Icon( Icon(
painter = painterResource(id = R.drawable.sync), painter = painterResource(id = R.drawable.sync),
contentDescription = "Sync Library" contentDescription = "Sync Library"
)
}, label = { Text("Sync Library") }, badge = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!uiState.isProUser) {
Icon(
imageVector = Icons.Default.VerifiedUser,
contentDescription = "Pro Feature",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
}
Switch(
checked = uiState.isSyncEnabled, onCheckedChange = {
if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick()
}, enabled = uiState.isProUser
) )
} }, label = { Text("Sync Library") }, badge = {
}, selected = false, onClick = { Row(verticalAlignment = Alignment.CenterVertically) {
if (uiState.isProUser) { if (!uiState.isProUser) {
onSyncToggle(!uiState.isSyncEnabled) Icon(
} else { imageVector = Icons.Default.VerifiedUser,
onSyncUpsellClick() contentDescription = "Pro Feature",
} modifier = Modifier.size(20.dp),
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
}
Switch(
checked = uiState.isSyncEnabled, onCheckedChange = {
if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick()
}, enabled = uiState.isProUser
)
}
}, selected = false, onClick = {
if (uiState.isProUser) {
onSyncToggle(!uiState.isSyncEnabled)
} else {
onSyncUpsellClick()
}
}, 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 { } else {
@ -774,11 +874,11 @@ private fun AppDrawerContent(
NavigationDrawerItem( NavigationDrawerItem(
icon = { icon = {
Icon( Icon(
painter = painterResource(id = R.drawable.fonts), painter = painterResource(id = R.drawable.fonts),
contentDescription = "Custom Fonts" contentDescription = "Custom Fonts"
) )
}, },
label = { Text("Custom Fonts") }, label = { Text("Custom Fonts") },
selected = false, selected = false,
onClick = onFontsClick, onClick = onFontsClick,
@ -788,11 +888,11 @@ private fun AppDrawerContent(
if (!isOss) { if (!isOss) {
NavigationDrawerItem( NavigationDrawerItem(
icon = { icon = {
Icon( Icon(
painter = painterResource(id = R.drawable.feedback), painter = painterResource(id = R.drawable.feedback),
contentDescription = "Feedback" contentDescription = "Feedback"
) )
}, },
label = { Text("Help & Feedback") }, label = { Text("Help & Feedback") },
badge = { badge = {
if (uiState.hasUnreadFeedback) { if (uiState.hasUnreadFeedback) {
@ -807,11 +907,11 @@ private fun AppDrawerContent(
if (uiState.currentUser != null) { if (uiState.currentUser != null) {
NavigationDrawerItem( NavigationDrawerItem(
icon = { icon = {
Icon( Icon(
painter = painterResource(id = R.drawable.logout), painter = painterResource(id = R.drawable.logout),
contentDescription = "Sign Out" contentDescription = "Sign Out"
) )
}, },
label = { Text("Sign Out") }, label = { Text("Sign Out") },
selected = false, selected = false,
onClick = onSignOutClick, onClick = onSignOutClick,
@ -1035,4 +1135,31 @@ fun FpsMonitor(modifier: Modifier = Modifier) {
.background(Color.Black.copy(alpha = 0.5f)) .background(Color.Black.copy(alpha = 0.5f))
.padding(4.dp) .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 * mail: epistemereader@gmail.com
*/ */
// LibraryScreen.kt
package com.aryan.reader package com.aryan.reader
import android.content.Context import android.content.Context
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.activity.compose.BackHandler 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.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check 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.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -67,6 +77,7 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -79,6 +90,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
@ -96,20 +110,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File 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.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import androidx.core.net.toUri
private fun getBookCountString(count: Int): String { private fun getBookCountString(count: Int): String {
return if (count == 1) "1 book" else "$count books" return if (count == 1) "1 book" else "$count books"
@ -130,6 +133,17 @@ fun LibraryScreen(
initialPage = uiState.libraryScreenStartPage, initialPage = uiState.libraryScreenStartPage,
pageCount = { 3 } 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() val scope = rememberCoroutineScope()
var isSearchActive by remember { mutableStateOf(false) } var isSearchActive by remember { mutableStateOf(false) }
@ -160,8 +174,11 @@ fun LibraryScreen(
pickFileLauncher.launch(arrayOf("*/*")) pickFileLauncher.launch(arrayOf("*/*"))
} }
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState) {
viewModel.setLibraryScreenPage(pagerState.currentPage) androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
.collect { page ->
viewModel.setLibraryScreenPage(page)
}
} }
var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) }
@ -217,6 +234,7 @@ fun LibraryScreen(
onNewShelfClick = viewModel::showCreateShelfDialog, onNewShelfClick = viewModel::showCreateShelfDialog,
onSelectFileClick = onSelectFileClick, onSelectFileClick = onSelectFileClick,
onScanNowClick = viewModel::scanSyncedFolder, onScanNowClick = viewModel::scanSyncedFolder,
onSyncMetadataClick = viewModel::syncFolderMetadata,
onSelectSyncFolderClick = onSelectSyncFolderClick, onSelectSyncFolderClick = onSelectSyncFolderClick,
onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder, onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder,
downloadingBookIds = uiState.downloadingBookIds, downloadingBookIds = uiState.downloadingBookIds,
@ -241,9 +259,11 @@ fun LibraryScreen(
showDeleteConfirmDialog = false showDeleteConfirmDialog = false
}, },
onDismiss = { showDeleteConfirmDialog = false }, onDismiss = { showDeleteConfirmDialog = false },
isPermanentDelete = true isPermanentDelete = true,
containsFolderItems = containsFolderItems
) )
} }
if (showDeleteShelvesDialog) { if (showDeleteShelvesDialog) {
DeleteShelvesConfirmationDialog( DeleteShelvesConfirmationDialog(
count = selectedShelves.size, count = selectedShelves.size,
@ -409,6 +429,7 @@ fun LibraryScreenContent(
onNewShelfClick: () -> Unit, onNewShelfClick: () -> Unit,
onSelectFileClick: () -> Unit, onSelectFileClick: () -> Unit,
onScanNowClick: () -> Unit, onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onSelectSyncFolderClick: () -> Unit, onSelectSyncFolderClick: () -> Unit,
onDisconnectSyncFolderClick: () -> Unit, onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
@ -621,6 +642,7 @@ fun LibraryScreenContent(
lastScanTime = lastFolderScanTime, lastScanTime = lastFolderScanTime,
onSelectFolderClick = onSelectSyncFolderClick, onSelectFolderClick = onSelectSyncFolderClick,
onScanNowClick = onScanNowClick, onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
onChangeFolderClick = onSelectSyncFolderClick, onChangeFolderClick = onSelectSyncFolderClick,
onDisconnectClick = onDisconnectSyncFolderClick, onDisconnectClick = onDisconnectSyncFolderClick,
isLoading = isLoading isLoading = isLoading
@ -1147,6 +1169,16 @@ private fun LibraryListItem(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { 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(
text = item.title ?: item.displayName, text = item.title ?: item.displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -1326,6 +1358,7 @@ private fun FolderSyncScreen(
lastScanTime: Long?, lastScanTime: Long?,
onSelectFolderClick: () -> Unit, onSelectFolderClick: () -> Unit,
onScanNowClick: () -> Unit, onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onChangeFolderClick: () -> Unit, onChangeFolderClick: () -> Unit,
onDisconnectClick: () -> Unit, onDisconnectClick: () -> Unit,
isLoading: Boolean isLoading: Boolean
@ -1334,9 +1367,10 @@ private fun FolderSyncScreen(
if (syncedFolderUri == null) { if (syncedFolderUri == null) {
EmptyState( EmptyState(
title = "Import files from a Folder", title = "Sync Local Folder",
message = "Select a folder on your device. Episteme will automatically find and import any new books you add to it.", 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, onSelectFileClick = onSelectFolderClick,
primaryButtonText = "Select Folder",
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} else { } else {
@ -1344,86 +1378,224 @@ private fun FolderSyncScreen(
getDisplayPathFromUri(context, syncedFolderUri) getDisplayPathFromUri(context, syncedFolderUri)
} }
// Calculate times
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = remember(lastScanTime) { val lastScanText = remember(lastScanTime) {
if (lastScanTime == null || lastScanTime == 0L) { if (lastScanTime == null || lastScanTime == 0L) "Never"
"Never scanned" else dateFormat.format(Date(lastScanTime))
} else { }
"Last scan: ${
SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()).format( val nextScanText = remember(lastScanTime) {
Date(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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 32.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp)
verticalArrangement = Arrangement.Center
) { ) {
Icon( // 1. Status Dashboard Card
imageVector = Icons.Default.FolderSpecial, // Using a standard icon androidx.compose.material3.ElevatedCard(
contentDescription = "Synced Folder", modifier = Modifier.fillMaxWidth(),
modifier = Modifier.size(80.dp), colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
tint = MaterialTheme.colorScheme.primary 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) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Scanning...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else {
Text(
text = lastScanText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() // Add this modifier
) )
} ) {
Spacer(modifier = Modifier.height(32.dp)) 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) {
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Active Sync",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Button(onClick = onScanNowClick, enabled = !isLoading) { // Status Indicator
Text("Scan for New Books") 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(
"Monitoring",
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
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)
}
}
}
} }
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = onChangeFolderClick, enabled = !isLoading) { // 2. Main Actions
Text("Change Folder") 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.height(12.dp))
TextButton(onClick = onDisconnectClick, enabled = !isLoading) { Spacer(modifier = Modifier.weight(1f))
Text("Disconnect Folder")
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")
}
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 * mail: epistemereader@gmail.com
*/ */
// MainScreen.kt
package com.aryan.reader package com.aryan.reader
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
@ -31,13 +34,15 @@ import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) { sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) {
object Home : BottomBarScreen("home", "Home", R.drawable.home) object Home : BottomBarScreen("home", "Home", R.drawable.home)
@ -55,6 +60,13 @@ fun MainScreen(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
navController: NavHostController navController: NavHostController
) { ) {
val context = LocalContext.current
SideEffect {
val activity = context as? ComponentActivity
activity?.enableEdgeToEdge()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val viewingShelfName = uiState.viewingShelfName val viewingShelfName = uiState.viewingShelfName
@ -67,6 +79,12 @@ fun MainScreen(
) )
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(uiState.mainScreenStartPage) {
if (pagerState.currentPage != uiState.mainScreenStartPage) {
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
}
}
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
viewModel.setMainScreenPage(pagerState.currentPage) viewModel.setMainScreenPage(pagerState.currentPage)
} }

File diff suppressed because it is too large Load diff

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.foundation.clickable
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -261,20 +262,43 @@ fun CustomTopAppBar(
} }
@Composable @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 title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents"
val text = if (isPermanentDelete) { val text = if (isPermanentDelete) {
"Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone." 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 { } 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." "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" val confirmText = if (isPermanentDelete) "Delete" else "Remove"
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(title) }, title = { Text(title) },
text = { Text(text) }, text = {
Text(
text,
color = if (containsFolderItems && isPermanentDelete) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
)
},
confirmButton = { 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 = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") } TextButton(onClick = onDismiss) { Text("Cancel") }
@ -430,7 +454,10 @@ fun EmptyState(
title: String, title: String,
message: String, message: String,
onSelectFileClick: () -> Unit, onSelectFileClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
primaryButtonText: String = "Select a File",
secondaryButtonText: String? = null,
onSecondaryClick: (() -> Unit)? = null
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@ -449,7 +476,8 @@ fun EmptyState(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@ -459,7 +487,15 @@ fun EmptyState(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(32.dp)) 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 * mail: epistemereader@gmail.com
*/ */
// RecentFileDao.kt
package com.aryan.reader.data package com.aryan.reader.data
import androidx.room.Dao import androidx.room.Dao
@ -33,6 +34,9 @@ interface RecentFileDao {
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") @Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileEntity>> 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") @Query("SELECT * FROM recent_files")
suspend fun getAllFiles(): List<RecentFileEntity> suspend fun getAllFiles(): List<RecentFileEntity>
@ -57,6 +61,12 @@ interface RecentFileDao {
@Query("SELECT * FROM recent_files WHERE uriString = :uriString") @Query("SELECT * FROM recent_files WHERE uriString = :uriString")
suspend fun getFileByUri(uriString: String): RecentFileEntity? 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") @Query("DELETE FROM recent_files")
suspend fun clearAll() suspend fun clearAll()
@ -74,4 +84,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)") @Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long) 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 * mail: epistemereader@gmail.com
*/ */
// RecentFilesRepository.kt
package com.aryan.reader.data package com.aryan.reader.data
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri
import timber.log.Timber import timber.log.Timber
import com.aryan.reader.BookImporter import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
@ -34,7 +36,7 @@ import java.io.FileOutputStream
private const val COVER_CACHE_DIR = "cover_cache" 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 recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR) private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
@ -60,6 +62,10 @@ class RecentFilesRepository(context: Context) {
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem() 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) { suspend fun getAllFilesForSync(): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() } 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}") 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) { suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
val item = recentFileDao.getFileByUri(uriString) val item = recentFileDao.getFileByUri(uriString)
if (item != null) { 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) { suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime) recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
@ -171,7 +217,11 @@ class RecentFilesRepository(context: Context) {
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.") Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
itemsToRemove.forEach { item -> itemsToRemove.forEach { item ->
item.coverImagePath?.let { deleteCachedCover(it) } item.coverImagePath?.let { deleteCachedCover(it) }
item.uriString?.let { bookImporter.deleteBookByUriString(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 }) recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
Timber.d("Permanently removed recent files from DB.") 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.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -174,11 +175,11 @@ class ContentBridge(
@Suppress("unused") @Suppress("unused")
class CfiJsBridge( class CfiJsBridge(
private val onCfiReady: (String) -> Unit, private val onCfiReady: (String) -> Unit,
private val onCfiForBookmarkReady: (String) -> Unit private val onCfiForBookmarkReady: (String) -> Unit,
private val onScrollFinishedCallback: (Boolean) -> Unit
) { ) {
@JavascriptInterface @JavascriptInterface
fun onCfiExtracted(jsonResponse: String) { fun onCfiExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI and diagnostics
try { try {
val json = JSONObject(jsonResponse) val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi", "/4") val cfi = json.optString("cfi", "/4")
@ -200,13 +201,12 @@ class CfiJsBridge(
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error parsing CFI JSON response: $jsonResponse") Timber.e(e, "Error parsing CFI JSON response: $jsonResponse")
// Still call back with a fallback CFI so the app doesn't hang
onCfiReady("/4") onCfiReady("/4")
} }
} }
@JavascriptInterface @JavascriptInterface
fun onCfiForBookmarkExtracted(jsonResponse: String) { fun onCfiForBookmarkExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI for a bookmark action
try { try {
val json = JSONObject(jsonResponse) val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi") val cfi = json.optString("cfi")
@ -230,6 +230,12 @@ class CfiJsBridge(
Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse") 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") @Suppress("unused")
@ -303,6 +309,7 @@ fun ChapterWebView(
currentFontSize: Float, currentFontSize: Float,
currentLineHeight: Float, currentLineHeight: Float,
onChapterInitiallyScrolled: () -> Unit, onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier,
onTap: () -> Unit, onTap: () -> Unit,
onPotentialScroll: () -> Unit, onPotentialScroll: () -> Unit,
onOverScrollTop: (dragAmount: Float) -> Unit, onOverScrollTop: (dragAmount: Float) -> Unit,
@ -314,9 +321,9 @@ fun ChapterWebView(
onCfiGenerated: (cfi: String) -> Unit, onCfiGenerated: (cfi: String) -> Unit,
onBookmarkCfiGenerated: (cfi: String) -> Unit, onBookmarkCfiGenerated: (cfi: String) -> Unit,
onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit, onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit,
onScrollFinished: (Boolean) -> Unit = {},
ttsScope: CoroutineScope, ttsScope: CoroutineScope,
tocFragments: List<String>, tocFragments: List<String>,
modifier: Modifier = Modifier,
initialFragmentId: String? = null, initialFragmentId: String? = null,
onTtsTextReady: suspend (String) -> Unit, onTtsTextReady: suspend (String) -> Unit,
isProUser: Boolean, isProUser: Boolean,
@ -347,6 +354,11 @@ fun ChapterWebView(
var showPaletteManager by remember { mutableStateOf(false) } 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) { LaunchedEffect(currentFontSize, currentLineHeight) {
localWebViewRef?.evaluateJavascript( localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();", "javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
@ -499,6 +511,10 @@ fun ChapterWebView(
consoleMessage?.let { consoleMessage?.let {
val message = it.message() val message = it.message()
when { when {
message.startsWith("BookmarkDiagnosis") -> {
Timber.tag("BookmarkDiagnosis").d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}")
}
message.startsWith("CFI_DIAGNOSIS:") -> { message.startsWith("CFI_DIAGNOSIS:") -> {
Timber.d( Timber.d(
"JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}" "JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}"
@ -549,15 +565,17 @@ fun ChapterWebView(
} }
addJavascriptInterface( addJavascriptInterface(
CfiJsBridge( CfiJsBridge(
onCfiReady = { cfi -> onCfiGenerated(cfi) }, onCfiReady = { cfi -> currentOnCfiGenerated(cfi) },
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) } onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) },
), "CfiBridge") onScrollFinishedCallback = { success -> currentOnScrollFinished(success) }
addJavascriptInterface(SnippetJsBridge { cfi, snippet -> ), "CfiBridge"
onSnippetForBookmarkReady( )
cfi,
snippet addJavascriptInterface(
) SnippetJsBridge { cfi, snippet ->
}, "SnippetBridge") currentOnSnippetForBookmarkReady(cfi, snippet)
}, "SnippetBridge"
)
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge") addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")
addJavascriptInterface( addJavascriptInterface(
AiJsBridge(ttsScope, onContentReadyForSummarization), AiJsBridge(ttsScope, onContentReadyForSummarization),

View file

@ -31,7 +31,6 @@ import android.graphics.Bitmap
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import timber.log.Timber
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -77,10 +76,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
@ -103,6 +100,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -131,7 +130,6 @@ import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.SyncUpdateInfo
import com.aryan.reader.countWords import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
@ -165,14 +163,13 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber
import java.io.File import java.io.File
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt 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_LOCKED_KEY = "auto_scroll_locked"
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider" private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
@ -206,8 +203,6 @@ fun EpubReaderScreen(
initialCfi: String?, initialCfi: String?,
initialBookmarksJson: String?, initialBookmarksJson: String?,
isProUser: Boolean, isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -230,8 +225,6 @@ fun EpubReaderScreen(
onNavigateToPro = onNavigateToPro, onNavigateToPro = onNavigateToPro,
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange, onRenderModeChange = onRenderModeChange,
pendingSyncUpdate = pendingSyncUpdate,
onClearPendingSyncUpdate = onClearPendingSyncUpdate,
customFonts = customFonts, customFonts = customFonts,
onImportFont = onImportFont onImportFont = onImportFont
) )
@ -249,8 +242,6 @@ fun EpubReaderHost(
initialCfi: String?, initialCfi: String?,
initialBookmarksJson: String?, initialBookmarksJson: String?,
isProUser: Boolean, isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -269,6 +260,7 @@ fun EpubReaderHost(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = remember { FocusRequester() } val containerFocusRequester = remember { FocusRequester() }
var isNavigatingToBookmark by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) } var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) } 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) { LaunchedEffect(skipChapterRequest) {
if (skipChapterRequest) { if (skipChapterRequest) {
skipChapterRequest = false skipChapterRequest = false
@ -1261,32 +1197,95 @@ fun EpubReaderHost(
when (currentRenderMode) { when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> { RenderMode.VERTICAL_SCROLL -> {
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
cfiToLoad = 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 (bookmark.chapterIndex != currentChapterIndex) {
if (targetChunk != null) { chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) {
chunkTargetOverride = targetChunk targetChunk
} else { } else {
chunkTargetOverride = 0 0
Timber.w("Could not get locator for bookmark CFI, will navigate to start of chapter.")
} }
currentChapterIndex = bookmark.chapterIndex 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) { 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 loadUpToChunkIndex = targetChunk
loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
} else { } else {
webViewRefForTts?.evaluateJavascript("javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", null) // 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 { } else {
Timber.w("Could not get locator for bookmark CFI in current chapter, loading all chunks as fallback.") // Fallback if we couldn't determine chunk
loadUpToChunkIndex = if (chapterChunks.isNotEmpty()) chapterChunks.size - 1 else 0 webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
null
)
} }
} }
} }
RenderMode.PAGINATED -> { RenderMode.PAGINATED -> {
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'") Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
val locator = locatorConverter.getLocatorFromCfi( val locator = locatorConverter.getLocatorFromCfi(
@ -1588,7 +1587,7 @@ fun EpubReaderHost(
"ControlFlowWithEmptyBody" "ControlFlowWithEmptyBody"
) )
ChapterWebView( ChapterWebView(
key = "$chapterKeyForWebView-$loadUpToChunkIndex", key = "$chapterKeyForWebView",
chapterTitle = chapterToRender.title, chapterTitle = chapterToRender.title,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
initialScrollTarget = initialScrollTargetForChapter, initialScrollTarget = initialScrollTargetForChapter,
@ -1795,6 +1794,10 @@ fun EpubReaderHost(
null null
) )
}, },
onScrollFinished = { success ->
Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success")
isNavigatingToBookmark = false
},
ttsScope = scope, ttsScope = scope,
onTtsTextReady = { jsonString -> onTtsTextReady = { jsonString ->
scope.launch { scope.launch {
@ -2959,6 +2962,26 @@ fun EpubReaderHost(
isTtsSessionActive = isTtsSessionActive 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) { if (showPermissionRationaleDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showPermissionRationaleDialog = false }, onDismissRequest = { showPermissionRationaleDialog = false },

View file

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

View file

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

View file

@ -565,8 +565,6 @@ fun PdfViewerScreen(
initialPage: Int?, initialPage: Int?,
initialBookmarksJson: String?, initialBookmarksJson: String?,
isProUser: Boolean, isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSavePosition: (page: Int, totalPages: Int) -> Unit, onSavePosition: (page: Int, totalPages: Int) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -1553,39 +1551,6 @@ fun PdfViewerScreen(
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) } 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) } var isOcrModelDownloading by remember { mutableStateOf(false) }
LaunchedEffect(isOcrModelDownloading) { LaunchedEffect(isOcrModelDownloading) {

View file

@ -38,9 +38,9 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
private const val START_TIMEOUT_FAST_MS = 750L private const val START_TIMEOUT_FAST_MS = 3000L
private const val START_TIMEOUT_RETRY_MS = 2500L private const val START_TIMEOUT_RETRY_MS = 4000L
private const val PROCESS_TIMEOUT_MS = 4000L private const val PROCESS_TIMEOUT_MS = 15000L
private const val MAX_RETRY_ATTEMPTS = 3 private const val MAX_RETRY_ATTEMPTS = 3
class BaseTtsSynthesizer(private val context: Context) { 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 ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE) val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD } 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) { val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
chunks.mapIndexed { index, text -> chunks.mapIndexed { index, text ->
TtsChunk(text, cfis[index], offsets[index]) val safeOffset = offsets.getOrNull(index) ?: -1
TtsChunk(text, cfis[index], safeOffset)
} }
} else { } else {
chunks.map { TtsChunk(it, "", -1) } chunks.map { TtsChunk(it, "", -1) }