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:
parent
60f6566b31
commit
a81df6921d
21 changed files with 3378 additions and 2478 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
115
app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
Normal file
115
app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
102
app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
Normal file
102
app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
Normal 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
|
||||
)
|
||||
}
|
||||
260
app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
Normal file
260
app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue