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:
parent
49e08cc9f1
commit
12d50bb68d
9 changed files with 482 additions and 122 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)) } }
|
||||
)
|
||||
}
|
||||
|
|
@ -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") },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue