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,121 +48,319 @@ 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 {
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) try {
if (documentTree == null || !documentTree.isDirectory) { appContext.contentResolver.takePersistableUriPermission(
Timber.e("Could not read the synced folder URI: $folderUriString. Cancelling worker.") folderUri,
WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME) android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
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
} }
} }
if (importedCount > 0) { foundBookIds.add(bookIdToUse)
Timber.d("Worker successfully imported $importedCount new book(s).")
} else {
Timber.d("Worker found no new books to import.")
}
prefs.edit { if (existingItem == null) {
putLong( val remoteMeta = folderMetadataMap[stableId]
MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB
System.currentTimeMillis()
// --- CHANGED: Removed extractFileInfo() call ---
// We use placeholders. The MetadataExtractionWorker will fix this later.
val placeholderTitle = file.name ?: "Unknown"
val placeholderAuthor = null
val placeholderCover = null
if (remoteMeta != null) {
Timber.tag("FolderSync").d("Worker: Importing existing book from Metadata + File: ${file.name}")
// We prefer remoteMeta if available because it might have the correct title/author from a previous sync
val tempItem = RecentFileItem(
bookId = stableId,
uriString = file.uri.toString(),
type = type,
displayName = file.name ?: "Unknown",
timestamp = remoteMeta.lastModifiedTimestamp,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
coverImagePath = null, // Will be fetched by MetadataWorker if needed
title = remoteMeta.title ?: placeholderTitle,
author = remoteMeta.author,
isAvailable = true,
isDeleted = false,
isRecent = false,
sourceFolderUri = folderUriString,
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson
) )
recentFilesRepository.addRecentFile(tempItem)
} else {
// FAST PATH: Insert barebones item
val newItem = RecentFileItem(
bookId = stableId,
uriString = file.uri.toString(),
type = type,
displayName = file.name ?: "Unknown",
timestamp = System.currentTimeMillis(),
coverImagePath = null, // Background worker will fill this
title = placeholderTitle,
author = null,
isAvailable = true,
lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false,
isRecent = false,
sourceFolderUri = folderUriString
)
recentFilesRepository.addRecentFile(newItem)
}
} else {
if (isMigration) {
val oldUriString = existingItem.uriString
val newUriString = file.uri.toString()
if (oldUriString != newUriString) {
Timber.tag("FolderSync").i("Migration: Updating URI and cleaning up internal storage for $bookIdToUse")
if (oldUriString != null) {
bookImporter.deleteBookByUriString(oldUriString)
} }
Result.success() existingItem = existingItem.copy(
uriString = newUriString,
isAvailable = true
)
recentFilesRepository.addRecentFile(existingItem)
}
} else if (existingItem.isDeleted) {
val resurrected = existingItem.copy(isDeleted = false, isAvailable = true)
recentFilesRepository.addRecentFile(resurrected)
}
val remoteMeta = folderMetadataMap[bookIdToUse]
if (remoteMeta == null) {
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
} else {
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("Worker: Remote metadata newer for ${file.name}")
val updatedItem = existingItem.copy(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson,
locatorBlockIndex = remoteMeta.locatorBlockIndex,
locatorCharOffset = remoteMeta.locatorCharOffset,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
timestamp = remoteMeta.lastModifiedTimestamp
)
recentFilesRepository.addRecentFile(updatedItem)
} else if (existingItem.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
}
}
}
}
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
if (idsToRemove.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
recentFilesRepository.deleteFilePermanently(idsToRemove)
}
val orphanedMetadataIds = folderMetadataMap.keys.filter { !foundBookIds.contains(it) }
if (orphanedMetadataIds.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${orphanedMetadataIds.size} orphaned metadata files.")
try {
val docTree = DocumentFile.fromTreeUri(appContext, folderUri)
val syncDir = docTree?.findFile("episteme")
if (syncDir != null) {
val allFiles = syncDir.listFiles()
orphanedMetadataIds.forEach { orphanId ->
allFiles.filter {
val name = it.name ?: ""
name.contains(orphanId) && (name.endsWith(".json") || name.contains(".sync-conflict"))
}.forEach { fileToDelete ->
try {
fileToDelete.delete()
} catch (_: Exception) { }
}
}
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error during folder sync worker execution.") Timber.tag("FolderSync").e(e, "Error during orphan cleanup")
Result.failure()
} }
} }
} }
private suspend fun prepareBookForImport(externalUri: Uri): Triple<Uri, String, FileType>? { // Reconcile Metadata (Write-back)
val type = getFileTypeFromUri(externalUri, appContext) ?: return null val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val hash = FileHasher.calculateSha256 { val booksToDelete = mutableListOf<String>()
appContext.contentResolver.openInputStream(externalUri)
} ?: return null
if (recentFilesRepository.getFileByBookId(hash) != null) { for (localBook in activeDbBooks) {
return null // Already exists val remoteMeta = folderMetadataMap[localBook.bookId]
if (remoteMeta == null) {
val exists = try {
val uri = localBook.uriString?.toUri()
if (uri != null) {
DocumentFile.fromSingleUri(appContext, uri)?.exists() == true
} else false
} catch (e: Exception) { false }
if (exists) {
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
} else {
Timber.tag("FolderSync").i("Metadata Sync: Book ${localBook.displayName} missing from disk. Scheduling removal.")
booksToDelete.add(localBook.bookId)
}
} else {
if (remoteMeta.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("SyncDecision: Remote NEWER for ${localBook.displayName}. Updating local DB.")
val updatedItem = localBook.copy(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson,
locatorBlockIndex = remoteMeta.locatorBlockIndex,
locatorCharOffset = remoteMeta.locatorCharOffset,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
timestamp = remoteMeta.lastModifiedTimestamp
)
recentFilesRepository.addRecentFile(updatedItem)
} else if (localBook.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
}
}
} }
val internalFile = bookImporter.importBook(externalUri) ?: return null if (booksToDelete.isNotEmpty()) {
return Triple(internalFile.toUri(), hash, type) recentFilesRepository.deleteFilePermanently(booksToDelete)
} }
private fun getFileNameFromUri(uri: Uri): String? { prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
return DocumentFile.fromSingleUri(appContext, uri)?.name
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
WorkManager.getInstance(appContext).enqueueUniqueWork(
MetadataExtractionWorker.WORK_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE,
metaRequest
)
return Result.success()
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
return Result.failure()
}
} }
private suspend fun addBookToDatabase( private data class ExtractedInfo(
uri: Uri, val title: String? = null,
type: FileType, val author: String? = null,
bookId: String, val coverPath: String? = null
displayName: String, )
sourceFolderUri: String
) { private suspend fun extractFileInfo(uri: Uri, type: FileType, displayName: String): ExtractedInfo {
var coverPath: String? = null var coverPath: String? = null
var title: String? = null var title: String? = null
var author: String? = null var author: String? = null
try {
if (type == FileType.EPUB || type == FileType.MOBI) { if (type == FileType.EPUB || type == FileType.MOBI) {
val book = withContext(Dispatchers.IO) { val book = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { inputStream -> appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
if (type == FileType.EPUB) { if (type == FileType.EPUB) {
epubParser.createEpubBook( epubParser.createEpubBook(
inputStream = inputStream, inputStream = inputStream,
originalBookNameHint = displayName originalBookNameHint = displayName,
parseContent = false
) )
} else { } else {
mobiParser.createMobiBook( mobiParser.createMobiBook(
@ -167,57 +371,39 @@ class FolderSyncWorker(
} }
} }
if (book != null) { if (book != null) {
title = book.title.takeIf { it.isNotBlank() } ?: displayName title = book.title.takeIf { it.isNotBlank() }
author = book.author.takeIf { it.isNotBlank() } author = book.author.takeIf { it.isNotBlank() }
book.coverImage?.let { book.coverImage?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri) coverPath = recentFilesRepository.saveCoverToCache(it, uri)
} }
} }
} else if (type == FileType.PDF) { } else if (type == FileType.PDF) {
title = displayName
pdfCoverGenerator.generateCover(uri)?.let { pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri) coverPath = recentFilesRepository.saveCoverToCache(it, uri)
} }
} }
} catch (e: Exception) {
val newItem = RecentFileItem( Timber.e(e, "Failed to extract info for file: $displayName")
bookId = bookId, }
uriString = uri.toString(), return ExtractedInfo(title, author, coverPath)
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,29 +418,59 @@ 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
) { ) {
val canRefresh = isSyncEnabled || hasSyncedFolder
val content = @Composable {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
RecentFilesGrid( RecentFilesGrid(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
recentFiles = recentFiles, recentFiles = recentFiles,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(), selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
onItemClick = onItemClick, onItemClick = onItemClick,
onItemLongClick = onItemLongClick, onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
downloadingBookIds = downloadingBookIds downloadingBookIds = downloadingBookIds
) )
Box( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(vertical = 24.dp), contentAlignment = Alignment.Center .padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) { ) {
SelectFileButton(onClick = onSelectFileClick, text = "Select Another File") androidx.compose.material3.Button(onClick = onSelectFileClick) {
Text("Select File")
} }
androidx.compose.material3.OutlinedButton(onClick = onNavigateToFolderSync) {
Text("Sync Folder")
}
}
}
}
if (canRefresh) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize()
) {
content()
}
} else {
content()
} }
} }
@ -509,6 +558,26 @@ fun RecentFileCard(
.fillMaxWidth(), .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"
@ -753,6 +824,35 @@ private fun AppDrawerContent(
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) }, 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 {
// OSS Header // OSS Header
Column( Column(
@ -1036,3 +1136,30 @@ fun FpsMonitor(modifier: Modifier = Modifier) {
.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( Column(
text = "Folder Sync is Active", modifier = Modifier.padding(20.dp),
style = MaterialTheme.typography.headlineSmall, verticalArrangement = Arrangement.spacedBy(16.dp)
color = MaterialTheme.colorScheme.onSurface, ) {
textAlign = TextAlign.Center, // Header Row with Status
modifier = Modifier.fillMaxWidth() // Add this modifier Row(
) modifier = Modifier.fillMaxWidth(),
Spacer(modifier = Modifier.height(16.dp)) horizontalArrangement = Arrangement.SpaceBetween,
Text( verticalAlignment = Alignment.CenterVertically
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) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(20.dp)) Icon(
Spacer(modifier = Modifier.width(8.dp)) imageVector = Icons.Default.FolderSpecial,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = "Scanning...", text = "Active Sync",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// Status Indicator
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = androidx.compose.foundation.shape.CircleShape
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"Scanning...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
}
} else { } else {
Box(
modifier = Modifier
.size(8.dp)
.background(
Color(0xFF4CAF50), // Green for active
androidx.compose.foundation.shape.CircleShape
)
)
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = lastScanText, "Monitoring",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() // Add this modifier
) )
} }
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onScanNowClick, enabled = !isLoading) {
Text("Scan for New Books")
} }
Spacer(modifier = Modifier.height(12.dp)) }
Button(onClick = onChangeFolderClick, enabled = !isLoading) { }
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// Folder Path
Column {
Text(
text = "LOCATION",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = folderPath,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
// Times Grid
Row(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "LAST CHECK",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = "NEXT AUTO SYNC",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = nextScanText, style = MaterialTheme.typography.bodySmall)
}
}
}
}
// 2. Main Actions
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Actions",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
androidx.compose.material3.FilledTonalButton(
onClick = onScanNowClick,
enabled = !isLoading,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan Files")
}
androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick,
enabled = !isLoading,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
Icon(
painter = painterResource(id = R.drawable.sync),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Sync Data")
}
}
}
Spacer(modifier = Modifier.weight(1f))
Column {
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onChangeFolderClick, enabled = !isLoading) {
Text("Change Folder") Text("Change Folder")
} }
Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = onDisconnectClick, enabled = !isLoading) { TextButton(
Text("Disconnect Folder") 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)
} }

View file

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

View file

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

View file

@ -86,6 +86,7 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.clickable import androidx.compose.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) {
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." "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) }
try {
item.uriString?.let { bookImporter.deleteBookByUriString(it) } 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 ->
currentOnSnippetForBookmarkReady(cfi, snippet)
}, "SnippetBridge"
) )
}, "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.
} else { // (Sometimes rapid jumps might leave gaps if logic was loose)
Timber.w("Could not get locator for bookmark CFI in current chapter, loading all chunks as fallback.") val content = chapterChunks.getOrNull(targetChunk)
loadUpToChunkIndex = if (chapterChunks.isNotEmpty()) chapterChunks.size - 1 else 0 if (content != null) {
} val escaped = escapeJsString(content)
webViewRefForTts?.evaluateJavascript(
"javascript:window.virtualization.appendChunk($targetChunk, '$escaped');",
null
)
} }
} }
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
null
)
scope.launch {
delay(3000)
if (isNavigatingToBookmark) {
isNavigatingToBookmark = false
}
}
} else {
// Fallback if we couldn't determine chunk
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
null
)
}
}
}
RenderMode.PAGINATED -> { 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) }