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.
This commit is contained in:
Aryan 2026-04-10 17:49:37 +05:30 committed by GitHub
parent 49e08cc9f1
commit 12d50bb68d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 482 additions and 122 deletions

View file

@ -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<DocumentFile>()
val fileQueue = ArrayDeque<DocumentFile>()
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<String>()
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }
while (fileQueue.isNotEmpty()) {
val rootDocId = android.provider.DocumentsContract.getTreeDocumentId(folderUri)
val dirQueue = ArrayDeque<String>()
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<String>()
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) {

View file

@ -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<RecentFileItem?>(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<RecentFileItem>,
openTabs: List<RecentFileItem>,
isTabsEnabled: Boolean,
selectedContextItems: Collection<RecentFileItem>,
pinnedHomeBookIds: Set<String>,
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<RecentFileItem>,
openTabs: List<RecentFileItem>,
isTabsEnabled: Boolean,
onTabCloseClick: (String) -> Unit,
onCloseAllTabsClick: () -> Unit,
pinnedHomeBookIds: Set<String>,
selectedItemUris: Set<String>,
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()
@ -1383,3 +1498,34 @@ fun ExternalFileBehaviorDialog(
}
)
}
@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)) } }
)
}

View file

@ -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") },

View file

@ -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"
)
}
}

View file

@ -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<String, Pair<Long, String>>()
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<Long, String>? = 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<Long, String>? {
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 {

View file

@ -31,6 +31,9 @@ interface RecentFileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateFile(file: RecentFileEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileEntity>>

View file

@ -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<RecentFileItem>) = 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.")
}
}

View file

@ -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
)
}

View file

@ -16,6 +16,11 @@
<string name="action_apply">Apply</string>
<string name="tab_free">Free</string>
<string name="action_save_catalog">Save</string>
<string name="active_tabs">Active Tabs</string>
<string name="close_tab">Close Tab</string>
<string name="close_all_tabs">Close All Tabs</string>
<string name="dialog_close_all_tabs">Close All Tabs?</string>
<string name="dialog_close_all_tabs_desc">Are you sure you want to close all active tabs?</string>
<!-- Shared Composables & Dialogs -->
<string name="legal_agreement_full">%1$s you agree to our %2$s and acknowledge you have read our %3$s.</string>