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
|
|
@ -24,8 +24,6 @@ class EpubTestActivity : ComponentActivity() {
|
||||||
initialCfi = null,
|
initialCfi = null,
|
||||||
initialBookmarksJson = null,
|
initialBookmarksJson = null,
|
||||||
isProUser = false,
|
isProUser = false,
|
||||||
pendingSyncUpdate = null,
|
|
||||||
onClearPendingSyncUpdate = {},
|
|
||||||
onNavigateBack = {},
|
onNavigateBack = {},
|
||||||
onSavePosition = { _, _, _ -> },
|
onSavePosition = { _, _, _ -> },
|
||||||
onBookmarksChanged = {},
|
onBookmarksChanged = {},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -128,8 +128,6 @@ fun AppNavigation(
|
||||||
initialPage = initialPage,
|
initialPage = initialPage,
|
||||||
initialBookmarksJson = initialBookmarksJson,
|
initialBookmarksJson = initialBookmarksJson,
|
||||||
isProUser = uiState.isProUser,
|
isProUser = uiState.isProUser,
|
||||||
pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId },
|
|
||||||
onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate,
|
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
Timber.d("Back action triggered from PDF Viewer.")
|
Timber.d("Back action triggered from PDF Viewer.")
|
||||||
viewModel.clearSelectedFile()
|
viewModel.clearSelectedFile()
|
||||||
|
|
@ -182,8 +180,6 @@ fun AppNavigation(
|
||||||
initialBookmarksJson = initialBookmarksJson,
|
initialBookmarksJson = initialBookmarksJson,
|
||||||
isProUser = uiState.isProUser,
|
isProUser = uiState.isProUser,
|
||||||
coverImagePath = coverPath,
|
coverImagePath = coverPath,
|
||||||
pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId },
|
|
||||||
onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate,
|
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
|
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
|
||||||
viewModel.clearSelectedFile()
|
viewModel.clearSelectedFile()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
|
// FolderSyncWorker.kt
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
@ -24,8 +25,10 @@ import android.net.Uri
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.RecentFilesRepository
|
import com.aryan.reader.data.RecentFilesRepository
|
||||||
|
|
@ -33,8 +36,11 @@ import com.aryan.reader.epub.EpubParser
|
||||||
import com.aryan.reader.epub.MobiParser
|
import com.aryan.reader.epub.MobiParser
|
||||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
import com.aryan.reader.pdf.PdfCoverGenerator
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import com.aryan.reader.data.LocalSyncUtils
|
||||||
|
|
||||||
class FolderSyncWorker(
|
class FolderSyncWorker(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
|
|
@ -42,121 +48,319 @@ class FolderSyncWorker(
|
||||||
) : CoroutineWorker(appContext, workerParams) {
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
private val recentFilesRepository = RecentFilesRepository(appContext)
|
private val recentFilesRepository = RecentFilesRepository(appContext)
|
||||||
private val bookImporter = BookImporter(appContext)
|
|
||||||
private val epubParser = EpubParser(appContext)
|
private val epubParser = EpubParser(appContext)
|
||||||
private val mobiParser = MobiParser(appContext)
|
private val mobiParser = MobiParser(appContext)
|
||||||
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
|
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
|
||||||
|
private val bookImporter = BookImporter(appContext)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val WORK_NAME = "FolderSyncWorker"
|
const val WORK_NAME = "FolderSyncWorker"
|
||||||
|
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
|
||||||
|
const val KEY_METADATA_ONLY = "key_metadata_only"
|
||||||
|
private val syncMutex = Mutex()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
Timber.d("Worker starting folder sync check.")
|
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
||||||
|
Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...")
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
syncMutex.withLock {
|
||||||
|
Timber.tag("FolderSync").d("Worker: Lock acquired. Starting Sync.")
|
||||||
|
performSync(isMetadataOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performSync(metadataOnly: Boolean): Result {
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
|
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
|
||||||
|
|
||||||
if (folderUriString.isNullOrBlank()) {
|
if (folderUriString.isNullOrBlank()) return Result.success()
|
||||||
Timber.d("No sync folder configured. Worker stopping.")
|
|
||||||
return Result.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
val folderUri = folderUriString.toUri()
|
val folderUri = folderUriString.toUri()
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
try {
|
||||||
if (documentTree == null || !documentTree.isDirectory) {
|
appContext.contentResolver.takePersistableUriPermission(
|
||||||
Timber.e("Could not read the synced folder URI: $folderUriString. Cancelling worker.")
|
folderUri,
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME)
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
return@withContext Result.failure()
|
)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val filesToScan = mutableListOf<DocumentFile>()
|
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
||||||
|
if (documentTree == null || !documentTree.isDirectory) {
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
||||||
|
|
||||||
|
if (!metadataOnly) {
|
||||||
|
val currentDiskFiles = mutableListOf<DocumentFile>()
|
||||||
val fileQueue = ArrayDeque<DocumentFile>()
|
val fileQueue = ArrayDeque<DocumentFile>()
|
||||||
documentTree.listFiles().let { fileQueue.addAll(it) }
|
documentTree.listFiles().let { fileQueue.addAll(it) }
|
||||||
|
|
||||||
while (fileQueue.isNotEmpty()) {
|
while (fileQueue.isNotEmpty()) {
|
||||||
val file = fileQueue.removeAt(0)
|
val file = fileQueue.removeAt(0)
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
|
if (file.name == ".episteme") continue
|
||||||
file.listFiles().let { fileQueue.addAll(it) }
|
file.listFiles().let { fileQueue.addAll(it) }
|
||||||
} else if (file.isFile) {
|
} else if (file.isFile) {
|
||||||
val fileName = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
if (fileName.endsWith(".pdf", true) || fileName.endsWith(".epub", true) || fileName.endsWith(".mobi", true) || fileName.endsWith(".azw3", true)) {
|
if (isValidExtension(name)) {
|
||||||
filesToScan.add(file)
|
currentDiskFiles.add(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var importedCount = 0
|
val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
for (file in filesToScan) {
|
|
||||||
val importResult = prepareBookForImport(file.uri)
|
val legacyLookup = activeDbBooks.associateBy { it.displayName }
|
||||||
if (importResult != null) {
|
|
||||||
val (internalUri, bookId, type) = importResult
|
val foundBookIds = mutableSetOf<String>()
|
||||||
val displayName = file.name ?: "Unknown File"
|
|
||||||
addBookToDatabase(internalUri, type, bookId, displayName, folderUriString)
|
for (file in currentDiskFiles) {
|
||||||
importedCount++
|
val stableId = "local_${file.name}_${file.length()}"
|
||||||
|
|
||||||
|
var existingItem = recentFilesRepository.getFileByBookId(stableId)
|
||||||
|
var bookIdToUse = stableId
|
||||||
|
var isMigration = false
|
||||||
|
|
||||||
|
if (existingItem == null) {
|
||||||
|
val legacyMatch = legacyLookup[file.name]
|
||||||
|
if (legacyMatch != null) {
|
||||||
|
Timber.tag("FolderSync").i("Migration: Found legacy match for ${file.name}. ID: ${legacyMatch.bookId}")
|
||||||
|
|
||||||
|
existingItem = legacyMatch
|
||||||
|
bookIdToUse = legacyMatch.bookId
|
||||||
|
isMigration = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (importedCount > 0) {
|
foundBookIds.add(bookIdToUse)
|
||||||
Timber.d("Worker successfully imported $importedCount new book(s).")
|
|
||||||
} else {
|
|
||||||
Timber.d("Worker found no new books to import.")
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs.edit {
|
if (existingItem == null) {
|
||||||
putLong(
|
val remoteMeta = folderMetadataMap[stableId]
|
||||||
MainViewModel.KEY_LAST_FOLDER_SCAN_TIME,
|
val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB
|
||||||
System.currentTimeMillis()
|
|
||||||
|
// --- CHANGED: Removed extractFileInfo() call ---
|
||||||
|
// We use placeholders. The MetadataExtractionWorker will fix this later.
|
||||||
|
val placeholderTitle = file.name ?: "Unknown"
|
||||||
|
val placeholderAuthor = null
|
||||||
|
val placeholderCover = null
|
||||||
|
|
||||||
|
if (remoteMeta != null) {
|
||||||
|
Timber.tag("FolderSync").d("Worker: Importing existing book from Metadata + File: ${file.name}")
|
||||||
|
// We prefer remoteMeta if available because it might have the correct title/author from a previous sync
|
||||||
|
val tempItem = RecentFileItem(
|
||||||
|
bookId = stableId,
|
||||||
|
uriString = file.uri.toString(),
|
||||||
|
type = type,
|
||||||
|
displayName = file.name ?: "Unknown",
|
||||||
|
timestamp = remoteMeta.lastModifiedTimestamp,
|
||||||
|
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
|
||||||
|
coverImagePath = null, // Will be fetched by MetadataWorker if needed
|
||||||
|
title = remoteMeta.title ?: placeholderTitle,
|
||||||
|
author = remoteMeta.author,
|
||||||
|
isAvailable = true,
|
||||||
|
isDeleted = false,
|
||||||
|
isRecent = false,
|
||||||
|
sourceFolderUri = folderUriString,
|
||||||
|
lastChapterIndex = remoteMeta.lastChapterIndex,
|
||||||
|
lastPage = remoteMeta.lastPage,
|
||||||
|
lastPositionCfi = remoteMeta.lastPositionCfi,
|
||||||
|
progressPercentage = remoteMeta.progressPercentage,
|
||||||
|
bookmarksJson = remoteMeta.bookmarksJson
|
||||||
)
|
)
|
||||||
|
recentFilesRepository.addRecentFile(tempItem)
|
||||||
|
} else {
|
||||||
|
// FAST PATH: Insert barebones item
|
||||||
|
val newItem = RecentFileItem(
|
||||||
|
bookId = stableId,
|
||||||
|
uriString = file.uri.toString(),
|
||||||
|
type = type,
|
||||||
|
displayName = file.name ?: "Unknown",
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
coverImagePath = null, // Background worker will fill this
|
||||||
|
title = placeholderTitle,
|
||||||
|
author = null,
|
||||||
|
isAvailable = true,
|
||||||
|
lastModifiedTimestamp = System.currentTimeMillis(),
|
||||||
|
isDeleted = false,
|
||||||
|
isRecent = false,
|
||||||
|
sourceFolderUri = folderUriString
|
||||||
|
)
|
||||||
|
recentFilesRepository.addRecentFile(newItem)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isMigration) {
|
||||||
|
val oldUriString = existingItem.uriString
|
||||||
|
val newUriString = file.uri.toString()
|
||||||
|
|
||||||
|
if (oldUriString != newUriString) {
|
||||||
|
Timber.tag("FolderSync").i("Migration: Updating URI and cleaning up internal storage for $bookIdToUse")
|
||||||
|
|
||||||
|
if (oldUriString != null) {
|
||||||
|
bookImporter.deleteBookByUriString(oldUriString)
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success()
|
existingItem = existingItem.copy(
|
||||||
|
uriString = newUriString,
|
||||||
|
isAvailable = true
|
||||||
|
)
|
||||||
|
recentFilesRepository.addRecentFile(existingItem)
|
||||||
|
}
|
||||||
|
} else if (existingItem.isDeleted) {
|
||||||
|
val resurrected = existingItem.copy(isDeleted = false, isAvailable = true)
|
||||||
|
recentFilesRepository.addRecentFile(resurrected)
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteMeta = folderMetadataMap[bookIdToUse]
|
||||||
|
|
||||||
|
if (remoteMeta == null) {
|
||||||
|
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
|
||||||
|
} else {
|
||||||
|
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
||||||
|
Timber.tag("FolderSync").d("Worker: Remote metadata newer for ${file.name}")
|
||||||
|
val updatedItem = existingItem.copy(
|
||||||
|
lastChapterIndex = remoteMeta.lastChapterIndex,
|
||||||
|
lastPage = remoteMeta.lastPage,
|
||||||
|
lastPositionCfi = remoteMeta.lastPositionCfi,
|
||||||
|
progressPercentage = remoteMeta.progressPercentage,
|
||||||
|
bookmarksJson = remoteMeta.bookmarksJson,
|
||||||
|
locatorBlockIndex = remoteMeta.locatorBlockIndex,
|
||||||
|
locatorCharOffset = remoteMeta.locatorCharOffset,
|
||||||
|
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
|
||||||
|
timestamp = remoteMeta.lastModifiedTimestamp
|
||||||
|
)
|
||||||
|
recentFilesRepository.addRecentFile(updatedItem)
|
||||||
|
} else if (existingItem.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
|
||||||
|
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
|
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
||||||
|
|
||||||
|
if (idsToRemove.isNotEmpty()) {
|
||||||
|
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
|
||||||
|
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
||||||
|
}
|
||||||
|
|
||||||
|
val orphanedMetadataIds = folderMetadataMap.keys.filter { !foundBookIds.contains(it) }
|
||||||
|
|
||||||
|
if (orphanedMetadataIds.isNotEmpty()) {
|
||||||
|
Timber.tag("FolderSync").i("Cleaning up ${orphanedMetadataIds.size} orphaned metadata files.")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val docTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
||||||
|
val syncDir = docTree?.findFile("episteme")
|
||||||
|
|
||||||
|
if (syncDir != null) {
|
||||||
|
val allFiles = syncDir.listFiles()
|
||||||
|
orphanedMetadataIds.forEach { orphanId ->
|
||||||
|
allFiles.filter {
|
||||||
|
val name = it.name ?: ""
|
||||||
|
name.contains(orphanId) && (name.endsWith(".json") || name.contains(".sync-conflict"))
|
||||||
|
}.forEach { fileToDelete ->
|
||||||
|
try {
|
||||||
|
fileToDelete.delete()
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error during folder sync worker execution.")
|
Timber.tag("FolderSync").e(e, "Error during orphan cleanup")
|
||||||
Result.failure()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun prepareBookForImport(externalUri: Uri): Triple<Uri, String, FileType>? {
|
// Reconcile Metadata (Write-back)
|
||||||
val type = getFileTypeFromUri(externalUri, appContext) ?: return null
|
val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
|
|
||||||
val hash = FileHasher.calculateSha256 {
|
val booksToDelete = mutableListOf<String>()
|
||||||
appContext.contentResolver.openInputStream(externalUri)
|
|
||||||
} ?: return null
|
|
||||||
|
|
||||||
if (recentFilesRepository.getFileByBookId(hash) != null) {
|
for (localBook in activeDbBooks) {
|
||||||
return null // Already exists
|
val remoteMeta = folderMetadataMap[localBook.bookId]
|
||||||
|
if (remoteMeta == null) {
|
||||||
|
val exists = try {
|
||||||
|
val uri = localBook.uriString?.toUri()
|
||||||
|
if (uri != null) {
|
||||||
|
DocumentFile.fromSingleUri(appContext, uri)?.exists() == true
|
||||||
|
} else false
|
||||||
|
} catch (e: Exception) { false }
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
|
||||||
|
} else {
|
||||||
|
Timber.tag("FolderSync").i("Metadata Sync: Book ${localBook.displayName} missing from disk. Scheduling removal.")
|
||||||
|
booksToDelete.add(localBook.bookId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (remoteMeta.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
|
||||||
|
Timber.tag("FolderSync").d("SyncDecision: Remote NEWER for ${localBook.displayName}. Updating local DB.")
|
||||||
|
val updatedItem = localBook.copy(
|
||||||
|
lastChapterIndex = remoteMeta.lastChapterIndex,
|
||||||
|
lastPage = remoteMeta.lastPage,
|
||||||
|
lastPositionCfi = remoteMeta.lastPositionCfi,
|
||||||
|
progressPercentage = remoteMeta.progressPercentage,
|
||||||
|
bookmarksJson = remoteMeta.bookmarksJson,
|
||||||
|
locatorBlockIndex = remoteMeta.locatorBlockIndex,
|
||||||
|
locatorCharOffset = remoteMeta.locatorCharOffset,
|
||||||
|
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
|
||||||
|
timestamp = remoteMeta.lastModifiedTimestamp
|
||||||
|
)
|
||||||
|
recentFilesRepository.addRecentFile(updatedItem)
|
||||||
|
} else if (localBook.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
|
||||||
|
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val internalFile = bookImporter.importBook(externalUri) ?: return null
|
if (booksToDelete.isNotEmpty()) {
|
||||||
return Triple(internalFile.toUri(), hash, type)
|
recentFilesRepository.deleteFilePermanently(booksToDelete)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFileNameFromUri(uri: Uri): String? {
|
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
|
||||||
return DocumentFile.fromSingleUri(appContext, uri)?.name
|
|
||||||
|
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
||||||
|
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
||||||
|
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||||
|
MetadataExtractionWorker.WORK_NAME,
|
||||||
|
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||||
|
metaRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun addBookToDatabase(
|
private data class ExtractedInfo(
|
||||||
uri: Uri,
|
val title: String? = null,
|
||||||
type: FileType,
|
val author: String? = null,
|
||||||
bookId: String,
|
val coverPath: String? = null
|
||||||
displayName: String,
|
)
|
||||||
sourceFolderUri: String
|
|
||||||
) {
|
private suspend fun extractFileInfo(uri: Uri, type: FileType, displayName: String): ExtractedInfo {
|
||||||
var coverPath: String? = null
|
var coverPath: String? = null
|
||||||
var title: String? = null
|
var title: String? = null
|
||||||
var author: String? = null
|
var author: String? = null
|
||||||
|
|
||||||
|
try {
|
||||||
if (type == FileType.EPUB || type == FileType.MOBI) {
|
if (type == FileType.EPUB || type == FileType.MOBI) {
|
||||||
val book = withContext(Dispatchers.IO) {
|
val book = withContext(Dispatchers.IO) {
|
||||||
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
if (type == FileType.EPUB) {
|
if (type == FileType.EPUB) {
|
||||||
epubParser.createEpubBook(
|
epubParser.createEpubBook(
|
||||||
inputStream = inputStream,
|
inputStream = inputStream,
|
||||||
originalBookNameHint = displayName
|
originalBookNameHint = displayName,
|
||||||
|
parseContent = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
mobiParser.createMobiBook(
|
mobiParser.createMobiBook(
|
||||||
|
|
@ -167,57 +371,39 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (book != null) {
|
if (book != null) {
|
||||||
title = book.title.takeIf { it.isNotBlank() } ?: displayName
|
title = book.title.takeIf { it.isNotBlank() }
|
||||||
author = book.author.takeIf { it.isNotBlank() }
|
author = book.author.takeIf { it.isNotBlank() }
|
||||||
book.coverImage?.let {
|
book.coverImage?.let {
|
||||||
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type == FileType.PDF) {
|
} else if (type == FileType.PDF) {
|
||||||
title = displayName
|
|
||||||
pdfCoverGenerator.generateCover(uri)?.let {
|
pdfCoverGenerator.generateCover(uri)?.let {
|
||||||
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
val newItem = RecentFileItem(
|
Timber.e(e, "Failed to extract info for file: $displayName")
|
||||||
bookId = bookId,
|
}
|
||||||
uriString = uri.toString(),
|
return ExtractedInfo(title, author, coverPath)
|
||||||
type = type,
|
|
||||||
displayName = displayName,
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
coverImagePath = coverPath,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
isAvailable = true,
|
|
||||||
lastModifiedTimestamp = System.currentTimeMillis(),
|
|
||||||
isDeleted = false,
|
|
||||||
isRecent = false, // Books from folder sync should not appear on the Home screen
|
|
||||||
sourceFolderUri = sourceFolderUri
|
|
||||||
)
|
|
||||||
recentFilesRepository.addRecentFile(newItem)
|
|
||||||
Timber.i("Worker added new book to database: $displayName")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? {
|
private fun isValidExtension(name: String): Boolean {
|
||||||
val mimeType = context.contentResolver.getType(uri)
|
return name.endsWith(".pdf", true) ||
|
||||||
return when (mimeType) {
|
name.endsWith(".epub", true) ||
|
||||||
"application/pdf" -> FileType.PDF
|
name.endsWith(".mobi", true) ||
|
||||||
"application/epub+zip" -> FileType.EPUB
|
name.endsWith(".azw3", true) ||
|
||||||
"application/x-mobipocket-ebook",
|
name.endsWith(".md", true)
|
||||||
"application/vnd.amazon.ebook",
|
}
|
||||||
"application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
|
|
||||||
else -> {
|
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||||
val path = getFileNameFromUri(uri)
|
return when {
|
||||||
when {
|
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
|
||||||
path?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
|
mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB
|
||||||
path?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
|
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
|
||||||
path?.endsWith(".mobi", ignoreCase = true) == true -> FileType.MOBI
|
name.endsWith(".md", true) -> FileType.MD
|
||||||
path?.endsWith(".azw3", ignoreCase = true) == true -> FileType.MOBI
|
name.endsWith(".txt", true) -> FileType.TXT
|
||||||
path?.endsWith(".prc", ignoreCase = true) == true -> FileType.MOBI
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
|
// HomeScreen
|
||||||
@file:Suppress("DEPRECATION")
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
@ -53,6 +54,8 @@ import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.FolderSpecial
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
|
@ -83,6 +86,7 @@ import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||||
|
|
@ -253,6 +257,7 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -271,7 +276,8 @@ fun HomeScreen(
|
||||||
drawerState.open()
|
drawerState.open()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShowDeviceManagement = viewModel::showDeviceManagementForDebug
|
onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
|
||||||
|
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContextualTopAppBar(
|
ContextualTopAppBar(
|
||||||
|
|
@ -291,14 +297,16 @@ fun HomeScreen(
|
||||||
if (uiState.recentFiles.isEmpty()) {
|
if (uiState.recentFiles.isEmpty()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = "Your Library is Empty",
|
title = "Your Library is Empty",
|
||||||
message = "Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.",
|
message = "Select a file to read, or sync a local folder to automatically import books.",
|
||||||
onSelectFileClick = onSelectFileClick,
|
onSelectFileClick = onSelectFileClick,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
|
secondaryButtonText = "Setup Folder Sync",
|
||||||
|
onSecondaryClick = { viewModel.navigateToFolderSync() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = "No Recent Files",
|
title = "No Recent Files",
|
||||||
message = "Open a file from your library to see it here, or select a new file to add.",
|
message = "Open a file from your library to see it here.",
|
||||||
onSelectFileClick = onSelectFileClick,
|
onSelectFileClick = onSelectFileClick,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
@ -310,8 +318,13 @@ fun HomeScreen(
|
||||||
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
|
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
|
||||||
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
|
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
|
||||||
onSelectFileClick = onSelectFileClick,
|
onSelectFileClick = onSelectFileClick,
|
||||||
|
onNavigateToFolderSync = { viewModel.navigateToFolderSync() },
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
downloadingBookIds = uiState.downloadingBookIds
|
downloadingBookIds = uiState.downloadingBookIds,
|
||||||
|
onRefresh = { viewModel.refreshLibrary() },
|
||||||
|
isRefreshing = uiState.isRefreshing,
|
||||||
|
isSyncEnabled = uiState.isSyncEnabled,
|
||||||
|
hasSyncedFolder = uiState.syncedFolderUri != null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -389,9 +402,15 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (uiState.showFolderMigrationDialog) {
|
||||||
|
FolderMigrationDialog(
|
||||||
|
onConfirm = { viewModel.completeFolderMigration() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecentFilesContent(
|
private fun RecentFilesContent(
|
||||||
recentFiles: List<RecentFileItem>,
|
recentFiles: List<RecentFileItem>,
|
||||||
|
|
@ -399,29 +418,59 @@ private fun RecentFilesContent(
|
||||||
onItemClick: (RecentFileItem) -> Unit,
|
onItemClick: (RecentFileItem) -> Unit,
|
||||||
onItemLongClick: (RecentFileItem) -> Unit,
|
onItemLongClick: (RecentFileItem) -> Unit,
|
||||||
onSelectFileClick: () -> Unit,
|
onSelectFileClick: () -> Unit,
|
||||||
|
onNavigateToFolderSync: () -> Unit,
|
||||||
windowSizeClass: WindowSizeClass,
|
windowSizeClass: WindowSizeClass,
|
||||||
downloadingBookIds: Set<String>,
|
downloadingBookIds: Set<String>,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
isRefreshing: Boolean,
|
||||||
|
isSyncEnabled: Boolean,
|
||||||
|
hasSyncedFolder: Boolean
|
||||||
) {
|
) {
|
||||||
|
val canRefresh = isSyncEnabled || hasSyncedFolder
|
||||||
|
|
||||||
|
val content = @Composable {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
RecentFilesGrid(
|
RecentFilesGrid(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
recentFiles = recentFiles,
|
recentFiles = recentFiles,
|
||||||
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
|
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
|
||||||
onItemClick = onItemClick,
|
onItemClick = onItemClick,
|
||||||
onItemLongClick = onItemLongClick,
|
onItemLongClick = onItemLongClick,
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
|
||||||
downloadingBookIds = downloadingBookIds
|
downloadingBookIds = downloadingBookIds
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(vertical = 24.dp), contentAlignment = Alignment.Center
|
.padding(bottom = 24.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
SelectFileButton(onClick = onSelectFileClick, text = "Select Another File")
|
androidx.compose.material3.Button(onClick = onSelectFileClick) {
|
||||||
|
Text("Select File")
|
||||||
}
|
}
|
||||||
|
androidx.compose.material3.OutlinedButton(onClick = onNavigateToFolderSync) {
|
||||||
|
Text("Sync Folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canRefresh) {
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,6 +558,26 @@ fun RecentFileCard(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (item.sourceFolderUri != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.9f),
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Folder,
|
||||||
|
contentDescription = "Local Folder",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!item.isAvailable) {
|
if (!item.isAvailable) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -576,7 +645,8 @@ fun DefaultTopAppBar(
|
||||||
onClearCloudData: () -> Unit,
|
onClearCloudData: () -> Unit,
|
||||||
onDrawerClick: () -> Unit,
|
onDrawerClick: () -> Unit,
|
||||||
onAboutClick: () -> Unit,
|
onAboutClick: () -> Unit,
|
||||||
onShowDeviceManagement: () -> Unit
|
onShowDeviceManagement: () -> Unit,
|
||||||
|
onFolderSyncToggle: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -636,7 +706,8 @@ private fun AppDrawerContent(
|
||||||
onUpgradeClick: () -> Unit,
|
onUpgradeClick: () -> Unit,
|
||||||
onSyncUpsellClick: () -> Unit,
|
onSyncUpsellClick: () -> Unit,
|
||||||
onFontsClick: () -> Unit,
|
onFontsClick: () -> Unit,
|
||||||
navController: NavHostController
|
navController: NavHostController,
|
||||||
|
onFolderSyncToggle: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
val isOss = BuildConfig.FLAVOR == "oss"
|
val isOss = BuildConfig.FLAVOR == "oss"
|
||||||
|
|
||||||
|
|
@ -753,6 +824,35 @@ private fun AppDrawerContent(
|
||||||
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (uiState.currentUser != null && uiState.isSyncEnabled) {
|
||||||
|
NavigationDrawerItem(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FolderSpecial,
|
||||||
|
contentDescription = "Backup Local Folders"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Column {
|
||||||
|
Text("Cloud sync for Local Folders")
|
||||||
|
Text(
|
||||||
|
"Upload books from your synced folders to Google Drive).",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
badge = {
|
||||||
|
Switch(
|
||||||
|
checked = uiState.isFolderSyncEnabled,
|
||||||
|
onCheckedChange = { onFolderSyncToggle(it) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selected = false,
|
||||||
|
onClick = { onFolderSyncToggle(!uiState.isFolderSyncEnabled) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// OSS Header
|
// OSS Header
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -1036,3 +1136,30 @@ fun FpsMonitor(modifier: Modifier = Modifier) {
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderMigrationDialog(onConfirm: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { }, // Force acknowledgment
|
||||||
|
icon = { Icon(Icons.Default.FolderSpecial, contentDescription = null) },
|
||||||
|
title = { Text("Folder Sync Refactored") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"We've completely rebuilt how Folder Sync works! Books from the folder are now read directly from your folder instead of being copied to app storage."
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"We'll now perform a one-time scan to migrate your existing progress and bookmarks. This will also free up internal storage space on your device.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text("Start Migration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,13 +17,17 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
|
// LibraryScreen.kt
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -49,14 +53,20 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.FolderSpecial
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -67,6 +77,7 @@ import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -79,6 +90,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -88,6 +100,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
|
@ -96,20 +110,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.FolderSpecial
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
private fun getBookCountString(count: Int): String {
|
private fun getBookCountString(count: Int): String {
|
||||||
return if (count == 1) "1 book" else "$count books"
|
return if (count == 1) "1 book" else "$count books"
|
||||||
|
|
@ -130,6 +133,17 @@ fun LibraryScreen(
|
||||||
initialPage = uiState.libraryScreenStartPage,
|
initialPage = uiState.libraryScreenStartPage,
|
||||||
pageCount = { 3 }
|
pageCount = { 3 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val containsFolderItems = remember(selectedItems) {
|
||||||
|
selectedItems.any { it.sourceFolderUri != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.libraryScreenStartPage) {
|
||||||
|
if (pagerState.currentPage != uiState.libraryScreenStartPage) {
|
||||||
|
pagerState.animateScrollToPage(uiState.libraryScreenStartPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var isSearchActive by remember { mutableStateOf(false) }
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
|
@ -160,8 +174,11 @@ fun LibraryScreen(
|
||||||
pickFileLauncher.launch(arrayOf("*/*"))
|
pickFileLauncher.launch(arrayOf("*/*"))
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState) {
|
||||||
viewModel.setLibraryScreenPage(pagerState.currentPage)
|
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
|
||||||
|
.collect { page ->
|
||||||
|
viewModel.setLibraryScreenPage(page)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -217,6 +234,7 @@ fun LibraryScreen(
|
||||||
onNewShelfClick = viewModel::showCreateShelfDialog,
|
onNewShelfClick = viewModel::showCreateShelfDialog,
|
||||||
onSelectFileClick = onSelectFileClick,
|
onSelectFileClick = onSelectFileClick,
|
||||||
onScanNowClick = viewModel::scanSyncedFolder,
|
onScanNowClick = viewModel::scanSyncedFolder,
|
||||||
|
onSyncMetadataClick = viewModel::syncFolderMetadata,
|
||||||
onSelectSyncFolderClick = onSelectSyncFolderClick,
|
onSelectSyncFolderClick = onSelectSyncFolderClick,
|
||||||
onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder,
|
onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder,
|
||||||
downloadingBookIds = uiState.downloadingBookIds,
|
downloadingBookIds = uiState.downloadingBookIds,
|
||||||
|
|
@ -241,9 +259,11 @@ fun LibraryScreen(
|
||||||
showDeleteConfirmDialog = false
|
showDeleteConfirmDialog = false
|
||||||
},
|
},
|
||||||
onDismiss = { showDeleteConfirmDialog = false },
|
onDismiss = { showDeleteConfirmDialog = false },
|
||||||
isPermanentDelete = true
|
isPermanentDelete = true,
|
||||||
|
containsFolderItems = containsFolderItems
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDeleteShelvesDialog) {
|
if (showDeleteShelvesDialog) {
|
||||||
DeleteShelvesConfirmationDialog(
|
DeleteShelvesConfirmationDialog(
|
||||||
count = selectedShelves.size,
|
count = selectedShelves.size,
|
||||||
|
|
@ -409,6 +429,7 @@ fun LibraryScreenContent(
|
||||||
onNewShelfClick: () -> Unit,
|
onNewShelfClick: () -> Unit,
|
||||||
onSelectFileClick: () -> Unit,
|
onSelectFileClick: () -> Unit,
|
||||||
onScanNowClick: () -> Unit,
|
onScanNowClick: () -> Unit,
|
||||||
|
onSyncMetadataClick: () -> Unit,
|
||||||
onSelectSyncFolderClick: () -> Unit,
|
onSelectSyncFolderClick: () -> Unit,
|
||||||
onDisconnectSyncFolderClick: () -> Unit,
|
onDisconnectSyncFolderClick: () -> Unit,
|
||||||
downloadingBookIds: Set<String>,
|
downloadingBookIds: Set<String>,
|
||||||
|
|
@ -621,6 +642,7 @@ fun LibraryScreenContent(
|
||||||
lastScanTime = lastFolderScanTime,
|
lastScanTime = lastFolderScanTime,
|
||||||
onSelectFolderClick = onSelectSyncFolderClick,
|
onSelectFolderClick = onSelectSyncFolderClick,
|
||||||
onScanNowClick = onScanNowClick,
|
onScanNowClick = onScanNowClick,
|
||||||
|
onSyncMetadataClick = onSyncMetadataClick,
|
||||||
onChangeFolderClick = onSelectSyncFolderClick,
|
onChangeFolderClick = onSelectSyncFolderClick,
|
||||||
onDisconnectClick = onDisconnectSyncFolderClick,
|
onDisconnectClick = onDisconnectSyncFolderClick,
|
||||||
isLoading = isLoading
|
isLoading = isLoading
|
||||||
|
|
@ -1147,6 +1169,16 @@ private fun LibraryListItem(
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (item.sourceFolderUri != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Folder,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = item.title ?: item.displayName,
|
text = item.title ?: item.displayName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
|
@ -1326,6 +1358,7 @@ private fun FolderSyncScreen(
|
||||||
lastScanTime: Long?,
|
lastScanTime: Long?,
|
||||||
onSelectFolderClick: () -> Unit,
|
onSelectFolderClick: () -> Unit,
|
||||||
onScanNowClick: () -> Unit,
|
onScanNowClick: () -> Unit,
|
||||||
|
onSyncMetadataClick: () -> Unit,
|
||||||
onChangeFolderClick: () -> Unit,
|
onChangeFolderClick: () -> Unit,
|
||||||
onDisconnectClick: () -> Unit,
|
onDisconnectClick: () -> Unit,
|
||||||
isLoading: Boolean
|
isLoading: Boolean
|
||||||
|
|
@ -1334,9 +1367,10 @@ private fun FolderSyncScreen(
|
||||||
|
|
||||||
if (syncedFolderUri == null) {
|
if (syncedFolderUri == null) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = "Import files from a Folder",
|
title = "Sync Local Folder",
|
||||||
message = "Select a folder on your device. Episteme will automatically find and import any new books you add to it.",
|
message = "Connect a folder to create a live library. Episteme will automatically monitor your files for new additions and keep your reading progress and other book metadata in sync with your folder.",
|
||||||
onSelectFileClick = onSelectFolderClick,
|
onSelectFileClick = onSelectFolderClick,
|
||||||
|
primaryButtonText = "Select Folder",
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1344,86 +1378,224 @@ private fun FolderSyncScreen(
|
||||||
getDisplayPathFromUri(context, syncedFolderUri)
|
getDisplayPathFromUri(context, syncedFolderUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate times
|
||||||
|
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
|
||||||
|
|
||||||
val lastScanText = remember(lastScanTime) {
|
val lastScanText = remember(lastScanTime) {
|
||||||
if (lastScanTime == null || lastScanTime == 0L) {
|
if (lastScanTime == null || lastScanTime == 0L) "Never"
|
||||||
"Never scanned"
|
else dateFormat.format(Date(lastScanTime))
|
||||||
} else {
|
}
|
||||||
"Last scan: ${
|
|
||||||
SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()).format(
|
val nextScanText = remember(lastScanTime) {
|
||||||
Date(lastScanTime)
|
if (lastScanTime == null || lastScanTime == 0L) "Pending first scan..."
|
||||||
)
|
else {
|
||||||
}"
|
// Adding 4 hours (4 * 60 * 60 * 1000) to match the Worker interval
|
||||||
|
val nextTime = lastScanTime + (4 * 60 * 60 * 1000)
|
||||||
|
dateFormat.format(Date(nextTime))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 32.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
// 1. Status Dashboard Card
|
||||||
imageVector = Icons.Default.FolderSpecial, // Using a standard icon
|
androidx.compose.material3.ElevatedCard(
|
||||||
contentDescription = "Synced Folder",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.size(80.dp),
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
tint = MaterialTheme.colorScheme.primary
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
) {
|
||||||
Text(
|
Column(
|
||||||
text = "Folder Sync is Active",
|
modifier = Modifier.padding(20.dp),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
) {
|
||||||
textAlign = TextAlign.Center,
|
// Header Row with Status
|
||||||
modifier = Modifier.fillMaxWidth() // Add this modifier
|
Row(
|
||||||
)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
Text(
|
verticalAlignment = Alignment.CenterVertically
|
||||||
text = "Monitoring folder:",
|
) {
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = folderPath,
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
if (isLoading) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
Icon(
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
imageVector = Icons.Default.FolderSpecial,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Scanning...",
|
text = "Active Sync",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status Indicator
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
"Scanning...",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(
|
||||||
|
Color(0xFF4CAF50), // Green for active
|
||||||
|
androidx.compose.foundation.shape.CircleShape
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = lastScanText,
|
"Monitoring",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.labelSmall
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth() // Add this modifier
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
Button(onClick = onScanNowClick, enabled = !isLoading) {
|
|
||||||
Text("Scan for New Books")
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
}
|
||||||
Button(onClick = onChangeFolderClick, enabled = !isLoading) {
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||||
|
|
||||||
|
// Folder Path
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "LOCATION",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = folderPath,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Times Grid
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "LAST CHECK",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "NEXT AUTO SYNC",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(text = nextScanText, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Main Actions
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Actions",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.FilledTonalButton(
|
||||||
|
onClick = onScanNowClick,
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Scan Files")
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.material3.OutlinedButton(
|
||||||
|
onClick = onSyncMetadataClick,
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.sync),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Sync Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onChangeFolderClick, enabled = !isLoading) {
|
||||||
Text("Change Folder")
|
Text("Change Folder")
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
TextButton(onClick = onDisconnectClick, enabled = !isLoading) {
|
TextButton(
|
||||||
Text("Disconnect Folder")
|
onClick = onDisconnectClick,
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,11 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
|
// MainScreen.kt
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
|
@ -31,13 +34,15 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) {
|
sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) {
|
||||||
object Home : BottomBarScreen("home", "Home", R.drawable.home)
|
object Home : BottomBarScreen("home", "Home", R.drawable.home)
|
||||||
|
|
@ -55,6 +60,13 @@ fun MainScreen(
|
||||||
windowSizeClass: WindowSizeClass,
|
windowSizeClass: WindowSizeClass,
|
||||||
navController: NavHostController
|
navController: NavHostController
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SideEffect {
|
||||||
|
val activity = context as? ComponentActivity
|
||||||
|
activity?.enableEdgeToEdge()
|
||||||
|
}
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val viewingShelfName = uiState.viewingShelfName
|
val viewingShelfName = uiState.viewingShelfName
|
||||||
|
|
||||||
|
|
@ -67,6 +79,12 @@ fun MainScreen(
|
||||||
)
|
)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.mainScreenStartPage) {
|
||||||
|
if (pagerState.currentPage != uiState.mainScreenStartPage) {
|
||||||
|
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
viewModel.setMainScreenPage(pagerState.currentPage)
|
viewModel.setMainScreenPage(pagerState.currentPage)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
|
// MainViewModel.kt
|
||||||
@file:Suppress("DEPRECATION")
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
@ -39,7 +40,10 @@ import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.aryan.reader.data.CloudflareRepository
|
import com.aryan.reader.data.CloudflareRepository
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
|
|
@ -99,6 +103,8 @@ import java.util.concurrent.CancellationException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
private const val KEY_RENDER_MODE = "render_mode"
|
private const val KEY_RENDER_MODE = "render_mode"
|
||||||
|
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
||||||
|
private const val KEY_FOLDER_MIGRATION_COMPLETED = "folder_migration_completed_v2"
|
||||||
|
|
||||||
data class BannerMessage(val message: String, val isError: Boolean = false)
|
data class BannerMessage(val message: String, val isError: Boolean = false)
|
||||||
|
|
||||||
|
|
@ -176,17 +182,19 @@ data class ReaderScreenState(
|
||||||
val isAuthMenuExpanded: Boolean = false,
|
val isAuthMenuExpanded: Boolean = false,
|
||||||
val isProUser: Boolean = false,
|
val isProUser: Boolean = false,
|
||||||
val isSyncEnabled: Boolean = false,
|
val isSyncEnabled: Boolean = false,
|
||||||
|
val isFolderSyncEnabled: Boolean = false,
|
||||||
val bannerMessage: BannerMessage? = null,
|
val bannerMessage: BannerMessage? = null,
|
||||||
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
|
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
|
||||||
val isReplacingDevice: Boolean = false,
|
val isReplacingDevice: Boolean = false,
|
||||||
val isRequestingDrivePermission: Boolean = false,
|
val isRequestingDrivePermission: Boolean = false,
|
||||||
val downloadingBookIds: Set<String> = emptySet(),
|
val downloadingBookIds: Set<String> = emptySet(),
|
||||||
val uploadingBookIds: Set<String> = emptySet(),
|
val uploadingBookIds: Set<String> = emptySet(),
|
||||||
val pendingSyncUpdate: SyncUpdateInfo? = null,
|
|
||||||
val syncedFolderUri: String? = null,
|
val syncedFolderUri: String? = null,
|
||||||
val lastFolderScanTime: Long? = null,
|
val lastFolderScanTime: Long? = null,
|
||||||
val hasUnreadFeedback: Boolean = false,
|
val hasUnreadFeedback: Boolean = false,
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
|
val showFolderMigrationDialog: Boolean = false,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
@ -263,6 +271,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
},
|
},
|
||||||
currentUser = authRepository.getSignedInUser(),
|
currentUser = authRepository.getSignedInUser(),
|
||||||
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
|
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
|
||||||
|
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false),
|
||||||
syncedFolderUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null),
|
syncedFolderUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null),
|
||||||
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
|
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
|
||||||
KEY_LAST_FOLDER_SCAN_TIME,
|
KEY_LAST_FOLDER_SCAN_TIME,
|
||||||
|
|
@ -520,6 +529,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
remoteConfigRepository.init()
|
remoteConfigRepository.init()
|
||||||
|
|
||||||
|
if (_internalState.value.syncedFolderUri != null) {
|
||||||
|
Timber.d("App Start: Triggering local folder metadata-only sync.")
|
||||||
|
syncFolderMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch { billingClientWrapper.initializeConnection() }
|
viewModelScope.launch { billingClientWrapper.initializeConnection() }
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -569,6 +583,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val folderUri = _internalState.value.syncedFolderUri
|
||||||
|
val migrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
|
||||||
|
|
||||||
|
if (folderUri != null && !migrationCompleted) {
|
||||||
|
Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.")
|
||||||
|
_internalState.update { it.copy(showFolderMigrationDialog = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun completeFolderMigration() {
|
||||||
|
Timber.tag("FolderSync").d("User accepted migration. Marking completed and starting scan.")
|
||||||
|
prefs.edit { putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) }
|
||||||
|
_internalState.update { it.copy(showFolderMigrationDialog = false) }
|
||||||
|
|
||||||
|
scanSyncedFolder()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val fontsRepository = FontsRepository(appContext)
|
private val fontsRepository = FontsRepository(appContext)
|
||||||
|
|
@ -1003,8 +1032,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun uploadSingleBookMetadata(book: RecentFileItem) {
|
private fun uploadSingleBookMetadata(book: RecentFileItem) {
|
||||||
if (!uiState.value.isSyncEnabled) {
|
if (!uiState.value.isSyncEnabled) return
|
||||||
Timber.tag("AnnotationSync").d("Sync disabled. Skipping upload.")
|
|
||||||
|
if (book.sourceFolderUri != null) {
|
||||||
|
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val currentUser = uiState.value.currentUser ?: return
|
val currentUser = uiState.value.currentUser ?: return
|
||||||
|
|
@ -1184,102 +1215,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
initialLocator = null,
|
initialLocator = null,
|
||||||
initialPageInBook = null,
|
initialPageInBook = null
|
||||||
pendingSyncUpdate = null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
bookToSync?.let {
|
bookToSync?.let {
|
||||||
if (uiState.value.uploadingBookIds.contains(it.bookId)) {
|
if (uiState.value.uploadingBookIds.contains(it.bookId)) {
|
||||||
Timber.d(
|
|
||||||
"Book closed, but initial upload is still in progress. Metadata will be synced upon upload completion."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (uiState.value.isSyncEnabled) {
|
||||||
Timber.d("Book closed, triggering metadata sync for ${it.bookId}")
|
Timber.d("Book closed, triggering metadata sync for ${it.bookId}")
|
||||||
uploadSingleBookMetadata(it)
|
uploadSingleBookMetadata(it)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncSingleBookMetadataOnOpen(bookId: String) {
|
|
||||||
if (!uiState.value.isSyncEnabled) return
|
|
||||||
val currentUser = uiState.value.currentUser ?: return
|
|
||||||
|
|
||||||
|
if (it.sourceFolderUri != null) {
|
||||||
|
Timber.d("Book closed (Folder Linked), syncing metadata to folder: ${it.bookId}")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
recentFilesRepository.syncLocalMetadataToFolder(it.bookId)
|
||||||
val remoteBookMetadata =
|
|
||||||
firestoreRepository.getBookMetadata(currentUser.uid, bookId) ?: return@launch
|
|
||||||
val localBook = recentFilesRepository.getFileByBookId(bookId)
|
|
||||||
|
|
||||||
val showUpdatePrompt = if (localBook == null) {
|
|
||||||
true
|
|
||||||
} else if (remoteBookMetadata.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
|
|
||||||
val remoteLocator =
|
|
||||||
if (remoteBookMetadata.lastChapterIndex != null && remoteBookMetadata.locatorBlockIndex != null && remoteBookMetadata.locatorCharOffset != null) {
|
|
||||||
Locator(
|
|
||||||
remoteBookMetadata.lastChapterIndex,
|
|
||||||
remoteBookMetadata.locatorBlockIndex,
|
|
||||||
remoteBookMetadata.locatorCharOffset
|
|
||||||
)
|
|
||||||
} else null
|
|
||||||
|
|
||||||
val localLocator =
|
|
||||||
if (localBook.lastChapterIndex != null && localBook.locatorBlockIndex != null && localBook.locatorCharOffset != null) {
|
|
||||||
Locator(
|
|
||||||
localBook.lastChapterIndex,
|
|
||||||
localBook.locatorBlockIndex,
|
|
||||||
localBook.locatorCharOffset
|
|
||||||
)
|
|
||||||
} else null
|
|
||||||
|
|
||||||
val positionChanged = when (localBook.type) {
|
|
||||||
FileType.PDF -> remoteBookMetadata.lastPage != localBook.lastPage
|
|
||||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> remoteLocator != localLocator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val bookmarksChanged =
|
|
||||||
remoteBookMetadata.bookmarksJson != localBook.bookmarksJson
|
|
||||||
|
|
||||||
positionChanged || bookmarksChanged
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showUpdatePrompt) {
|
|
||||||
Timber.d("Remote metadata is newer for $bookId. Proposing update to user.")
|
|
||||||
|
|
||||||
recentFilesRepository.addRecentFile(remoteBookMetadata.toRecentFileItem())
|
|
||||||
|
|
||||||
val locator =
|
|
||||||
if (remoteBookMetadata.lastChapterIndex != null && remoteBookMetadata.locatorBlockIndex != null && remoteBookMetadata.locatorCharOffset != null) {
|
|
||||||
Locator(
|
|
||||||
remoteBookMetadata.lastChapterIndex,
|
|
||||||
remoteBookMetadata.locatorBlockIndex,
|
|
||||||
remoteBookMetadata.locatorCharOffset
|
|
||||||
)
|
|
||||||
} else null
|
|
||||||
|
|
||||||
_internalState.update {
|
|
||||||
it.copy(
|
|
||||||
pendingSyncUpdate = SyncUpdateInfo(
|
|
||||||
bookId = bookId,
|
|
||||||
locator = locator,
|
|
||||||
page = remoteBookMetadata.lastPage,
|
|
||||||
cfi = remoteBookMetadata.lastPositionCfi,
|
|
||||||
bookmarksJson = remoteBookMetadata.bookmarksJson
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Timber.d(
|
|
||||||
"Local metadata is up-to-date for $bookId or remote data is identical. No prompt."
|
|
||||||
)
|
|
||||||
if (localBook != null && remoteBookMetadata.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
|
|
||||||
recentFilesRepository.addRecentFile(remoteBookMetadata.toRecentFileItem())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to sync single book metadata on open for bookId: $bookId")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1312,12 +1265,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
|
folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
)
|
)
|
||||||
Timber.d("Persistable URI permission taken for folder: $folderUri")
|
Timber.d("Persistable URI permission taken for folder: $folderUri")
|
||||||
prefs.edit { putString(KEY_SYNCED_FOLDER_URI, folderUri.toString()) }
|
prefs.edit {
|
||||||
_internalState.update { it.copy(syncedFolderUri = folderUri.toString()) }
|
putString(KEY_SYNCED_FOLDER_URI, folderUri.toString())
|
||||||
// Trigger an initial scan
|
putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
_internalState.update { it.copy(
|
||||||
|
syncedFolderUri = folderUri.toString(),
|
||||||
|
showFolderMigrationDialog = false
|
||||||
|
) }
|
||||||
|
|
||||||
scanSyncedFolder()
|
scanSyncedFolder()
|
||||||
|
|
||||||
// Schedule periodic sync
|
|
||||||
val workManager = WorkManager.getInstance(appContext)
|
val workManager = WorkManager.getInstance(appContext)
|
||||||
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
||||||
|
|
||||||
|
|
@ -1339,20 +1298,77 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun syncFolderMetadata() {
|
||||||
|
triggerFolderSyncWorker(metadataOnly = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scanSyncedFolder() {
|
||||||
|
triggerFolderSyncWorker(metadataOnly = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerFolderSyncWorker(metadataOnly: Boolean) {
|
||||||
|
@Suppress("UnusedVariable", "Unused") val folderUriString = _internalState.value.syncedFolderUri ?: return
|
||||||
|
Timber.tag("FolderSync").d("Requesting folder sync (metadataOnly=$metadataOnly)")
|
||||||
|
|
||||||
|
val workManager = WorkManager.getInstance(appContext)
|
||||||
|
val data = androidx.work.Data.Builder()
|
||||||
|
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>()
|
||||||
|
.setInputData(data)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniqueWork(
|
||||||
|
FolderSyncWorker.WORK_NAME_ONETIME,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
workManager.getWorkInfoByIdFlow(request.id).collect { workInfo ->
|
||||||
|
if (workInfo != null) {
|
||||||
|
when (workInfo.state) {
|
||||||
|
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
|
||||||
|
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Folder Sync: Scanning files..."
|
||||||
|
_internalState.update { it.copy(isLoading = true, bannerMessage = BannerMessage(msg)) }
|
||||||
|
}
|
||||||
|
WorkInfo.State.SUCCEEDED -> {
|
||||||
|
_internalState.update { it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
bannerMessage = BannerMessage("Folder Sync: Scan complete."),
|
||||||
|
lastFolderScanTime = System.currentTimeMillis()
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
|
||||||
|
_internalState.update { it.copy(isLoading = false, isRefreshing = false, errorMessage = "Sync failed.") } // ADD isRefreshing = false
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun disconnectSyncedFolder() {
|
fun disconnectSyncedFolder() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val folderUriString = _internalState.value.syncedFolderUri
|
val folderUriString = _internalState.value.syncedFolderUri
|
||||||
if (folderUriString != null) {
|
if (folderUriString != null) {
|
||||||
|
Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.")
|
||||||
|
recentFilesRepository.deleteFilesBySourceFolder(folderUriString) // New DAO method call
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = folderUriString.toUri()
|
val uri = folderUriString.toUri()
|
||||||
val contentResolver = appContext.contentResolver
|
val contentResolver = appContext.contentResolver
|
||||||
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
contentResolver.releasePersistableUriPermission(uri, takeFlags)
|
contentResolver.releasePersistableUriPermission(uri, takeFlags)
|
||||||
Timber.d("Released persistable URI permission for folder: $uri")
|
Timber.tag("FolderSync").d("Released permission for: $uri")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to release persistable URI permission for $folderUriString")
|
Timber.e(e, "Failed to release permission")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
remove(KEY_SYNCED_FOLDER_URI)
|
remove(KEY_SYNCED_FOLDER_URI)
|
||||||
remove(KEY_LAST_FOLDER_SCAN_TIME)
|
remove(KEY_LAST_FOLDER_SCAN_TIME)
|
||||||
|
|
@ -1360,90 +1376,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
|
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
|
||||||
|
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||||
Timber.d("Cancelled folder sync worker.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scanSyncedFolder() {
|
|
||||||
val folderUriString = _internalState.value.syncedFolderUri ?: return
|
|
||||||
val folderUri = folderUriString.toUri()
|
|
||||||
|
|
||||||
_internalState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = true, bannerMessage = BannerMessage("Scanning folder for new books...")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
|
||||||
if (documentTree == null || !documentTree.isDirectory) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_internalState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
errorMessage = "Could not read the synced folder. Please select it again."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val filesToImport = mutableListOf<DocumentFile>()
|
|
||||||
val fileQueue = ArrayDeque<DocumentFile>()
|
|
||||||
documentTree.listFiles().let { fileQueue.addAll(it) }
|
|
||||||
|
|
||||||
while (fileQueue.isNotEmpty()) {
|
|
||||||
val file = fileQueue.removeAt(0)
|
|
||||||
if (file.isDirectory) {
|
|
||||||
file.listFiles().let { fileQueue.addAll(it) }
|
|
||||||
} else if (file.isFile) {
|
|
||||||
val fileName = file.name ?: ""
|
|
||||||
if (fileName.endsWith(".pdf", true) ||
|
|
||||||
fileName.endsWith(".epub", true) ||
|
|
||||||
fileName.endsWith(".mobi", true) ||
|
|
||||||
fileName.endsWith(".azw3", true) ||
|
|
||||||
fileName.endsWith(".md", true)
|
|
||||||
) {
|
|
||||||
filesToImport.add(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var importedCount = 0
|
|
||||||
for (file in filesToImport) {
|
|
||||||
val importResult = prepareBookForImport(file.uri)
|
|
||||||
if (importResult != null) {
|
|
||||||
val (internalUri, bookId, type) = importResult
|
|
||||||
val displayName = getFileNameFromUri(file.uri, appContext) ?: "Unknown File"
|
|
||||||
addFileToRecent(
|
|
||||||
internalUri,
|
|
||||||
type,
|
|
||||||
bookId,
|
|
||||||
customDisplayName = displayName,
|
|
||||||
isRecent = false,
|
|
||||||
sourceFolderUri = folderUriString
|
|
||||||
)
|
|
||||||
importedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val scanTime = System.currentTimeMillis()
|
|
||||||
prefs.edit { putLong(KEY_LAST_FOLDER_SCAN_TIME, scanTime) }
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val message = if (importedCount > 0) {
|
|
||||||
"Successfully imported $importedCount new book(s) from your folder."
|
|
||||||
} else {
|
|
||||||
"No new books found to import."
|
|
||||||
}
|
|
||||||
_internalState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
bannerMessage = BannerMessage(message),
|
|
||||||
lastFolderScanTime = scanTime
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1543,10 +1475,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearPendingSyncUpdate() {
|
|
||||||
_internalState.update { it.copy(pendingSyncUpdate = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteAllCloudAndLocalData() {
|
fun deleteAllCloudAndLocalData() {
|
||||||
if (!uiState.value.isSyncEnabled) {
|
if (!uiState.value.isSyncEnabled) {
|
||||||
_internalState.update { it.copy(errorMessage = "Enable sync to clear cloud data.") }
|
_internalState.update { it.copy(errorMessage = "Enable sync to clear cloud data.") }
|
||||||
|
|
@ -1772,6 +1700,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFolderSyncEnabled(enabled: Boolean) {
|
||||||
|
prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) }
|
||||||
|
_internalState.update { it.copy(isFolderSyncEnabled = enabled) }
|
||||||
|
|
||||||
|
if (enabled && uiState.value.isSyncEnabled) {
|
||||||
|
viewModelScope.launch { syncWithCloud(showBanner = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun syncWithCloud(showBanner: Boolean = false) = viewModelScope.launch {
|
private fun syncWithCloud(showBanner: Boolean = false) = viewModelScope.launch {
|
||||||
val hasPermissions = googleDriveRepository.hasDrivePermissions(appContext)
|
val hasPermissions = googleDriveRepository.hasDrivePermissions(appContext)
|
||||||
val currentUser = _internalState.value.currentUser
|
val currentUser = _internalState.value.currentUser
|
||||||
|
|
@ -1785,7 +1722,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
if (showBanner) {
|
if (showBanner) {
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(bannerMessage = BannerMessage("Syncing library and fonts..."))
|
it.copy(bannerMessage = BannerMessage("Cloud Sync: Checking for updates..."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1800,7 +1737,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
firestoreRepository.getAllShelves(currentUser.uid)
|
firestoreRepository.getAllShelves(currentUser.uid)
|
||||||
}
|
}
|
||||||
val localBooks = withContext(Dispatchers.IO) {
|
val localBooks = withContext(Dispatchers.IO) {
|
||||||
recentFilesRepository.getAllFilesForSync()
|
val allFiles = recentFilesRepository.getAllFilesForSync()
|
||||||
|
if (_internalState.value.isFolderSyncEnabled) {
|
||||||
|
allFiles
|
||||||
|
} else {
|
||||||
|
allFiles.filter { it.sourceFolderUri == null }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
|
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
|
||||||
|
|
@ -1988,7 +1930,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (showBanner) {
|
if (showBanner) {
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false, bannerMessage = BannerMessage("Sync complete.")
|
isLoading = false, bannerMessage = BannerMessage("Cloud Sync: Complete.")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2087,7 +2029,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addFileToRecent(
|
private suspend fun addFileToRecent(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
bookId: String,
|
bookId: String,
|
||||||
|
|
@ -2095,8 +2037,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
customDisplayName: String? = null,
|
customDisplayName: String? = null,
|
||||||
isRecent: Boolean,
|
isRecent: Boolean,
|
||||||
sourceFolderUri: String? = null
|
sourceFolderUri: String? = null
|
||||||
) {
|
) = withContext(Dispatchers.IO) {
|
||||||
viewModelScope.launch {
|
|
||||||
val isNewBook = withContext(Dispatchers.IO) {
|
val isNewBook = withContext(Dispatchers.IO) {
|
||||||
recentFilesRepository.getFileByBookId(bookId) == null
|
recentFilesRepository.getFileByBookId(bookId) == null
|
||||||
}
|
}
|
||||||
|
|
@ -2199,7 +2140,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
uploadNewBookAndMetadata(newItem)
|
uploadNewBookAndMetadata(newItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun setSortOrder(sortOrder: SortOrder) {
|
fun setSortOrder(sortOrder: SortOrder) {
|
||||||
_internalState.update { it.copy(sortOrder = sortOrder) }
|
_internalState.update { it.copy(sortOrder = sortOrder) }
|
||||||
|
|
@ -2555,9 +2495,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
) {
|
) {
|
||||||
Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator")
|
Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
recentFilesRepository.getFileByUri(uri.toString())?.let {
|
recentFilesRepository.getFileByUri(uri.toString())?.let { _ ->
|
||||||
recentFilesRepository.updateEpubReadingPosition(
|
recentFilesRepository.updateEpubReadingPosition(
|
||||||
uri.toString(), locator, cfiForWebView, progress
|
uriString = uri.toString(),
|
||||||
|
locator = locator,
|
||||||
|
cfiForWebView = cfiForWebView,
|
||||||
|
progress = progress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2600,15 +2543,51 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
Timber.d("Saving PDF position locally: URI=$currentPdfUri, Page=$page")
|
Timber.d("Saving PDF position locally: URI=$currentPdfUri, Page=$page")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let {
|
recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ ->
|
||||||
recentFilesRepository.updatePdfReadingPosition(
|
recentFilesRepository.updatePdfReadingPosition(
|
||||||
currentPdfUri.toString(), page, progress
|
uriString = currentPdfUri.toString(),
|
||||||
|
page = page,
|
||||||
|
progress = progress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshLibrary() {
|
||||||
|
val syncEnabled = _internalState.value.isSyncEnabled
|
||||||
|
val hasFolder = _internalState.value.syncedFolderUri != null // Check for URI instead of toggle
|
||||||
|
|
||||||
|
if (!syncEnabled && !hasFolder) {
|
||||||
|
Timber.d("Refresh skipped: No sync methods active.")
|
||||||
|
_internalState.update { it.copy(isRefreshing = false) } // Ensure indicator retracts immediately
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_internalState.update { it.copy(isRefreshing = true) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (syncEnabled) {
|
||||||
|
syncWithCloud(showBanner = false).join()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFolder) {
|
||||||
|
// This triggers the worker which we observe above to clear isRefreshing
|
||||||
|
syncFolderMetadata()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Refresh failed")
|
||||||
|
_internalState.update { it.copy(isRefreshing = false) }
|
||||||
|
} finally {
|
||||||
|
// If folder sync isn't running, we must close the indicator here
|
||||||
|
if (!hasFolder) {
|
||||||
|
_internalState.update { it.copy(isRefreshing = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clearBookCache() {
|
fun clearBookCache() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
bookCacheDao.clearAllCache()
|
bookCacheDao.clearAllCache()
|
||||||
|
|
@ -2630,23 +2609,43 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
_internalState.update { it.copy(contextualActionItems = newSelection) }
|
_internalState.update { it.copy(contextualActionItems = newSelection) }
|
||||||
Timber.d("New selection size: ${newSelection.size}")
|
Timber.d("New selection size: ${newSelection.size}")
|
||||||
} else {
|
} else {
|
||||||
|
if (item.sourceFolderUri != null && item.uriString != null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val exists = try {
|
||||||
|
val uri = item.uriString.toUri()
|
||||||
|
DocumentFile.fromSingleUri(appContext, uri)?.exists() == true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
Timber.tag("FolderSync").i("LazyCleanup: File ${item.displayName} missing. Removing.")
|
||||||
|
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
|
||||||
|
showBanner("File deleted from folder. Removed from library.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
Timber.d("Recent file clicked (opening): ${item.displayName}")
|
Timber.d("Recent file clicked (opening): ${item.displayName}")
|
||||||
if (item.isAvailable) {
|
if (item.isAvailable) {
|
||||||
item.getUri()?.let { uri ->
|
item.getUri()?.let { uri ->
|
||||||
openBook(uri, item.bookId, item.type, item.displayName)
|
openBook(uri, item.bookId, item.type, item.displayName)
|
||||||
} ?: run {
|
} ?: run {
|
||||||
_internalState.update {
|
_internalState.update { it.copy(errorMessage = "Could not find file location.") }
|
||||||
it.copy(
|
}
|
||||||
errorMessage = "Could not find file location for ${item.displayName}."
|
} else {
|
||||||
)
|
downloadBook(item, openWhenComplete = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
syncSingleBookMetadataOnOpen(item.bookId)
|
|
||||||
|
Timber.d("Recent file clicked (opening): ${item.displayName}")
|
||||||
|
if (item.isAvailable) {
|
||||||
|
item.getUri()?.let { uri ->
|
||||||
|
openBook(uri, item.bookId, item.type, item.displayName)
|
||||||
|
} ?: run {
|
||||||
|
_internalState.update { it.copy(errorMessage = "Could not find file location.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w(
|
|
||||||
"Clicked on a book that is not available locally: ${item.displayName}, starting download."
|
|
||||||
)
|
|
||||||
downloadBook(item, openWhenComplete = true)
|
downloadBook(item, openWhenComplete = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3035,11 +3034,62 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
fun deleteContextualItemsPermanently() {
|
fun deleteContextualItemsPermanently() {
|
||||||
val itemsToRemove = _internalState.value.contextualActionItems
|
val itemsToRemove = _internalState.value.contextualActionItems
|
||||||
if (itemsToRemove.isNotEmpty()) {
|
if (itemsToRemove.isNotEmpty()) {
|
||||||
val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext)
|
|
||||||
|
|
||||||
_internalState.update { it.copy(contextualActionItems = emptySet()) }
|
_internalState.update { it.copy(contextualActionItems = emptySet()) }
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext)
|
||||||
|
|
||||||
|
val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null }
|
||||||
|
|
||||||
|
if (folderBooks.isNotEmpty()) {
|
||||||
|
Timber.d("Processing ${folderBooks.size} folder books for deletion.")
|
||||||
|
|
||||||
|
val idsToDeleteLocally = mutableListOf<String>()
|
||||||
|
|
||||||
|
folderBooks.forEach { item ->
|
||||||
|
idsToDeleteLocally.add(item.bookId)
|
||||||
|
pdfTextRepository.clearBookText(item.bookId)
|
||||||
|
|
||||||
|
if (item.uriString != null) {
|
||||||
|
try {
|
||||||
|
val fileUri = item.uriString.toUri()
|
||||||
|
val fileDoc = DocumentFile.fromSingleUri(appContext, fileUri)
|
||||||
|
if (fileDoc != null && fileDoc.exists()) {
|
||||||
|
if (fileDoc.delete()) {
|
||||||
|
Timber.i("Physically deleted folder file: ${item.displayName}")
|
||||||
|
} else {
|
||||||
|
Timber.e("Failed to delete folder file via SAF: ${item.displayName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Error deleting physical file for ${item.bookId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try to delete the metadata JSON (.bookId.json)
|
||||||
|
if (item.sourceFolderUri != null) {
|
||||||
|
try {
|
||||||
|
val rootUri = item.sourceFolderUri.toUri()
|
||||||
|
val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri)
|
||||||
|
val syncDir = rootDoc?.findFile("episteme") ?: rootDoc?.findFile(".episteme")
|
||||||
|
|
||||||
|
if (syncDir != null) {
|
||||||
|
// Try hidden first, then legacy
|
||||||
|
val metaFile = syncDir.findFile(".${item.bookId}.json")
|
||||||
|
?: syncDir.findFile("${item.bookId}.json")
|
||||||
|
|
||||||
|
metaFile?.delete()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Error deleting metadata file for ${item.bookId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recentFilesRepository.deleteFilePermanently(idsToDeleteLocally)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managedBooks.isNotEmpty()) {
|
||||||
val currentUser = uiState.value.currentUser
|
val currentUser = uiState.value.currentUser
|
||||||
|
|
||||||
if (canSync && currentUser != null) {
|
if (canSync && currentUser != null) {
|
||||||
|
|
@ -3050,21 +3100,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val accessToken = googleDriveRepository.getAccessToken(appContext) ?: throw Exception("No token")
|
val accessToken = googleDriveRepository.getAccessToken(appContext)
|
||||||
|
?: throw Exception("No token")
|
||||||
val deviceId = getInstallationId()
|
val deviceId = getInstallationId()
|
||||||
|
|
||||||
val remoteFiles = withContext(Dispatchers.IO) {
|
val remoteFiles = withContext(Dispatchers.IO) {
|
||||||
googleDriveRepository.getFiles(accessToken)?.files.orEmpty()
|
googleDriveRepository.getFiles(accessToken)?.files.orEmpty()
|
||||||
.associateBy { it.name }
|
.associateBy { it.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
for (item in itemsToRemove) {
|
for (item in managedBooks) {
|
||||||
recentFilesRepository.markAsDeleted(listOf(item.bookId))
|
recentFilesRepository.markAsDeleted(listOf(item.bookId))
|
||||||
pdfTextRepository.clearBookText(item.bookId)
|
pdfTextRepository.clearBookText(item.bookId)
|
||||||
val deletedItem =
|
|
||||||
recentFilesRepository.getFileByBookId(item.bookId) ?: continue
|
|
||||||
|
|
||||||
firestoreRepository.syncBookMetadata(
|
firestoreRepository.syncBookMetadata(
|
||||||
currentUser.uid, deletedItem.toBookMetadata(), deviceId
|
currentUser.uid, item.toBookMetadata().copy(isDeleted = true), deviceId
|
||||||
)
|
)
|
||||||
|
|
||||||
val fileExtension = item.type.name.lowercase()
|
val fileExtension = item.type.name.lowercase()
|
||||||
|
|
@ -3076,37 +3126,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
|
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
|
||||||
}
|
}
|
||||||
|
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(
|
it.copy(isLoading = false, bannerMessage = BannerMessage("Deletion complete."))
|
||||||
isLoading = false,
|
|
||||||
bannerMessage = BannerMessage("Deletion complete.")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error during permanent deletion")
|
Timber.e(e, "Error during permanent deletion")
|
||||||
|
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
|
||||||
recentFilesRepository.deleteFilePermanently(itemsToRemove.map { it.bookId })
|
managedBooks.forEach { item ->
|
||||||
itemsToRemove.forEach { item ->
|
|
||||||
pdfTextRepository.clearBookText(item.bookId)
|
pdfTextRepository.clearBookText(item.bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(
|
it.copy(isLoading = false, errorMessage = "Cloud sync failed, deleted locally.")
|
||||||
isLoading = false,
|
|
||||||
errorMessage = "Cloud sync failed, deleted locally."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
recentFilesRepository.deleteFilePermanently(itemsToRemove.map { it.bookId })
|
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
|
||||||
itemsToRemove.forEach { item -> pdfTextRepository.clearBookText(item.bookId) }
|
managedBooks.forEach { item -> pdfTextRepository.clearBookText(item.bookId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val totalRemoved = folderBooks.size + managedBooks.size
|
||||||
|
_internalState.update { it.copy(isLoading = false, bannerMessage = BannerMessage("$totalRemoved books removed from library.")) }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Attempted to remove contextual items, but none were selected.")
|
Timber.w("Attempted to remove contextual items, but none were selected.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun navigateToFolderSync() {
|
||||||
|
// 1. Switch MainScreen to Library Tab (Index 1)
|
||||||
|
setMainScreenPage(1)
|
||||||
|
// 2. Switch LibraryScreen to Folder Tab (Index 2)
|
||||||
|
setLibraryScreenPage(2)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
|
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
|
||||||
|
|
|
||||||
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.foundation.clickable
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -261,20 +262,43 @@ fun CustomTopAppBar(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteConfirmationDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit, isPermanentDelete: Boolean = false) {
|
fun DeleteConfirmationDialog(
|
||||||
|
count: Int,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isPermanentDelete: Boolean = false,
|
||||||
|
containsFolderItems: Boolean = false // New parameter
|
||||||
|
) {
|
||||||
val title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents"
|
val title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents"
|
||||||
|
|
||||||
val text = if (isPermanentDelete) {
|
val text = if (isPermanentDelete) {
|
||||||
|
if (containsFolderItems) {
|
||||||
|
"Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone."
|
||||||
|
} else {
|
||||||
"Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone."
|
"Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone."
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
"Do you want to remove $count selected file(s) from the recent files list? It will reappear if you open it again from the library."
|
"Do you want to remove $count selected file(s) from the recent files list? It will reappear if you open it again from the library."
|
||||||
}
|
}
|
||||||
|
|
||||||
val confirmText = if (isPermanentDelete) "Delete" else "Remove"
|
val confirmText = if (isPermanentDelete) "Delete" else "Remove"
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(title) },
|
title = { Text(title) },
|
||||||
text = { Text(text) },
|
text = {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (containsFolderItems && isPermanentDelete) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = onConfirm) { Text(confirmText) }
|
TextButton(
|
||||||
|
onClick = onConfirm,
|
||||||
|
colors = if (containsFolderItems && isPermanentDelete) ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) else ButtonDefaults.textButtonColors()
|
||||||
|
) {
|
||||||
|
Text(confirmText)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
|
@ -430,7 +454,10 @@ fun EmptyState(
|
||||||
title: String,
|
title: String,
|
||||||
message: String,
|
message: String,
|
||||||
onSelectFileClick: () -> Unit,
|
onSelectFileClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
primaryButtonText: String = "Select a File",
|
||||||
|
secondaryButtonText: String? = null,
|
||||||
|
onSecondaryClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -449,7 +476,8 @@ fun EmptyState(
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -459,7 +487,15 @@ fun EmptyState(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
SelectFileButton(onClick = onSelectFileClick, text = "Select a File")
|
|
||||||
|
SelectFileButton(onClick = onSelectFileClick, text = primaryButtonText)
|
||||||
|
|
||||||
|
if (secondaryButtonText != null && onSecondaryClick != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
OutlinedButton(onClick = onSecondaryClick) {
|
||||||
|
Text(secondaryButtonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
|
// RecentFileDao.kt
|
||||||
package com.aryan.reader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
|
@ -33,6 +34,9 @@ interface RecentFileDao {
|
||||||
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||||
fun getRecentFiles(): Flow<List<RecentFileEntity>>
|
fun getRecentFiles(): Flow<List<RecentFileEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
||||||
|
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM recent_files")
|
@Query("SELECT * FROM recent_files")
|
||||||
suspend fun getAllFiles(): List<RecentFileEntity>
|
suspend fun getAllFiles(): List<RecentFileEntity>
|
||||||
|
|
||||||
|
|
@ -57,6 +61,12 @@ interface RecentFileDao {
|
||||||
@Query("SELECT * FROM recent_files WHERE uriString = :uriString")
|
@Query("SELECT * FROM recent_files WHERE uriString = :uriString")
|
||||||
suspend fun getFileByUri(uriString: String): RecentFileEntity?
|
suspend fun getFileByUri(uriString: String): RecentFileEntity?
|
||||||
|
|
||||||
|
@Query("DELETE FROM recent_files WHERE sourceFolderUri = :sourceFolderUri")
|
||||||
|
suspend fun deleteFilesBySourceFolder(sourceFolderUri: String)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recent_files WHERE bookId LIKE :prefix || '%'")
|
||||||
|
suspend fun getFilesWithIdPrefix(prefix: String): List<RecentFileEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM recent_files")
|
@Query("DELETE FROM recent_files")
|
||||||
suspend fun clearAll()
|
suspend fun clearAll()
|
||||||
|
|
||||||
|
|
@ -74,4 +84,7 @@ interface RecentFileDao {
|
||||||
|
|
||||||
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
|
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
|
||||||
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
|
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0")
|
||||||
|
suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity>
|
||||||
}
|
}
|
||||||
|
|
@ -17,11 +17,13 @@
|
||||||
*
|
*
|
||||||
* mail: epistemereader@gmail.com
|
* mail: epistemereader@gmail.com
|
||||||
*/
|
*/
|
||||||
|
// RecentFilesRepository.kt
|
||||||
package com.aryan.reader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.aryan.reader.BookImporter
|
import com.aryan.reader.BookImporter
|
||||||
import com.aryan.reader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
|
|
@ -34,7 +36,7 @@ import java.io.FileOutputStream
|
||||||
|
|
||||||
private const val COVER_CACHE_DIR = "cover_cache"
|
private const val COVER_CACHE_DIR = "cover_cache"
|
||||||
|
|
||||||
class RecentFilesRepository(context: Context) {
|
class RecentFilesRepository(private val context: Context) {
|
||||||
|
|
||||||
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
|
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
|
||||||
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
|
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
|
||||||
|
|
@ -60,6 +62,10 @@ class RecentFilesRepository(context: Context) {
|
||||||
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem()
|
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext recentFileDao.getFilesBySourceFolder(sourceFolderUri).map { it.toRecentFileItem() }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getAllFilesForSync(): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
suspend fun getAllFilesForSync(): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
||||||
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() }
|
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() }
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +115,42 @@ class RecentFilesRepository(context: Context) {
|
||||||
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
|
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun syncLocalMetadataToFolder(bookId: String) = withContext(Dispatchers.IO) {
|
||||||
|
val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext
|
||||||
|
val folderUriString = entity.sourceFolderUri
|
||||||
|
|
||||||
|
if (folderUriString != null) {
|
||||||
|
Timber.d("Syncing metadata to local folder for book: $bookId")
|
||||||
|
|
||||||
|
val metadata = FolderBookMetadata(
|
||||||
|
bookId = entity.bookId,
|
||||||
|
title = entity.title,
|
||||||
|
author = entity.author,
|
||||||
|
displayName = entity.displayName,
|
||||||
|
type = entity.type.name,
|
||||||
|
lastChapterIndex = entity.lastChapterIndex,
|
||||||
|
lastPage = entity.lastPage,
|
||||||
|
lastPositionCfi = entity.lastPositionCfi,
|
||||||
|
progressPercentage = entity.progressPercentage ?: 0f,
|
||||||
|
isRecent = entity.isRecent,
|
||||||
|
lastModifiedTimestamp = entity.lastModifiedTimestamp,
|
||||||
|
bookmarksJson = entity.bookmarks,
|
||||||
|
locatorBlockIndex = entity.locatorBlockIndex,
|
||||||
|
locatorCharOffset = entity.locatorCharOffset
|
||||||
|
)
|
||||||
|
|
||||||
|
LocalSyncUtils.saveMetadataToFolder(
|
||||||
|
context = context, // Now correctly references the property
|
||||||
|
sourceFolderUri = folderUriString.toUri(),
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
|
||||||
|
recentFileDao.deleteFilesBySourceFolder(folderUriString)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
|
suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
|
||||||
val item = recentFileDao.getFileByUri(uriString)
|
val item = recentFileDao.getFileByUri(uriString)
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
|
|
@ -126,6 +168,10 @@ class RecentFilesRepository(context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getFolderBooksWithoutCovers(): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
|
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
|
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
|
||||||
|
|
@ -171,7 +217,11 @@ class RecentFilesRepository(context: Context) {
|
||||||
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
|
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
|
||||||
itemsToRemove.forEach { item ->
|
itemsToRemove.forEach { item ->
|
||||||
item.coverImagePath?.let { deleteCachedCover(it) }
|
item.coverImagePath?.let { deleteCachedCover(it) }
|
||||||
|
try {
|
||||||
item.uriString?.let { bookImporter.deleteBookByUriString(it) }
|
item.uriString?.let { bookImporter.deleteBookByUriString(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
|
recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
|
||||||
Timber.d("Permanently removed recent files from DB.")
|
Timber.d("Permanently removed recent files from DB.")
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -174,11 +175,11 @@ class ContentBridge(
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
class CfiJsBridge(
|
class CfiJsBridge(
|
||||||
private val onCfiReady: (String) -> Unit,
|
private val onCfiReady: (String) -> Unit,
|
||||||
private val onCfiForBookmarkReady: (String) -> Unit
|
private val onCfiForBookmarkReady: (String) -> Unit,
|
||||||
|
private val onScrollFinishedCallback: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun onCfiExtracted(jsonResponse: String) {
|
fun onCfiExtracted(jsonResponse: String) {
|
||||||
// This is called from JavaScript with the generated CFI and diagnostics
|
|
||||||
try {
|
try {
|
||||||
val json = JSONObject(jsonResponse)
|
val json = JSONObject(jsonResponse)
|
||||||
val cfi = json.optString("cfi", "/4")
|
val cfi = json.optString("cfi", "/4")
|
||||||
|
|
@ -200,13 +201,12 @@ class CfiJsBridge(
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error parsing CFI JSON response: $jsonResponse")
|
Timber.e(e, "Error parsing CFI JSON response: $jsonResponse")
|
||||||
// Still call back with a fallback CFI so the app doesn't hang
|
|
||||||
onCfiReady("/4")
|
onCfiReady("/4")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun onCfiForBookmarkExtracted(jsonResponse: String) {
|
fun onCfiForBookmarkExtracted(jsonResponse: String) {
|
||||||
// This is called from JavaScript with the generated CFI for a bookmark action
|
|
||||||
try {
|
try {
|
||||||
val json = JSONObject(jsonResponse)
|
val json = JSONObject(jsonResponse)
|
||||||
val cfi = json.optString("cfi")
|
val cfi = json.optString("cfi")
|
||||||
|
|
@ -230,6 +230,12 @@ class CfiJsBridge(
|
||||||
Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse")
|
Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onScrollFinished(success: Boolean) {
|
||||||
|
Timber.tag("BookmarkDiagnosis").d("JS reported scroll finished. Success: $success")
|
||||||
|
onScrollFinishedCallback(success)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
|
|
@ -303,6 +309,7 @@ fun ChapterWebView(
|
||||||
currentFontSize: Float,
|
currentFontSize: Float,
|
||||||
currentLineHeight: Float,
|
currentLineHeight: Float,
|
||||||
onChapterInitiallyScrolled: () -> Unit,
|
onChapterInitiallyScrolled: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onTap: () -> Unit,
|
onTap: () -> Unit,
|
||||||
onPotentialScroll: () -> Unit,
|
onPotentialScroll: () -> Unit,
|
||||||
onOverScrollTop: (dragAmount: Float) -> Unit,
|
onOverScrollTop: (dragAmount: Float) -> Unit,
|
||||||
|
|
@ -314,9 +321,9 @@ fun ChapterWebView(
|
||||||
onCfiGenerated: (cfi: String) -> Unit,
|
onCfiGenerated: (cfi: String) -> Unit,
|
||||||
onBookmarkCfiGenerated: (cfi: String) -> Unit,
|
onBookmarkCfiGenerated: (cfi: String) -> Unit,
|
||||||
onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit,
|
onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit,
|
||||||
|
onScrollFinished: (Boolean) -> Unit = {},
|
||||||
ttsScope: CoroutineScope,
|
ttsScope: CoroutineScope,
|
||||||
tocFragments: List<String>,
|
tocFragments: List<String>,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
initialFragmentId: String? = null,
|
initialFragmentId: String? = null,
|
||||||
onTtsTextReady: suspend (String) -> Unit,
|
onTtsTextReady: suspend (String) -> Unit,
|
||||||
isProUser: Boolean,
|
isProUser: Boolean,
|
||||||
|
|
@ -347,6 +354,11 @@ fun ChapterWebView(
|
||||||
|
|
||||||
var showPaletteManager by remember { mutableStateOf(false) }
|
var showPaletteManager by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
|
||||||
|
val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated)
|
||||||
|
val currentOnBookmarkCfiGenerated by rememberUpdatedState(onBookmarkCfiGenerated)
|
||||||
|
val currentOnScrollFinished by rememberUpdatedState(onScrollFinished)
|
||||||
|
|
||||||
LaunchedEffect(currentFontSize, currentLineHeight) {
|
LaunchedEffect(currentFontSize, currentLineHeight) {
|
||||||
localWebViewRef?.evaluateJavascript(
|
localWebViewRef?.evaluateJavascript(
|
||||||
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
|
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
|
||||||
|
|
@ -499,6 +511,10 @@ fun ChapterWebView(
|
||||||
consoleMessage?.let {
|
consoleMessage?.let {
|
||||||
val message = it.message()
|
val message = it.message()
|
||||||
when {
|
when {
|
||||||
|
message.startsWith("BookmarkDiagnosis") -> {
|
||||||
|
Timber.tag("BookmarkDiagnosis").d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}")
|
||||||
|
}
|
||||||
|
|
||||||
message.startsWith("CFI_DIAGNOSIS:") -> {
|
message.startsWith("CFI_DIAGNOSIS:") -> {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}"
|
"JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}"
|
||||||
|
|
@ -549,15 +565,17 @@ fun ChapterWebView(
|
||||||
}
|
}
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
CfiJsBridge(
|
CfiJsBridge(
|
||||||
onCfiReady = { cfi -> onCfiGenerated(cfi) },
|
onCfiReady = { cfi -> currentOnCfiGenerated(cfi) },
|
||||||
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
|
onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) },
|
||||||
), "CfiBridge")
|
onScrollFinishedCallback = { success -> currentOnScrollFinished(success) }
|
||||||
addJavascriptInterface(SnippetJsBridge { cfi, snippet ->
|
), "CfiBridge"
|
||||||
onSnippetForBookmarkReady(
|
)
|
||||||
cfi,
|
|
||||||
snippet
|
addJavascriptInterface(
|
||||||
|
SnippetJsBridge { cfi, snippet ->
|
||||||
|
currentOnSnippetForBookmarkReady(cfi, snippet)
|
||||||
|
}, "SnippetBridge"
|
||||||
)
|
)
|
||||||
}, "SnippetBridge")
|
|
||||||
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")
|
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
AiJsBridge(ttsScope, onContentReadyForSummarization),
|
AiJsBridge(ttsScope, onContentReadyForSummarization),
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import android.graphics.Bitmap
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import timber.log.Timber
|
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
|
@ -77,10 +76,8 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
|
@ -103,6 +100,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.BiasAlignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -117,6 +115,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
|
@ -131,7 +130,6 @@ import com.aryan.reader.RenderMode
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
import com.aryan.reader.SummarizationResult
|
import com.aryan.reader.SummarizationResult
|
||||||
import com.aryan.reader.SummaryCacheManager
|
import com.aryan.reader.SummaryCacheManager
|
||||||
import com.aryan.reader.SyncUpdateInfo
|
|
||||||
import com.aryan.reader.countWords
|
import com.aryan.reader.countWords
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
|
|
@ -165,14 +163,13 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import androidx.compose.ui.BiasAlignment
|
|
||||||
import androidx.core.content.edit
|
|
||||||
|
|
||||||
private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked"
|
private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked"
|
||||||
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
|
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
|
||||||
|
|
@ -206,8 +203,6 @@ fun EpubReaderScreen(
|
||||||
initialCfi: String?,
|
initialCfi: String?,
|
||||||
initialBookmarksJson: String?,
|
initialBookmarksJson: String?,
|
||||||
isProUser: Boolean,
|
isProUser: Boolean,
|
||||||
pendingSyncUpdate: SyncUpdateInfo?,
|
|
||||||
onClearPendingSyncUpdate: () -> Unit,
|
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
|
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
|
||||||
onBookmarksChanged: (bookmarksJson: String) -> Unit,
|
onBookmarksChanged: (bookmarksJson: String) -> Unit,
|
||||||
|
|
@ -230,8 +225,6 @@ fun EpubReaderScreen(
|
||||||
onNavigateToPro = onNavigateToPro,
|
onNavigateToPro = onNavigateToPro,
|
||||||
coverImagePath = coverImagePath,
|
coverImagePath = coverImagePath,
|
||||||
onRenderModeChange = onRenderModeChange,
|
onRenderModeChange = onRenderModeChange,
|
||||||
pendingSyncUpdate = pendingSyncUpdate,
|
|
||||||
onClearPendingSyncUpdate = onClearPendingSyncUpdate,
|
|
||||||
customFonts = customFonts,
|
customFonts = customFonts,
|
||||||
onImportFont = onImportFont
|
onImportFont = onImportFont
|
||||||
)
|
)
|
||||||
|
|
@ -249,8 +242,6 @@ fun EpubReaderHost(
|
||||||
initialCfi: String?,
|
initialCfi: String?,
|
||||||
initialBookmarksJson: String?,
|
initialBookmarksJson: String?,
|
||||||
isProUser: Boolean,
|
isProUser: Boolean,
|
||||||
pendingSyncUpdate: SyncUpdateInfo?,
|
|
||||||
onClearPendingSyncUpdate: () -> Unit,
|
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
|
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
|
||||||
onBookmarksChanged: (bookmarksJson: String) -> Unit,
|
onBookmarksChanged: (bookmarksJson: String) -> Unit,
|
||||||
|
|
@ -269,6 +260,7 @@ fun EpubReaderHost(
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
val containerFocusRequester = remember { FocusRequester() }
|
val containerFocusRequester = remember { FocusRequester() }
|
||||||
|
var isNavigatingToBookmark by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var isPageSliderVisible by remember { mutableStateOf(false) }
|
var isPageSliderVisible by remember { mutableStateOf(false) }
|
||||||
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
|
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
@ -559,62 +551,6 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(pendingSyncUpdate) {
|
|
||||||
if (pendingSyncUpdate != null) {
|
|
||||||
val locator = pendingSyncUpdate.locator
|
|
||||||
val message = if (locator != null) {
|
|
||||||
val chapterTitle = chapters.getOrNull(locator.chapterIndex)?.title ?: "another location"
|
|
||||||
"Newer reading position found in '$chapterTitle'. Sync now?"
|
|
||||||
} else {
|
|
||||||
"Bookmarks updated on another device. Sync now?"
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = withTimeoutOrNull(10_000L) {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
message = message,
|
|
||||||
actionLabel = "Sync",
|
|
||||||
withDismissAction = true,
|
|
||||||
duration = SnackbarDuration.Indefinite
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
if (locator != null) {
|
|
||||||
when (currentRenderMode) {
|
|
||||||
RenderMode.VERTICAL_SCROLL -> {
|
|
||||||
val cfi = locatorConverter.getCfiFromLocator(epubBook.title, locator)
|
|
||||||
if (cfi != null) {
|
|
||||||
val targetChunk = locator.blockIndex / 20
|
|
||||||
if (currentChapterIndex != locator.chapterIndex) {
|
|
||||||
chunkTargetOverride = targetChunk
|
|
||||||
currentChapterIndex = locator.chapterIndex
|
|
||||||
} else {
|
|
||||||
if (targetChunk >= loadedChunkCount) {
|
|
||||||
loadUpToChunkIndex = targetChunk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfiToLoad = cfi
|
|
||||||
} else {
|
|
||||||
Timber.w("Could not get CFI from locator for sync.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RenderMode.PAGINATED -> {
|
|
||||||
(paginator as? BookPaginator)?.findPageForLocator(locator)?.let { page ->
|
|
||||||
scope.launch {
|
|
||||||
paginatedPagerState.scrollToPage(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingSyncUpdate.bookmarksJson?.let { newBookmarksJson ->
|
|
||||||
bookmarks = loadBookmarks(context, epubBook.title, chapters, newBookmarksJson)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClearPendingSyncUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(skipChapterRequest) {
|
LaunchedEffect(skipChapterRequest) {
|
||||||
if (skipChapterRequest) {
|
if (skipChapterRequest) {
|
||||||
skipChapterRequest = false
|
skipChapterRequest = false
|
||||||
|
|
@ -1261,32 +1197,95 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
when (currentRenderMode) {
|
when (currentRenderMode) {
|
||||||
RenderMode.VERTICAL_SCROLL -> {
|
RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
|
||||||
cfiToLoad = bookmark.cfi
|
cfiToLoad = bookmark.cfi
|
||||||
val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
|
|
||||||
val targetChunk = locator?.let { it.blockIndex / 20 }
|
// FIX: Try to extract chunk index directly from CFI for Vertical Mode
|
||||||
|
// Vertical Mode CFIs are relative to content-container, so the first number
|
||||||
|
// usually represents the chunk (2->Chunk0, 4->Chunk1, 6->Chunk2...)
|
||||||
|
val directChunkIndex = try {
|
||||||
|
val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() }
|
||||||
|
if (parts.isNotEmpty()) {
|
||||||
|
val firstIndex = parts[0]
|
||||||
|
// Standard EPUB CFI: indices are 1-based steps (2, 4, 6...)
|
||||||
|
(firstIndex - 2) / 2
|
||||||
|
} else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val locator = if (directChunkIndex == null) {
|
||||||
|
locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetChunk = directChunkIndex ?: locator?.let { it.blockIndex / 20 }
|
||||||
|
|
||||||
if (bookmark.chapterIndex != currentChapterIndex) {
|
if (bookmark.chapterIndex != currentChapterIndex) {
|
||||||
if (targetChunk != null) {
|
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) {
|
||||||
chunkTargetOverride = targetChunk
|
targetChunk
|
||||||
} else {
|
} else {
|
||||||
chunkTargetOverride = 0
|
0
|
||||||
Timber.w("Could not get locator for bookmark CFI, will navigate to start of chapter.")
|
|
||||||
}
|
}
|
||||||
currentChapterIndex = bookmark.chapterIndex
|
currentChapterIndex = bookmark.chapterIndex
|
||||||
} else {
|
}
|
||||||
if (targetChunk != null) {
|
else {
|
||||||
|
if (targetChunk != null && targetChunk >= 0) {
|
||||||
|
isNavigatingToBookmark = true
|
||||||
|
|
||||||
|
// FIX: Ensure we don't reload if we already have it,
|
||||||
|
// but do ensure the WebView has the content injected.
|
||||||
if (targetChunk >= loadedChunkCount) {
|
if (targetChunk >= loadedChunkCount) {
|
||||||
|
Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk")
|
||||||
|
|
||||||
|
val chunksToInject = (loadedChunkCount..targetChunk)
|
||||||
|
chunksToInject.forEach { idx ->
|
||||||
|
val content = chapterChunks.getOrNull(idx)
|
||||||
|
if (content != null) {
|
||||||
|
val escaped = escapeJsString(content)
|
||||||
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"javascript:window.virtualization.appendChunk($idx, '$escaped');",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
loadUpToChunkIndex = targetChunk
|
loadUpToChunkIndex = targetChunk
|
||||||
|
loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
|
||||||
} else {
|
} else {
|
||||||
webViewRefForTts?.evaluateJavascript("javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", null)
|
// Even if loadedChunkCount is high enough in Kotlin state,
|
||||||
}
|
// ensure the specific chunk for the bookmark is actually in the DOM.
|
||||||
} else {
|
// (Sometimes rapid jumps might leave gaps if logic was loose)
|
||||||
Timber.w("Could not get locator for bookmark CFI in current chapter, loading all chunks as fallback.")
|
val content = chapterChunks.getOrNull(targetChunk)
|
||||||
loadUpToChunkIndex = if (chapterChunks.isNotEmpty()) chapterChunks.size - 1 else 0
|
if (content != null) {
|
||||||
}
|
val escaped = escapeJsString(content)
|
||||||
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"javascript:window.virtualization.appendChunk($targetChunk, '$escaped');",
|
||||||
|
null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
delay(3000)
|
||||||
|
if (isNavigatingToBookmark) {
|
||||||
|
isNavigatingToBookmark = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback if we couldn't determine chunk
|
||||||
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
RenderMode.PAGINATED -> {
|
RenderMode.PAGINATED -> {
|
||||||
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
|
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
|
||||||
val locator = locatorConverter.getLocatorFromCfi(
|
val locator = locatorConverter.getLocatorFromCfi(
|
||||||
|
|
@ -1588,7 +1587,7 @@ fun EpubReaderHost(
|
||||||
"ControlFlowWithEmptyBody"
|
"ControlFlowWithEmptyBody"
|
||||||
)
|
)
|
||||||
ChapterWebView(
|
ChapterWebView(
|
||||||
key = "$chapterKeyForWebView-$loadUpToChunkIndex",
|
key = "$chapterKeyForWebView",
|
||||||
chapterTitle = chapterToRender.title,
|
chapterTitle = chapterToRender.title,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
initialScrollTarget = initialScrollTargetForChapter,
|
initialScrollTarget = initialScrollTargetForChapter,
|
||||||
|
|
@ -1795,6 +1794,10 @@ fun EpubReaderHost(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onScrollFinished = { success ->
|
||||||
|
Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success")
|
||||||
|
isNavigatingToBookmark = false
|
||||||
|
},
|
||||||
ttsScope = scope,
|
ttsScope = scope,
|
||||||
onTtsTextReady = { jsonString ->
|
onTtsTextReady = { jsonString ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -2959,6 +2962,26 @@ fun EpubReaderHost(
|
||||||
isTtsSessionActive = isTtsSessionActive
|
isTtsSessionActive = isTtsSessionActive
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isNavigatingToBookmark) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f))
|
||||||
|
.clickable(enabled = true) {},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Navigating to bookmark...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showPermissionRationaleDialog) {
|
if (showPermissionRationaleDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showPermissionRationaleDialog = false },
|
onDismissRequest = { showPermissionRationaleDialog = false },
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ private fun Color.luminance(): Float {
|
||||||
return (0.299f * red + 0.587f * green + 0.114f * blue)
|
return (0.299f * red + 0.587f * green + 0.114f * blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
||||||
object CssParser {
|
object CssParser {
|
||||||
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
|
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
|
||||||
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
|
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,7 @@
|
||||||
package com.aryan.reader.paginatedreader
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
|
@ -51,7 +49,6 @@ class LocatorConverter(
|
||||||
private val proto: ProtoBuf,
|
private val proto: ProtoBuf,
|
||||||
private val context: Context
|
private val context: Context
|
||||||
) {
|
) {
|
||||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
||||||
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
|
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
|
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
|
||||||
|
|
@ -114,12 +111,7 @@ class LocatorConverter(
|
||||||
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
||||||
} else {
|
} else {
|
||||||
Timber.w("getLocatorFromCfi: Chapter $chapterIndex not in DB. Triggering on-demand processing.")
|
Timber.w("getLocatorFromCfi: Chapter $chapterIndex not in DB. Triggering on-demand processing.")
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
processAndCacheChapter(book, chapterIndex)
|
processAndCacheChapter(book, chapterIndex)
|
||||||
} else {
|
|
||||||
Timber.e("On-demand processing requires API 34+, cannot proceed.")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBlocks == null) {
|
if (allBlocks == null) {
|
||||||
|
|
@ -238,9 +230,7 @@ class LocatorConverter(
|
||||||
val allBlocks = if (processedChapter != null) {
|
val allBlocks = if (processedChapter != null) {
|
||||||
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
|
||||||
} else {
|
} else {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
processAndCacheChapter(book, locator.chapterIndex)
|
processAndCacheChapter(book, locator.chapterIndex)
|
||||||
} else null
|
|
||||||
} ?: return@withContext null
|
} ?: return@withContext null
|
||||||
|
|
||||||
var offset = 0
|
var offset = 0
|
||||||
|
|
|
||||||
|
|
@ -565,8 +565,6 @@ fun PdfViewerScreen(
|
||||||
initialPage: Int?,
|
initialPage: Int?,
|
||||||
initialBookmarksJson: String?,
|
initialBookmarksJson: String?,
|
||||||
isProUser: Boolean,
|
isProUser: Boolean,
|
||||||
pendingSyncUpdate: SyncUpdateInfo?,
|
|
||||||
onClearPendingSyncUpdate: () -> Unit,
|
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onSavePosition: (page: Int, totalPages: Int) -> Unit,
|
onSavePosition: (page: Int, totalPages: Int) -> Unit,
|
||||||
onBookmarksChanged: (bookmarksJson: String) -> Unit,
|
onBookmarksChanged: (bookmarksJson: String) -> Unit,
|
||||||
|
|
@ -1553,39 +1551,6 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
|
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(pendingSyncUpdate) {
|
|
||||||
if (pendingSyncUpdate != null) {
|
|
||||||
val newPage = pendingSyncUpdate.page
|
|
||||||
val message = if (newPage != null) {
|
|
||||||
"Newer reading position found. Sync to page ${newPage + 1}?"
|
|
||||||
} else {
|
|
||||||
"Bookmarks updated on another device. Sync now?"
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = withTimeoutOrNull(10_000L) {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
message = message,
|
|
||||||
actionLabel = "Sync",
|
|
||||||
withDismissAction = true,
|
|
||||||
duration = SnackbarDuration.Indefinite
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
if (newPage != null) {
|
|
||||||
when (displayMode) {
|
|
||||||
DisplayMode.PAGINATION -> pagerState.scrollToPage(newPage)
|
|
||||||
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.scrollToPage(newPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingSyncUpdate.bookmarksJson?.let { newBookmarksJson ->
|
|
||||||
bookmarks = loadPdfBookmarksFromJson(newBookmarksJson)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClearPendingSyncUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isOcrModelDownloading by remember { mutableStateOf(false) }
|
var isOcrModelDownloading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(isOcrModelDownloading) {
|
LaunchedEffect(isOcrModelDownloading) {
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,9 @@ import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
private const val START_TIMEOUT_FAST_MS = 750L
|
private const val START_TIMEOUT_FAST_MS = 3000L
|
||||||
private const val START_TIMEOUT_RETRY_MS = 2500L
|
private const val START_TIMEOUT_RETRY_MS = 4000L
|
||||||
private const val PROCESS_TIMEOUT_MS = 4000L
|
private const val PROCESS_TIMEOUT_MS = 15000L
|
||||||
private const val MAX_RETRY_ATTEMPTS = 3
|
private const val MAX_RETRY_ATTEMPTS = 3
|
||||||
|
|
||||||
class BaseTtsSynthesizer(private val context: Context) {
|
class BaseTtsSynthesizer(private val context: Context) {
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,11 @@ class TtsPlaybackManager(
|
||||||
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
||||||
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
||||||
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
|
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
|
||||||
|
|
||||||
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
|
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
|
||||||
chunks.mapIndexed { index, text ->
|
chunks.mapIndexed { index, text ->
|
||||||
TtsChunk(text, cfis[index], offsets[index])
|
val safeOffset = offsets.getOrNull(index) ?: -1
|
||||||
|
TtsChunk(text, cfis[index], safeOffset)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chunks.map { TtsChunk(it, "", -1) }
|
chunks.map { TtsChunk(it, "", -1) }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue