From 12d50bb68d44b5dd5bbe974c2009d3c7db7ab935 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 10 Apr 2026 17:49:37 +0530 Subject: [PATCH] Library improvements (#163) * Improved local folder sync and file management by migrating sidecar metadata to a dedicated subfolder and enhancing book ID stability. * Optimized folder sync and increased synced folder limits. * Added support for managing active tabs in the `HomeScreen`. * Added support for strict file type filtering when picking documents. --- .../java/com/aryan/reader/FolderSyncWorker.kt | 242 ++++++++++++------ .../main/java/com/aryan/reader/HomeScreen.kt | 158 +++++++++++- .../java/com/aryan/reader/LibraryScreen.kt | 5 +- .../java/com/aryan/reader/MainViewModel.kt | 37 ++- .../com/aryan/reader/data/LocalSyncUtils.kt | 94 +++++-- .../com/aryan/reader/data/RecentFileDao.kt | 3 + .../reader/data/RecentFilesRepository.kt | 50 ++++ .../reader/epubreader/EpubReaderControls.kt | 10 +- app/src/main/res/values/strings.xml | 5 + 9 files changed, 482 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 07b05ba..d656f07 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -134,6 +134,9 @@ class FolderSyncWorker( return false } + Timber.tag("FolderSync").d("Phase 0: Migrating legacy root sidecars to subfolder...") + LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree) + Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) @@ -195,97 +198,168 @@ class FolderSyncWorker( } if (!metadataOnly) { - Timber.tag("FolderSync").d("Phase 2: Scanning physical files...") - val currentDiskFiles = mutableListOf() - val fileQueue = ArrayDeque() - documentTree.listFiles().let { fileQueue.addAll(it) } + Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...") + val contentResolver = appContext.contentResolver + val foundBookIds = mutableSetOf() + val newOrUpdatedItems = mutableListOf() + val existingItemsMap = existingFolderBooks.associateBy { it.bookId } - while (fileQueue.isNotEmpty()) { + val rootDocId = android.provider.DocumentsContract.getTreeDocumentId(folderUri) + val dirQueue = ArrayDeque() + dirQueue.add(rootDocId) + + val projection = arrayOf( + android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID, + android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME, + android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE, + android.provider.DocumentsContract.Document.COLUMN_SIZE, + android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + + while (dirQueue.isNotEmpty()) { if (isStopped) break + val currentDocId = dirQueue.removeFirst() + val childrenUri = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId) - val file = fileQueue.removeAt(0) - if (file.isDirectory) { - if (file.name?.startsWith(".") == true) { - continue - } - file.listFiles().let { fileQueue.addAll(it) } - } else if (file.isFile) { - val name = file.name ?: "" - val type = getFileType(name, file.type) - if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) { - currentDiskFiles.add(file) + try { + contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE) + val sizeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_SIZE) + val modCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED) + + while (cursor.moveToNext() && !isStopped) { + val docId = cursor.getString(idCol) + val name = cursor.getString(nameCol) ?: "" + val mimeType = cursor.getString(mimeCol) + + if (mimeType == android.provider.DocumentsContract.Document.MIME_TYPE_DIR) { + if (!name.startsWith(".") && name != "EpistemeSyncData") { + dirQueue.add(docId) + } + } else { + val size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L + val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L + + val type = getFileType(name, mimeType) + if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) { + val stableId = "local_$name" + foundBookIds.add(stableId) + + val docUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) + var existingItem = existingItemsMap[stableId] + + if (existingItem == null) { + val oldItem = existingItemsMap.values.find { it.bookId.startsWith("local_${name}_") && it.bookId != stableId } + if (oldItem != null) { + val oldId = oldItem.bookId + Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId") + + recentFilesRepository.migrateBookIdLocally(oldId, stableId) + existingItem = recentFilesRepository.getFileByBookId(stableId) + + try { + val syncDir = documentTree.findFile("EpistemeSyncData") + if (syncDir != null) { + syncDir.findFile(".$oldId.json")?.delete() + syncDir.findFile("$oldId.json")?.delete() + syncDir.findFile(".$oldId" + "_annotations.json")?.delete() + } + } catch (_: Exception) { Timber.tag("FolderSync").e("Failed to clean up orphaned SAF sidecars.") } + } + } + + if (existingItem == null) { + val remoteMeta = folderMetadataMap[stableId] + + val newItem = RecentFileItem( + bookId = stableId, + uriString = docUri.toString(), + type = type, + displayName = name, + timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), + lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), + coverImagePath = null, + title = remoteMeta?.title ?: name, + author = remoteMeta?.author, + isAvailable = true, + isDeleted = false, + isRecent = remoteMeta?.isRecent ?: false, + sourceFolderUri = folderUriString, + lastChapterIndex = remoteMeta?.lastChapterIndex, + lastPage = remoteMeta?.lastPage, + lastPositionCfi = remoteMeta?.lastPositionCfi, + progressPercentage = remoteMeta?.progressPercentage, + bookmarksJson = remoteMeta?.bookmarksJson, + highlightsJson = remoteMeta?.highlightsJson, + customName = remoteMeta?.customName, + locatorBlockIndex = remoteMeta?.locatorBlockIndex, + locatorCharOffset = remoteMeta?.locatorCharOffset, + fileSize = size + ) + newOrUpdatedItems.add(newItem) + } else { + var needsUpdate = false + var updatedItem = existingItem + + if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) { + Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).") + recentFilesRepository.clearLocalCachesForBook(stableId) + updatedItem = updatedItem.copy(fileSize = size, lastModifiedTimestamp = lastModified) + needsUpdate = true + } + + if (updatedItem.isDeleted || !updatedItem.isAvailable) { + updatedItem = updatedItem.copy(isDeleted = false, isAvailable = true) + needsUpdate = true + } + + if (updatedItem.uriString != docUri.toString()) { + updatedItem = updatedItem.copy(uriString = docUri.toString()) + needsUpdate = true + } + + if (needsUpdate) { + newOrUpdatedItems.add(updatedItem) + } + } + + if (newOrUpdatedItems.size >= 50) { + recentFilesRepository.addRecentFiles(newOrUpdatedItems) + newOrUpdatedItems.clear() + } + + if (!processedBookIds.contains(stableId)) { + val sidecarData = preloadedSidecars[stableId] + if (sidecarData != null) { + val (remoteTs, jsonPayload) = sidecarData + val localFiles = listOf( + File(appContext.filesDir, "annotations/annotation_$stableId.json"), + File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"), + File(appContext.filesDir, "page_layouts/layout_$stableId.json"), + File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.json") + ) + val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L + + if (remoteTs > (localTs + 1000)) { + Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for new book $stableId. Importing.") + recentFilesRepository.importAnnotationBundle(stableId, jsonPayload) + } + } + } + } + } + } } + } catch (e: Exception) { + Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId") } } - val foundBookIds = mutableSetOf() - - for (file in currentDiskFiles) { - if (isStopped) break - - val stableId = "local_${file.name}_${file.length()}" - foundBookIds.add(stableId) - - val existingItem = recentFilesRepository.getFileByBookId(stableId) - - if (existingItem == null) { - val remoteMeta = folderMetadataMap[stableId] - val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB - val placeholderTitle = file.name ?: "Unknown" - - val newItem = RecentFileItem( - bookId = stableId, - uriString = file.uri.toString(), - type = type, - displayName = file.name ?: "Unknown", - timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), - lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), - coverImagePath = null, - title = remoteMeta?.title ?: placeholderTitle, - author = remoteMeta?.author, - isAvailable = true, - isDeleted = false, - isRecent = remoteMeta?.isRecent ?: false, - sourceFolderUri = folderUriString, - lastChapterIndex = remoteMeta?.lastChapterIndex, - lastPage = remoteMeta?.lastPage, - lastPositionCfi = remoteMeta?.lastPositionCfi, - progressPercentage = remoteMeta?.progressPercentage, - bookmarksJson = remoteMeta?.bookmarksJson, - highlightsJson = remoteMeta?.highlightsJson, - customName = remoteMeta?.customName, - locatorBlockIndex = remoteMeta?.locatorBlockIndex, - locatorCharOffset = remoteMeta?.locatorCharOffset - ) - - recentFilesRepository.addRecentFile(newItem) - } else { - if (existingItem.isDeleted || !existingItem.isAvailable) { - val revived = existingItem.copy(isDeleted = false, isAvailable = true) - recentFilesRepository.addRecentFile(revived) - } - } - - if (!processedBookIds.contains(stableId)) { - val sidecarData = preloadedSidecars[stableId] - - if (sidecarData != null) { - val (remoteTs, jsonPayload) = sidecarData - - val localFiles = listOf( - File(appContext.filesDir, "annotations/annotation_$stableId.json"), - File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"), - File(appContext.filesDir, "page_layouts/layout_$stableId.json"), - File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.json") - ) - val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L - - if (remoteTs > (localTs + 1000)) { - Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for new book $stableId. Importing.") - recentFilesRepository.importAnnotationBundle(stableId, jsonPayload) - } - } - } + if (newOrUpdatedItems.isNotEmpty()) { + recentFilesRepository.addRecentFiles(newOrUpdatedItems) + newOrUpdatedItems.clear() } if (!isStopped) { diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 408fef1..103ed74 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -47,7 +47,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -55,6 +57,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderSpecial @@ -79,6 +82,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer @@ -146,10 +150,12 @@ fun HomeScreen( ) { val context = LocalContext.current val customTabUriHandler = remember { CustomTabUriHandler(context) } + var showCloseAllTabsDialog by remember { mutableStateOf(false) } CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val recentFilesForHome = uiState.recentFiles.filter { it.isRecent } + val openTabs = uiState.openTabs val selectedContextItems = uiState.contextualActionItems val isContextualModeActive = selectedContextItems.isNotEmpty() val scope = rememberCoroutineScope() @@ -166,7 +172,7 @@ fun HomeScreen( var showInfoDialog by remember { mutableStateOf(false) } var itemForInfoDialog by remember { mutableStateOf(null) } var showBehaviorDialog by remember { mutableStateOf(false) } - + var showStrictFilterDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearReflowCacheDialog by remember { mutableStateOf(false) } @@ -240,8 +246,9 @@ fun HomeScreen( if (isContextualModeActive) { viewModel.clearContextualAction() } + val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*") try { - pickFileLauncher.launch(arrayOf("*/*")) + pickFileLauncher.launch(mimeTypes) } catch (_: android.content.ActivityNotFoundException) { Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.") try { @@ -312,7 +319,14 @@ fun HomeScreen( onClearReflowCache = { showClearReflowCacheDialog = true }, onRecentFilesLimitChange = viewModel::setRecentFilesLimit, onTabsToggle = viewModel::setTabsEnabled, - onExternalFileBehaviorClick = { showBehaviorDialog = true } + onExternalFileBehaviorClick = { showBehaviorDialog = true }, + onStrictFilterToggleClick = { + if (uiState.useStrictFileFilter) { + viewModel.setStrictFileFilter(false) + } else { + showStrictFilterDialog = true + } + } ) } else { ContextualTopAppBar( @@ -335,7 +349,7 @@ fun HomeScreen( .fillMaxSize() .padding(paddingValues) ) { - if (recentFilesForHome.isEmpty()) { + if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) { if (uiState.recentFiles.isEmpty()) { EmptyState( title = stringResource(R.string.your_library_empty), @@ -356,10 +370,14 @@ fun HomeScreen( } else { RecentFilesContent( recentFiles = recentFilesForHome, + openTabs = openTabs, + isTabsEnabled = uiState.isTabsEnabled, selectedContextItems = selectedContextItems, pinnedHomeBookIds = uiState.pinnedHomeBookIds, onItemClick = { item -> viewModel.onRecentFileClicked(item) }, onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) }, + onTabCloseClick = { bookId -> viewModel.closeTab(bookId) }, + onCloseAllTabsClick = { showCloseAllTabsDialog = true }, onSelectFileClick = onSelectFileClick, onNavigateToFolderSync = { viewModel.navigateToFolderSync() }, windowSizeClass = windowSizeClass, @@ -395,6 +413,16 @@ fun HomeScreen( }, onDismiss = { showDeleteConfirmDialog = false }) } + if (showCloseAllTabsDialog) { + CloseAllTabsDialog( + onConfirm = { + viewModel.closeAllTabs() + showCloseAllTabsDialog = false + }, + onDismiss = { showCloseAllTabsDialog = false } + ) + } + if (showClearCloudDataDialog) { ClearCloudDataConfirmationDialog(onConfirm = { viewModel.deleteAllUserData() @@ -461,6 +489,16 @@ fun HomeScreen( onSelect = { viewModel.setExternalFileBehavior(it) } ) } + + if (showStrictFilterDialog) { + StrictFilterConfirmationDialog( + onConfirm = { + viewModel.setStrictFileFilter(true) + showStrictFilterDialog = false + }, + onDismiss = { showStrictFilterDialog = false } + ) + } } } if (showAboutDialog) { @@ -504,10 +542,14 @@ fun HomeScreen( @Composable private fun RecentFilesContent( recentFiles: List, + openTabs: List, + isTabsEnabled: Boolean, selectedContextItems: Collection, pinnedHomeBookIds: Set, onItemClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit, + onTabCloseClick: (String) -> Unit, + onCloseAllTabsClick: () -> Unit, onSelectFileClick: () -> Unit, onNavigateToFolderSync: () -> Unit, windowSizeClass: WindowSizeClass, @@ -526,6 +568,10 @@ private fun RecentFilesContent( .fillMaxSize() .padding(horizontal = 16.dp), recentFiles = recentFiles, + openTabs = openTabs, + isTabsEnabled = isTabsEnabled, + onTabCloseClick = onTabCloseClick, + onCloseAllTabsClick = onCloseAllTabsClick, selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(), pinnedHomeBookIds = pinnedHomeBookIds, onItemClick = onItemClick, @@ -570,6 +616,10 @@ private fun RecentFilesContent( private fun RecentFilesGrid( modifier: Modifier = Modifier, recentFiles: List, + openTabs: List, + isTabsEnabled: Boolean, + onTabCloseClick: (String) -> Unit, + onCloseAllTabsClick: () -> Unit, pinnedHomeBookIds: Set, selectedItemUris: Set, onItemClick: (RecentFileItem) -> Unit, @@ -585,11 +635,66 @@ private fun RecentFilesGrid( } Column(modifier = modifier) { + if (isTabsEnabled && openTabs.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, top = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.active_tabs), + style = MaterialTheme.typography.titleLarge + ) + IconButton(onClick = onCloseAllTabsClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close_all_tabs), + tint = MaterialTheme.colorScheme.error + ) + } + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 8.dp) + ) { + items(openTabs, key = { "tab_${it.bookId}" }) { tab -> + InputChip( + selected = false, + onClick = { onItemClick(tab) }, + label = { + Text( + text = tab.customName ?: tab.title ?: tab.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 150.dp) + ) + }, + trailingIcon = { + IconButton( + onClick = { onTabCloseClick(tab.bookId) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close_tab), + modifier = Modifier.size(16.dp) + ) + } + } + ) + } + } + } + Text( text = stringResource(R.string.recent_files), style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp, top = 24.dp) + modifier = Modifier.padding(bottom = 8.dp, top = if (isTabsEnabled && openTabs.isNotEmpty()) 8.dp else 24.dp) ) + LazyVerticalGrid( columns = gridCells, contentPadding = contentPadding, @@ -777,7 +882,8 @@ fun DefaultTopAppBar( onFolderSyncToggle: (Boolean) -> Unit, onRecentFilesLimitChange: (Int) -> Unit, onTabsToggle: (Boolean) -> Unit, - onExternalFileBehaviorClick: () -> Unit + onExternalFileBehaviorClick: () -> Unit, + onStrictFilterToggleClick: () -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } @@ -846,6 +952,15 @@ fun DefaultTopAppBar( showOptionsMenu = false }) + DropdownMenuItem(text = { Text("Use Strict File Filter") }, onClick = { + onStrictFilterToggleClick() + showOptionsMenu = false + }, trailingIcon = { + if (uiState.useStrictFileFilter) { + Icon(Icons.Default.Check, contentDescription = "Enabled") + } + }) + HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { onClearCache() @@ -1382,4 +1497,35 @@ fun ExternalFileBehaviorDialog( TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) +} + +@Composable +fun CloseAllTabsDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_close_all_tabs)) }, + text = { Text(stringResource(R.string.dialog_close_all_tabs_desc)) }, + confirmButton = { + TextButton( + onClick = onConfirm, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(R.string.action_close)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + } + ) +} + +@Composable +fun StrictFilterConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Enable Strict File Filter") }, + text = { Text("If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?") }, + confirmButton = { TextButton(onClick = onConfirm) { Text("Enable") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 05a8e02..eeff620 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -215,8 +215,9 @@ fun LibraryScreen( if (isContextualModeActive) { viewModel.clearContextualAction() } + val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*") try { - pickFileLauncher.launch(arrayOf("*/*")) + pickFileLauncher.launch(mimeTypes) } catch (_: android.content.ActivityNotFoundException) { Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.") try { @@ -1553,7 +1554,7 @@ private fun FolderSyncScreen( Scaffold( floatingActionButton = { - if (syncedFolders.size < 3) { + if (syncedFolders.size < 10) { ExtendedFloatingActionButton( text = { Text(stringResource(R.string.fab_add_folder)) }, icon = { Icon(Icons.Default.Add, "Add") }, diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 7faf048..7f1a84a 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -242,6 +242,7 @@ data class ReaderScreenState( val activeTabBookId: String? = null, val showExternalFileSavePromptFor: String? = null, val externalFileBehavior: String = "ASK", + val useStrictFileFilter: Boolean = false, ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -352,7 +353,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } catch(_: Exception) { emptyList() } } ?: emptyList(), activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), - externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" + externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK", + useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false) ) ) @@ -4468,6 +4470,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun closeAllTabs() { + Timber.tag("PdfTabSync").i("ViewModel: closeAllTabs called") + prefs.edit { + remove(KEY_OPEN_TAB_IDS) + remove(KEY_ACTIVE_TAB) + } + _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) } + clearSelectedFile() + } + + fun setStrictFileFilter(enabled: Boolean) { + prefs.edit { putBoolean(KEY_USE_STRICT_FILE_FILTER, enabled) } + _internalState.update { it.copy(useStrictFileFilter = enabled) } + } + companion object { private const val KEY_SORT_ORDER = "sort_order" internal const val KEY_SHELVES = "shelf_names" @@ -4483,7 +4500,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 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_SYNCED_FOLDERS_JSON = "synced_folders_list_json" - private const val MAX_FOLDER_LIMIT = 3 + private const val MAX_FOLDER_LIMIT = 10 internal const val KEY_PINNED_HOME = "pinned_home_books" internal const val KEY_PINNED_LIBRARY = "pinned_library_books" private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit" @@ -4491,5 +4508,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_OPEN_TAB_IDS = "open_tab_ids" private const val KEY_ACTIVE_TAB = "active_tab_book_id" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" + private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" + + val SUPPORTED_MIME_TYPES = arrayOf( + "application/pdf", "application/epub+zip", "application/x-mobipocket-ebook", + "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook", "text/markdown", + "text/x-markdown", "text/plain", "text/html", "application/xhtml+xml", + "application/x-fictionbook+xml", "application/x-zip-compressed-fb2", "application/zip", + "application/vnd.comicbook+zip", "application/x-cbz", "application/vnd.comicbook-rar", + "application/x-cbr", "application/x-rar-compressed", "application/x-cb7", + "application/x-7z-compressed", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.oasis.opendocument.text", "application/x-vnd.oasis.opendocument.text-flat-xml", + "text/csv", "text/comma-separated-values", "text/tab-separated-values", "application/json", + "application/xml", "text/xml", "text/x-java-source", "text/x-python", "text/x-kotlin", + "text/javascript", "application/javascript", "text/x-c", "text/x-c++", + "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" + ) } } 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 9089fd3..f7c3912 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -14,6 +14,53 @@ import timber.log.Timber object LocalSyncUtils { private const val TAG = "FolderSync" private const val ANNOTATION_SUFFIX = "_annotations" + private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData" + + private fun getOrCreateSyncDir(rootTree: DocumentFile): DocumentFile? { + val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME) + if (existing != null && existing.isDirectory) return existing + if (existing != null && existing.isFile) return null + return rootTree.createDirectory(SYNC_SUBFOLDER_NAME) + } + + suspend fun migrateLegacySidecarsToSubfolder(context: Context, rootTree: DocumentFile) = withContext(Dispatchers.IO) { + try { + val allRootFiles = rootTree.listFiles() + val legacyFiles = allRootFiles.filter { file -> + val name = file.name ?: "" + file.isFile && ( + (name.startsWith(".local_") && name.endsWith(".json")) || + (name.startsWith("local_") && name.endsWith(".json")) || + (name.contains(ANNOTATION_SUFFIX)) + ) + } + + if (legacyFiles.isEmpty()) return@withContext + + Timber.tag(TAG).i("Found ${legacyFiles.size} legacy sidecar files at root. Migrating to $SYNC_SUBFOLDER_NAME...") + val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext + + for (legacyFile in legacyFiles) { + val name = legacyFile.name ?: continue + try { + val targetFile = syncDir.findFile(name) ?: syncDir.createFile("application/json", name) + if (targetFile != null) { + context.contentResolver.openInputStream(legacyFile.uri)?.use { input -> + context.contentResolver.openOutputStream(targetFile.uri, "w")?.use { output -> + input.copyTo(output) + } + } + legacyFile.delete() // Cleanup root file after success + Timber.tag(TAG).d("Migrated: $name") + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to migrate legacy file: $name") + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error migrating legacy sidecars") + } + } suspend fun saveMetadataToFolder( context: Context, @@ -22,12 +69,13 @@ object LocalSyncUtils { ) = withContext(Dispatchers.IO) { try { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext + val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext val syncFileName = ".${metadata.bookId}.json" val legacyVisibleName = "${metadata.bookId}.json" - val existingHidden = rootTree.findFile(syncFileName) - val existingVisible = rootTree.findFile(legacyVisibleName) + val existingHidden = syncDir.findFile(syncFileName) + val existingVisible = syncDir.findFile(legacyVisibleName) val fileToCheck = existingHidden ?: existingVisible if (fileToCheck != null && fileToCheck.exists()) { @@ -50,9 +98,9 @@ object LocalSyncUtils { } val tempFileName = ".${metadata.bookId}.tmp" - rootTree.findFile(tempFileName)?.delete() + syncDir.findFile(tempFileName)?.delete() - val tempFile = rootTree.createFile("application/json", tempFileName) + val tempFile = syncDir.createFile("application/json", tempFileName) if (tempFile == null) { Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}") return@withContext @@ -116,12 +164,13 @@ object LocalSyncUtils { ) = withContext(Dispatchers.IO) { Timber.tag("FolderAnnotationSync").d("saveAnnotationSidecar called for bookId: $bookId, timestamp: $timestamp") try { - val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: run { - Timber.tag("FolderAnnotationSync").w("Could not get DocumentFile from sourceFolderUri") - return@withContext - } - - val currentBest = resolveAndCleanAnnotationConflicts(context, rootTree, bookId) + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext + val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext + val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) + val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json" + val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp" + val tempFile = syncDir.createFile("application/json", tempName) + val existingMain = syncDir.findFile(targetName) if (currentBest != null) { val (remoteTs, _) = currentBest @@ -137,12 +186,8 @@ object LocalSyncUtils { wrapper.put("data", JSONObject(jsonPayload)) val contentBytes = wrapper.toString().toByteArray() - val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json" - val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp" + syncDir.findFile(tempName)?.delete() - rootTree.findFile(tempName)?.delete() - - val tempFile = rootTree.createFile("application/json", tempName) if (tempFile == null) { Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.") return@withContext @@ -191,7 +236,9 @@ object LocalSyncUtils { val results = mutableMapOf>() try { - val allFiles = rootTree.listFiles() + val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) + if (syncDir == null || !syncDir.isDirectory) return@withContext results + val allFiles = syncDir.listFiles() val annotationFiles = allFiles.filter { file -> val name = file.name ?: "" @@ -256,8 +303,8 @@ object LocalSyncUtils { ): Pair? = withContext(Dispatchers.IO) { try { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null - - val bestFile = resolveAndCleanAnnotationConflicts(context, rootTree, bookId) + val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext null + val bestFile = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) return@withContext bestFile } catch (e: Exception) { @@ -268,12 +315,12 @@ object LocalSyncUtils { private fun resolveAndCleanAnnotationConflicts( context: Context, - rootTree: DocumentFile, + syncDir: DocumentFile, bookId: String ): Pair? { val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" - val allFiles = rootTree.listFiles() + val allFiles = syncDir.listFiles() val candidates = allFiles.filter { file -> val name = file.name ?: "" @@ -332,7 +379,7 @@ object LocalSyncUtils { val correctName = "${basePattern}.json" if (bestFile.name != correctName) { Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName") - val existingTarget = rootTree.findFile(correctName) + val existingTarget = syncDir.findFile(correctName) if (existingTarget != null && existingTarget.uri != bestFile.uri) { existingTarget.delete() } @@ -431,8 +478,9 @@ object LocalSyncUtils { try { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults - - val allFiles = rootTree.listFiles() + val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) + if (syncDir == null || !syncDir.isDirectory) return@withContext finalResults + val allFiles = syncDir.listFiles() val groupedFiles = allFiles .filter { diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index 2ecbf18..1e371b8 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -31,6 +31,9 @@ interface RecentFileDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOrUpdateFile(file: RecentFileEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdateFiles(files: List) + @Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") fun getRecentFiles(): Flow> diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 997e32a..ae32e33 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -444,4 +444,54 @@ class RecentFilesRepository(private val context: Context) { } return deleted } + + suspend fun migrateBookIdLocally(oldId: String, newId: String) = withContext(Dispatchers.IO) { + val oldEntity = recentFileDao.getFileByBookId(oldId) ?: return@withContext + val newEntity = oldEntity.copy(bookId = newId) + recentFileDao.insertOrUpdateFile(newEntity) + recentFileDao.deleteFilePermanently(listOf(oldId)) + + fun renameSafely(oldFile: File?, newFile: File?) { + if (oldFile != null && oldFile.exists() && newFile != null) { + if (newFile.exists()) newFile.delete() + oldFile.renameTo(newFile) + } + } + + renameSafely( + pdfAnnotationRepository.getAnnotationFileForSync(oldId) ?: File(context.filesDir, "annotations/annotation_$oldId.json"), + pdfAnnotationRepository.getAnnotationFileForSync(newId) ?: File(context.filesDir, "annotations/annotation_$newId.json") + ) + renameSafely(pdfRichTextRepository.getFileForSync(oldId), pdfRichTextRepository.getFileForSync(newId)) + renameSafely(pageLayoutRepository.getLayoutFile(oldId), pageLayoutRepository.getLayoutFile(newId)) + renameSafely(pdfTextBoxRepository.getFileForSync(oldId), pdfTextBoxRepository.getFileForSync(newId)) + renameSafely(pdfHighlightRepository.getFileForSync(oldId), pdfHighlightRepository.getFileForSync(newId)) + + val oldCache = File(context.cacheDir, "imported_file_$oldId") + val newCache = File(context.cacheDir, "imported_file_$newId") + if (oldCache.exists()) { + if (newCache.exists()) newCache.deleteRecursively() + oldCache.renameTo(newCache) + } + Timber.tag("SyncMigration").d("Migrated local sidecars from $oldId to $newId") + } + + suspend fun clearLocalCachesForBook(bookId: String) = withContext(Dispatchers.IO) { + try { + pdfRichTextRepository.getFileForSync(bookId).delete() + pageLayoutRepository.getLayoutFile(bookId).delete() + val cacheDir = File(context.cacheDir, "imported_file_$bookId") + if (cacheDir.exists()) cacheDir.deleteRecursively() + Timber.d("Cleared layout and text caches for modified book: $bookId") + } catch (e: Exception) { + Timber.e(e, "Error clearing caches for $bookId") + } + } + + suspend fun addRecentFiles(items: List) = withContext(Dispatchers.IO) { + if (items.isEmpty()) return@withContext + val entities = items.map { it.toRecentFileEntity() } + recentFileDao.insertOrUpdateFiles(entities) + Timber.d("Batch inserted/updated ${items.size} recent files in DB.") + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index db462e5..51c6864 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -1048,8 +1048,8 @@ fun AutoScrollControls( onClick = onPlayPauseToggle, modifier = Modifier.size(36.dp), colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + contentColor = MaterialTheme.colorScheme.primary ) ) { Icon( @@ -1212,8 +1212,8 @@ fun AutoScrollControls( onClick = onPlayPauseToggle, modifier = Modifier.size(48.dp), colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + contentColor = MaterialTheme.colorScheme.primary ) ) { Icon( @@ -1225,7 +1225,7 @@ fun AutoScrollControls( if (isTempPaused && isPlaying) { CircularProgressIndicator( modifier = Modifier.size(48.dp), - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), strokeWidth = 3.dp ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 886ac0d..eac908c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,11 @@ Apply Free Save + Active Tabs + Close Tab + Close All Tabs + Close All Tabs? + Are you sure you want to close all active tabs? %1$s you agree to our %2$s and acknowledge you have read our %3$s.