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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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