diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 7313228..1c9fb54 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -55,28 +55,57 @@ class FolderSyncWorker( override suspend fun doWork(): Result { val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false) - // Check if folder is still linked before starting val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) - if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) { - Timber.tag("FolderSync").w("Worker: Folder unlinked. Aborting work.") + + val jsonString = prefs.getString("synced_folders_list_json", null) + val folders = mutableListOf() + + if (jsonString != null) { + try { + val array = org.json.JSONArray(jsonString) + for (i in 0 until array.length()) { + folders.add(array.getJSONObject(i).getString("uri")) + } + } catch (e: Exception) { Timber.e(e) } + } else { + val single = prefs.getString("synced_folder_uri", null) + if (single != null) folders.add(single) + } + + if (folders.isEmpty()) { + Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.") return Result.success() } - Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...") + Timber.tag("FolderSync").d("Worker: processing ${folders.size} folders.") return withContext(Dispatchers.IO) { syncMutex.withLock { - Timber.tag("FolderSync").d("Worker: Lock acquired. Starting Sync.") - performSync(isMetadataOnly) + var allSuccess = true + + for (uriString in folders) { + val success = performSyncForFolder(uriString, isMetadataOnly) + if (!success) allSuccess = false + } + + if (jsonString != null) { + try { + val array = org.json.JSONArray(jsonString) + val now = System.currentTimeMillis() + for (i in 0 until array.length()) { + array.getJSONObject(i).put("lastScanTime", now) + } + prefs.edit { putString("synced_folders_list_json", array.toString()) } + } catch (_: Exception) {} + } + + if (allSuccess) Result.success() else Result.failure() } } } - private suspend fun performSync(metadataOnly: Boolean): Result { - val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) - val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null) - - if (folderUriString.isNullOrBlank()) return Result.success() + private suspend fun performSyncForFolder(folderUriString: String, metadataOnly: Boolean): Boolean { + if (folderUriString.isBlank()) return true val folderUri = folderUriString.toUri() try { @@ -86,17 +115,20 @@ class FolderSyncWorker( android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION ) } catch (_: SecurityException) { - return Result.failure() + return false } val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) if (documentTree == null || !documentTree.isDirectory) { - return Result.failure() + return false } Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) + Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...") + val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree) + folderMetadataMap.forEach { (bookId, remoteMeta) -> val existingItem = recentFilesRepository.getFileByBookId(bookId) @@ -120,19 +152,18 @@ class FolderSyncWorker( } } - // Phase 1.5: Sync Annotations for existing books (Runs in both Metadata-Only and Full modes) Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...") val processedBookIds = mutableSetOf() val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) for (book in existingFolderBooks) { processedBookIds.add(book.bookId) - val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, book.bookId) + + val sidecarData = preloadedSidecars[book.bookId] if (sidecarData != null) { val (remoteTs, jsonPayload) = sidecarData - // Check timestamps of ALL potential local annotation files val localFiles = listOf( File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"), File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"), @@ -141,7 +172,7 @@ class FolderSyncWorker( ) val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L - if (remoteTs > (localTs + 1000)) { // 1s buffer + if (remoteTs > (localTs + 1000)) { Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) } else { @@ -217,7 +248,7 @@ class FolderSyncWorker( } if (!processedBookIds.contains(stableId)) { - val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, stableId) + val sidecarData = preloadedSidecars[stableId] if (sidecarData != null) { val (remoteTs, jsonPayload) = sidecarData @@ -238,7 +269,6 @@ class FolderSyncWorker( } } - // Cleanup removed files if (!isStopped) { val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId } @@ -250,8 +280,6 @@ class FolderSyncWorker( } } - prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) } - if (!isStopped) { Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") val metaRequest = OneTimeWorkRequestBuilder().build() @@ -262,11 +290,11 @@ class FolderSyncWorker( ) } - return Result.success() + return true } catch (e: Exception) { Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.") - return Result.failure() + return false } } diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 37230bd..301f9e6 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -345,16 +345,15 @@ fun HomeScreen( onRefresh = { viewModel.refreshLibrary() }, isRefreshing = uiState.isRefreshing, isSyncEnabled = uiState.isSyncEnabled, - hasSyncedFolder = uiState.syncedFolderUri != null + hasSyncedFolder = uiState.syncedFolders.isNotEmpty() ) } } - // Loading Indicator Overlay if (uiState.isLoading) { Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f) // Semi-transparent overlay + color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f) ) { Box( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 0de526c..de43cc0 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -60,7 +60,6 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -154,7 +153,7 @@ fun LibraryScreen( contract = ActivityResultContracts.OpenDocumentTree() ) { uri -> uri?.let { - viewModel.setSyncedFolder(it) + viewModel.addSyncedFolder(it) } } @@ -264,9 +263,11 @@ fun LibraryScreen( onScanNowClick = viewModel::scanSyncedFolder, onSyncMetadataClick = viewModel::syncFolderMetadata, onSelectSyncFolderClick = onSelectSyncFolderClick, - onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder, + syncedFolders = uiState.syncedFolders, + onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) }, + onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) }, + onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders, downloadingBookIds = uiState.downloadingBookIds, - syncedFolderUri = uiState.syncedFolderUri, lastFolderScanTime = uiState.lastFolderScanTime, isLoading = uiState.isLoading ) @@ -461,9 +462,11 @@ fun LibraryScreenContent( onSelectSyncFolderClick: () -> Unit, onDisconnectSyncFolderClick: () -> Unit, downloadingBookIds: Set, - syncedFolderUri: String?, lastFolderScanTime: Long?, isLoading: Boolean, + syncedFolders: List, + onAddFolderClick: (android.net.Uri) -> Unit, + onRemoveFolderClick: (SyncedFolder) -> Unit, ) { val isBookContextualModeActive = selectedItems.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty() @@ -666,13 +669,11 @@ fun LibraryScreenContent( } 2 -> { FolderSyncScreen( - syncedFolderUri = syncedFolderUri, - lastScanTime = lastFolderScanTime, - onSelectFolderClick = onSelectSyncFolderClick, + syncedFolders = syncedFolders, + onAddFolderClick = onAddFolderClick, + onRemoveFolderClick = onRemoveFolderClick, onScanNowClick = onScanNowClick, onSyncMetadataClick = onSyncMetadataClick, - onChangeFolderClick = onSelectSyncFolderClick, - onDisconnectClick = onDisconnectSyncFolderClick, isLoading = isLoading ) } @@ -1382,182 +1383,43 @@ private fun getDisplayPathFromUri(context: Context, uriString: String): String { @Composable private fun FolderSyncScreen( - syncedFolderUri: String?, - lastScanTime: Long?, - onSelectFolderClick: () -> Unit, + syncedFolders: List, + onAddFolderClick: (android.net.Uri) -> Unit, + onRemoveFolderClick: (SyncedFolder) -> Unit, onScanNowClick: () -> Unit, onSyncMetadataClick: () -> Unit, - onChangeFolderClick: () -> Unit, - onDisconnectClick: () -> Unit, isLoading: Boolean ) { - val context = LocalContext.current - - if (syncedFolderUri == null) { - EmptyState( - title = "Sync Local Folder", - message = "Connect a folder to create a live library. Episteme will automatically monitor your files for new additions and keep your reading progress and other book metadata in sync with your folder.", - onSelectFileClick = onSelectFolderClick, - primaryButtonText = "Select Folder", - modifier = Modifier.fillMaxSize() - ) - } else { - val folderPath = remember(syncedFolderUri) { - getDisplayPathFromUri(context, syncedFolderUri) + val pickFolderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri -> + uri?.let { + onAddFolderClick(it) } + } - // Calculate times - val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) } + LocalContext.current - val lastScanText = remember(lastScanTime) { - if (lastScanTime == null || lastScanTime == 0L) "Never" - else dateFormat.format(Date(lastScanTime)) - } - - val nextScanText = remember(lastScanTime) { - if (lastScanTime == null || lastScanTime == 0L) "Pending first scan..." - else { - // Adding 4 hours (4 * 60 * 60 * 1000) to match the Worker interval - val nextTime = lastScanTime + (4 * 60 * 60 * 1000) - dateFormat.format(Date(nextTime)) + Scaffold( + floatingActionButton = { + if (syncedFolders.size < 3) { + ExtendedFloatingActionButton( + text = { Text("Add Folder") }, + icon = { Icon(Icons.Default.Add, "Add") }, + onClick = { pickFolderLauncher.launch(null) } + ) } } - + ) { padding -> Column( modifier = Modifier .fillMaxSize() + .padding(padding) .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // 1. Status Dashboard Card - androidx.compose.material3.ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = androidx.compose.material3.CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh - ) - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Header Row with Status - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.FolderSpecial, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "Active Sync", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - - // Status Indicator - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = androidx.compose.foundation.shape.CircleShape - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(12.dp), - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - "Scanning...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - } else { - Box( - modifier = Modifier - .size(8.dp) - .background( - Color(0xFF4CAF50), // Green for active - androidx.compose.foundation.shape.CircleShape - ) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - "Monitoring", - style = MaterialTheme.typography.labelSmall - ) - } - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - - // Folder Path - Column { - Text( - text = "LOCATION", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = folderPath, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - // Times Grid - Row( - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "LAST CHECK", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = lastScanText, style = MaterialTheme.typography.bodySmall) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = "NEXT AUTO SYNC", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = nextScanText, style = MaterialTheme.typography.bodySmall) - } - } - } - } - - // 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) - ) - + // Global Actions Header + if (syncedFolders.isNotEmpty()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) @@ -1568,13 +1430,13 @@ private fun FolderSyncScreen( modifier = Modifier.weight(1f), shape = MaterialTheme.shapes.small ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp)) + } Spacer(modifier = Modifier.width(8.dp)) - Text("Scan Files") + Text(if (isLoading) "Scanning..." else "Scan All") } androidx.compose.material3.OutlinedButton( @@ -1583,45 +1445,121 @@ private fun FolderSyncScreen( modifier = Modifier.weight(1f), shape = MaterialTheme.shapes.small ) { - Icon( - painter = painterResource(id = R.drawable.sync), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) - Text("Sync Data") + Text("Sync Meta") + } + } + } else { + EmptyState( + title = "Sync Local Folders", + message = "Connect local folders to create a live library. Episteme will monitor files and sync progress.", + onSelectFileClick = { pickFolderLauncher.launch(null) }, + primaryButtonText = "Select Folder", + modifier = Modifier.fillMaxSize() + ) + } + + // List of Folders + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB + ) { + items(syncedFolders, key = { it.uriString }) { folder -> + FolderCard(folder, onRemoveFolderClick) + } + } + } + } +} + +@Composable +private fun FolderCard( + folder: SyncedFolder, + onRemoveClick: (SyncedFolder) -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) } + val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime)) + + androidx.compose.material3.ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = Icons.Default.FolderSpecial, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = folder.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, "Options") + } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text("Remove Folder") }, + onClick = { + showMenu = false + onRemoveClick(folder) + }, + colors = androidx.compose.material3.MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error + ) + ) } } } - Spacer(modifier = Modifier.weight(1f)) + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) - Column { - HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) + // Details + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "LAST SYNC", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + Text(text = lastScanText, style = MaterialTheme.typography.bodySmall) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onChangeFolderClick, enabled = !isLoading) { - Text("Change Folder") - } - - TextButton( - onClick = onDisconnectClick, - enabled = !isLoading, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error + // You could add Book Count here if we queried it from DB + // For now, let's just show Status + Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) { + Text( + text = "STATUS", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(6.dp) + .background(Color(0xFF4CAF50), androidx.compose.foundation.shape.CircleShape) ) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Disconnect") + Spacer(modifier = Modifier.width(4.dp)) + Text(text = "Active", style = MaterialTheme.typography.bodySmall) } } } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index e4b0bb9..32c4762 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -30,6 +30,7 @@ import android.content.SharedPreferences import android.database.Cursor import android.net.Uri import android.os.Build +import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.core.content.edit import androidx.core.net.toUri @@ -133,6 +134,12 @@ data class DeviceLimitReachedState( val isLimitReached: Boolean = false, val registeredDevices: List = emptyList() ) +data class SyncedFolder( + val uriString: String, + val name: String, + val lastScanTime: Long +) + data class Shelf(val name: String, val books: List) { val bookCount: Int get() = books.size @@ -185,7 +192,7 @@ data class ReaderScreenState( val isRequestingDrivePermission: Boolean = false, val downloadingBookIds: Set = emptySet(), val uploadingBookIds: Set = emptySet(), - val syncedFolderUri: String? = null, + val syncedFolders: List = emptyList(), val lastFolderScanTime: Long? = null, val hasUnreadFeedback: Boolean = false, val searchQuery: String = "", @@ -269,7 +276,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio currentUser = authRepository.getSignedInUser(), isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false), isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false), - syncedFolderUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null), + syncedFolders = loadSyncedFoldersFromPrefs(), lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( KEY_LAST_FOLDER_SCAN_TIME, 0L @@ -453,7 +460,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ): PageModificationResult = withContext(Dispatchers.Default) { Timber.d("Removing page at index $removeIndex for book $bookId") - // 1. Update Layout val newLayout = currentLayout.toMutableList() if (removeIndex in newLayout.indices) { newLayout.removeAt(removeIndex) @@ -463,7 +469,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } - // 2. Shift Annotations (Delete for removed page, decrement for pages > removed) val newAnnotations = mutableMapOf>() currentAnnotations.forEach { (pageIdx, annots) -> if (pageIdx != removeIndex) { @@ -473,7 +478,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - // 3. Shift Bookmarks val newTotalPages = newLayout.size val newBookmarksJson = try { if (currentBookmarksJson.isNotBlank()) { @@ -504,7 +508,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio currentBookmarksJson } - // 4. Save Layout persistently pageLayoutRepository.saveLayout(bookId, newLayout) PageModificationResult(newLayout, newAnnotations, newBookmarksJson) @@ -528,9 +531,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val isMigrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false) - if (_internalState.value.syncedFolderUri != null) { + if (_internalState.value.syncedFolders.isNotEmpty()) { if (isMigrationCompleted) { - Timber.d("App Start: Triggering local folder metadata-only sync.") syncFolderMetadata() } else { Timber.d("App Start: Skipping sync. Waiting for migration/detachment logic.") @@ -586,10 +588,10 @@ 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) { + val hasFolders = _internalState.value.syncedFolders.isNotEmpty() + if (hasFolders && !migrationCompleted) { Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.") _internalState.update { it.copy(showFolderMigrationDialog = true) } } @@ -608,6 +610,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private fun getDisplayPathFromUri(context: Context, uriString: String): String { + val uri = uriString.toUri() + val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder" + if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri).isNotEmpty()) { + val documentId = DocumentsContract.getTreeDocumentId(uri) + val split = documentId.split(":") + if (split.size > 1) { + return split[1] + } + } + return fallbackName + } + private val fontsRepository = FontsRepository(appContext) val customFonts = fontsRepository.getAllFonts().stateIn( @@ -1268,20 +1283,83 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - fun setSyncedFolder(folderUri: Uri) { + private fun loadSyncedFoldersFromPrefs(): List { + val jsonString = prefs.getString(KEY_SYNCED_FOLDERS_JSON, null) + val folders = mutableListOf() + + if (jsonString == null && prefs.contains(KEY_SYNCED_FOLDER_URI)) { + val oldUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null) + val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L) + if (oldUri != null) { + val name = getDisplayPathFromUri(appContext, oldUri) + val migrated = SyncedFolder(oldUri, name, oldTime) + folders.add(migrated) + saveSyncedFoldersToPrefs(folders) + + prefs.edit { + remove(KEY_SYNCED_FOLDER_URI) + remove(KEY_LAST_FOLDER_SCAN_TIME) + } + } + } else if (jsonString != null) { + try { + val jsonArray = JSONArray(jsonString) + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + folders.add( + SyncedFolder( + uriString = obj.getString("uri"), + name = obj.getString("name"), + lastScanTime = obj.optLong("lastScanTime", 0L) + ) + ) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse synced folders JSON") + } + } + return folders + } + + private fun saveSyncedFoldersToPrefs(folders: List) { + val jsonArray = JSONArray() + folders.forEach { folder -> + val obj = JSONObject() + obj.put("uri", folder.uriString) + obj.put("name", folder.name) + obj.put("lastScanTime", folder.lastScanTime) + jsonArray.put(obj) + } + prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) } + } + + fun addSyncedFolder(folderUri: Uri) { + val currentFolders = _internalState.value.syncedFolders + + if (currentFolders.size >= MAX_FOLDER_LIMIT) { + showBanner("Limit reached: Maximum $MAX_FOLDER_LIMIT folders allowed.", isError = true) + return + } + + if (currentFolders.any { it.uriString == folderUri.toString() }) { + showBanner("This folder is already synced.", isError = true) + return + } + viewModelScope.launch { try { appContext.contentResolver.takePersistableUriPermission( - folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION + folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) - Timber.d("Persistable URI permission taken for folder: $folderUri") - prefs.edit { - putString(KEY_SYNCED_FOLDER_URI, folderUri.toString()) - putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) - } + + val name = getDisplayPathFromUri(appContext, folderUri.toString()) + val newFolder = SyncedFolder(folderUri.toString(), name, 0L) + val newStats = currentFolders + newFolder + + saveSyncedFoldersToPrefs(newStats) _internalState.update { it.copy( - syncedFolderUri = folderUri.toString(), + syncedFolders = newStats, showFolderMigrationDialog = false ) } @@ -1289,25 +1367,49 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val workManager = WorkManager.getInstance(appContext) val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() - - val syncRequest = - PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).setConstraints( - constraints - ).build() - + val syncRequest = PeriodicWorkRequestBuilder(4, TimeUnit.HOURS) + .setConstraints(constraints) + .build() workManager.enqueueUniquePeriodicWork( - FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.REPLACE, syncRequest + FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest ) - Timber.d("Scheduled periodic folder sync worker.") + + showBanner("Folder added: $name") + } catch (e: SecurityException) { - Timber.e(e, "Failed to take persistable URI permission for $folderUri") - _internalState.update { - it.copy(errorMessage = "Could not get permission for the selected folder.") - } + Timber.e(e, "Failed to take permissions for $folderUri") + showBanner("Failed to access folder permissions.", isError = true) } } } + fun removeSyncedFolder(folder: SyncedFolder) { + viewModelScope.launch { + val currentFolders = _internalState.value.syncedFolders.toMutableList() + currentFolders.removeAll { it.uriString == folder.uriString } + + saveSyncedFoldersToPrefs(currentFolders) + _internalState.update { it.copy(syncedFolders = currentFolders) } + + // Cleanup + recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) + try { + appContext.contentResolver.releasePersistableUriPermission( + folder.uriString.toUri(), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (e: Exception) { + Timber.w("Failed to release permissions: ${e.message}") + } + + if (currentFolders.isEmpty()) { + WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) + } + + showBanner("Folder removed.") + } + } + fun syncFolderMetadata() { triggerFolderSyncWorker(metadataOnly = true) } @@ -1317,8 +1419,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun triggerFolderSyncWorker(metadataOnly: Boolean) { - @Suppress("UnusedVariable", "Unused") val folderUriString = _internalState.value.syncedFolderUri ?: return - Timber.tag("FolderSync").d("Requesting folder sync (metadataOnly=$metadataOnly)") + val folders = _internalState.value.syncedFolders + if (folders.isEmpty()) return + + Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly)") val workManager = WorkManager.getInstance(appContext) val data = androidx.work.Data.Builder() @@ -1369,35 +1473,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - fun disconnectSyncedFolder() { + fun disconnectAllSyncedFolders() { viewModelScope.launch { - val folderUriString = _internalState.value.syncedFolderUri - - Timber.tag("FolderSync").d("Cancelling all folder sync workers...") - WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) - WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME) - WorkManager.getInstance(appContext).cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) + val folders = _internalState.value.syncedFolders + folders.forEach { folder -> + recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) + try { + appContext.contentResolver.releasePersistableUriPermission( + folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: Exception) {} + } prefs.edit { + remove(KEY_SYNCED_FOLDERS_JSON) remove(KEY_SYNCED_FOLDER_URI) - remove(KEY_LAST_FOLDER_SCAN_TIME) } - _internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) } + _internalState.update { it.copy(syncedFolders = emptyList()) } - if (folderUriString != null) { - Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.") - recentFilesRepository.deleteFilesBySourceFolder(folderUriString) - - try { - val uri = folderUriString.toUri() - val contentResolver = appContext.contentResolver - val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION - contentResolver.releasePersistableUriPermission(uri, takeFlags) - Timber.tag("FolderSync").d("Released permission for: $uri") - } catch (e: Exception) { - Timber.e(e, "Failed to release permission") - } - } + WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) } } @@ -2640,11 +2734,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun refreshLibrary() { val syncEnabled = _internalState.value.isSyncEnabled - val hasFolder = _internalState.value.syncedFolderUri != null // Check for URI instead of toggle + val hasFolder = _internalState.value.syncedFolders.isNotEmpty() if (!syncEnabled && !hasFolder) { Timber.d("Refresh skipped: No sync methods active.") - _internalState.update { it.copy(isRefreshing = false) } // Ensure indicator retracts immediately + _internalState.update { it.copy(isRefreshing = false) } return } @@ -2657,14 +2751,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } 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) } } @@ -3346,8 +3438,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" private const val KEY_MIGRATION_CHECKED_UIDS = "migration_checked_uids" private const val KEY_INSTALLATION_ID = "installation_id" + private const val KEY_APP_OPEN_COUNT = "app_open_count" internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri" internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time" - private const val KEY_APP_OPEN_COUNT = "app_open_count" + private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json" + private const val MAX_FOLDER_LIMIT = 3 } } diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index 97a2070..9794d3c 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -29,8 +29,12 @@ class MetadataExtractionWorker( override suspend fun doWork(): Result = withContext(Dispatchers.IO) { val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) - if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) { - Timber.tag("MetadataWorker").w("Folder disconnected. Stopping extraction worker.") + + val hasLegacy = prefs.contains("synced_folder_uri") + val hasNew = prefs.contains("synced_folders_list_json") + + if (!hasLegacy && !hasNew) { + Timber.tag("MetadataWorker").w("No folders linked. Stopping.") return@withContext Result.success() } @@ -46,7 +50,7 @@ class MetadataExtractionWorker( filesToProcess.forEach { item -> if (isStopped) return@forEach - if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) return@forEach + if (item.sourceFolderUri == null) return@forEach try { val uri = item.uriString?.toUri() ?: return@forEach diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt index f661599..9089fd3 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -184,6 +184,71 @@ object LocalSyncUtils { } } + suspend fun preloadAnnotationSidecars( + context: Context, + rootTree: DocumentFile + ): Map> = withContext(Dispatchers.IO) { + val results = mutableMapOf>() + + try { + val allFiles = rootTree.listFiles() + + val annotationFiles = allFiles.filter { file -> + val name = file.name ?: "" + name.contains(ANNOTATION_SUFFIX) && name.endsWith(".json") && !name.endsWith(".tmp") + } + + val filesByBookId = annotationFiles.groupBy { file -> + val name = file.name ?: "" + var temp = name.substringBeforeLast(".json") + if (temp.contains(".sync-conflict")) { + temp = temp.substringBefore(".sync-conflict") + } + if (temp.endsWith(ANNOTATION_SUFFIX)) { + temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length) + } + if (temp.startsWith(".")) { + temp = temp.substring(1) + } + temp + } + + filesByBookId.forEach { (bookId, files) -> + if (bookId.isNotBlank()) { + var bestTs = -1L + var bestData: String? = null + + for (file in files) { + try { + val content = context.contentResolver.openInputStream(file.uri)?.use { + it.bufferedReader().readText() + } ?: continue + + val json = JSONObject(content) + val ts = json.optLong("timestamp", 0L) + val data = json.optJSONObject("data")?.toString() + + if (data != null && ts > bestTs) { + bestTs = ts + bestData = data + } + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error parsing preloaded file: ${file.name}") + } + } + + if (bestData != null) { + results[bookId] = Pair(bestTs, bestData) + } + } + } + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars") + } + + return@withContext results + } + suspend fun getAnnotationSidecar( context: Context, sourceFolderUri: Uri,