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
|
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...")
|
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
||||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
||||||
|
|
||||||
|
|
@ -195,97 +198,168 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadataOnly) {
|
if (!metadataOnly) {
|
||||||
Timber.tag("FolderSync").d("Phase 2: Scanning physical files...")
|
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
|
||||||
val currentDiskFiles = mutableListOf<DocumentFile>()
|
val contentResolver = appContext.contentResolver
|
||||||
val fileQueue = ArrayDeque<DocumentFile>()
|
val foundBookIds = mutableSetOf<String>()
|
||||||
documentTree.listFiles().let { fileQueue.addAll(it) }
|
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
|
if (isStopped) break
|
||||||
|
val currentDocId = dirQueue.removeFirst()
|
||||||
|
val childrenUri = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||||
|
|
||||||
val file = fileQueue.removeAt(0)
|
try {
|
||||||
if (file.isDirectory) {
|
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||||
if (file.name?.startsWith(".") == true) {
|
val idCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||||
continue
|
val nameCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||||
}
|
val mimeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
file.listFiles().let { fileQueue.addAll(it) }
|
val sizeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_SIZE)
|
||||||
} else if (file.isFile) {
|
val modCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||||
val name = file.name ?: ""
|
|
||||||
val type = getFileType(name, file.type)
|
while (cursor.moveToNext() && !isStopped) {
|
||||||
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
|
val docId = cursor.getString(idCol)
|
||||||
currentDiskFiles.add(file)
|
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>()
|
if (newOrUpdatedItems.isNotEmpty()) {
|
||||||
|
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
||||||
for (file in currentDiskFiles) {
|
newOrUpdatedItems.clear()
|
||||||
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 (!isStopped) {
|
if (!isStopped) {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.FolderSpecial
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.InputChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
|
@ -146,10 +150,12 @@ fun HomeScreen(
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val customTabUriHandler = remember { CustomTabUriHandler(context) }
|
val customTabUriHandler = remember { CustomTabUriHandler(context) }
|
||||||
|
var showCloseAllTabsDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val recentFilesForHome = uiState.recentFiles.filter { it.isRecent }
|
val recentFilesForHome = uiState.recentFiles.filter { it.isRecent }
|
||||||
|
val openTabs = uiState.openTabs
|
||||||
val selectedContextItems = uiState.contextualActionItems
|
val selectedContextItems = uiState.contextualActionItems
|
||||||
val isContextualModeActive = selectedContextItems.isNotEmpty()
|
val isContextualModeActive = selectedContextItems.isNotEmpty()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -166,7 +172,7 @@ fun HomeScreen(
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
var showBehaviorDialog by remember { mutableStateOf(false) }
|
var showBehaviorDialog by remember { mutableStateOf(false) }
|
||||||
|
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
||||||
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||||
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
|
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -240,8 +246,9 @@ fun HomeScreen(
|
||||||
if (isContextualModeActive) {
|
if (isContextualModeActive) {
|
||||||
viewModel.clearContextualAction()
|
viewModel.clearContextualAction()
|
||||||
}
|
}
|
||||||
|
val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*")
|
||||||
try {
|
try {
|
||||||
pickFileLauncher.launch(arrayOf("*/*"))
|
pickFileLauncher.launch(mimeTypes)
|
||||||
} catch (_: android.content.ActivityNotFoundException) {
|
} catch (_: android.content.ActivityNotFoundException) {
|
||||||
Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.")
|
Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.")
|
||||||
try {
|
try {
|
||||||
|
|
@ -312,7 +319,14 @@ fun HomeScreen(
|
||||||
onClearReflowCache = { showClearReflowCacheDialog = true },
|
onClearReflowCache = { showClearReflowCacheDialog = true },
|
||||||
onRecentFilesLimitChange = viewModel::setRecentFilesLimit,
|
onRecentFilesLimitChange = viewModel::setRecentFilesLimit,
|
||||||
onTabsToggle = viewModel::setTabsEnabled,
|
onTabsToggle = viewModel::setTabsEnabled,
|
||||||
onExternalFileBehaviorClick = { showBehaviorDialog = true }
|
onExternalFileBehaviorClick = { showBehaviorDialog = true },
|
||||||
|
onStrictFilterToggleClick = {
|
||||||
|
if (uiState.useStrictFileFilter) {
|
||||||
|
viewModel.setStrictFileFilter(false)
|
||||||
|
} else {
|
||||||
|
showStrictFilterDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContextualTopAppBar(
|
ContextualTopAppBar(
|
||||||
|
|
@ -335,7 +349,7 @@ fun HomeScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
if (recentFilesForHome.isEmpty()) {
|
if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) {
|
||||||
if (uiState.recentFiles.isEmpty()) {
|
if (uiState.recentFiles.isEmpty()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.your_library_empty),
|
title = stringResource(R.string.your_library_empty),
|
||||||
|
|
@ -356,10 +370,14 @@ fun HomeScreen(
|
||||||
} else {
|
} else {
|
||||||
RecentFilesContent(
|
RecentFilesContent(
|
||||||
recentFiles = recentFilesForHome,
|
recentFiles = recentFilesForHome,
|
||||||
|
openTabs = openTabs,
|
||||||
|
isTabsEnabled = uiState.isTabsEnabled,
|
||||||
selectedContextItems = selectedContextItems,
|
selectedContextItems = selectedContextItems,
|
||||||
pinnedHomeBookIds = uiState.pinnedHomeBookIds,
|
pinnedHomeBookIds = uiState.pinnedHomeBookIds,
|
||||||
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
|
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
|
||||||
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
|
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
|
||||||
|
onTabCloseClick = { bookId -> viewModel.closeTab(bookId) },
|
||||||
|
onCloseAllTabsClick = { showCloseAllTabsDialog = true },
|
||||||
onSelectFileClick = onSelectFileClick,
|
onSelectFileClick = onSelectFileClick,
|
||||||
onNavigateToFolderSync = { viewModel.navigateToFolderSync() },
|
onNavigateToFolderSync = { viewModel.navigateToFolderSync() },
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
|
|
@ -395,6 +413,16 @@ fun HomeScreen(
|
||||||
}, onDismiss = { showDeleteConfirmDialog = false })
|
}, onDismiss = { showDeleteConfirmDialog = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showCloseAllTabsDialog) {
|
||||||
|
CloseAllTabsDialog(
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.closeAllTabs()
|
||||||
|
showCloseAllTabsDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCloseAllTabsDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showClearCloudDataDialog) {
|
if (showClearCloudDataDialog) {
|
||||||
ClearCloudDataConfirmationDialog(onConfirm = {
|
ClearCloudDataConfirmationDialog(onConfirm = {
|
||||||
viewModel.deleteAllUserData()
|
viewModel.deleteAllUserData()
|
||||||
|
|
@ -461,6 +489,16 @@ fun HomeScreen(
|
||||||
onSelect = { viewModel.setExternalFileBehavior(it) }
|
onSelect = { viewModel.setExternalFileBehavior(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showStrictFilterDialog) {
|
||||||
|
StrictFilterConfirmationDialog(
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.setStrictFileFilter(true)
|
||||||
|
showStrictFilterDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showStrictFilterDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showAboutDialog) {
|
if (showAboutDialog) {
|
||||||
|
|
@ -504,10 +542,14 @@ fun HomeScreen(
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecentFilesContent(
|
private fun RecentFilesContent(
|
||||||
recentFiles: List<RecentFileItem>,
|
recentFiles: List<RecentFileItem>,
|
||||||
|
openTabs: List<RecentFileItem>,
|
||||||
|
isTabsEnabled: Boolean,
|
||||||
selectedContextItems: Collection<RecentFileItem>,
|
selectedContextItems: Collection<RecentFileItem>,
|
||||||
pinnedHomeBookIds: Set<String>,
|
pinnedHomeBookIds: Set<String>,
|
||||||
onItemClick: (RecentFileItem) -> Unit,
|
onItemClick: (RecentFileItem) -> Unit,
|
||||||
onItemLongClick: (RecentFileItem) -> Unit,
|
onItemLongClick: (RecentFileItem) -> Unit,
|
||||||
|
onTabCloseClick: (String) -> Unit,
|
||||||
|
onCloseAllTabsClick: () -> Unit,
|
||||||
onSelectFileClick: () -> Unit,
|
onSelectFileClick: () -> Unit,
|
||||||
onNavigateToFolderSync: () -> Unit,
|
onNavigateToFolderSync: () -> Unit,
|
||||||
windowSizeClass: WindowSizeClass,
|
windowSizeClass: WindowSizeClass,
|
||||||
|
|
@ -526,6 +568,10 @@ private fun RecentFilesContent(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
recentFiles = recentFiles,
|
recentFiles = recentFiles,
|
||||||
|
openTabs = openTabs,
|
||||||
|
isTabsEnabled = isTabsEnabled,
|
||||||
|
onTabCloseClick = onTabCloseClick,
|
||||||
|
onCloseAllTabsClick = onCloseAllTabsClick,
|
||||||
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
|
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
|
||||||
pinnedHomeBookIds = pinnedHomeBookIds,
|
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||||
onItemClick = onItemClick,
|
onItemClick = onItemClick,
|
||||||
|
|
@ -570,6 +616,10 @@ private fun RecentFilesContent(
|
||||||
private fun RecentFilesGrid(
|
private fun RecentFilesGrid(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
recentFiles: List<RecentFileItem>,
|
recentFiles: List<RecentFileItem>,
|
||||||
|
openTabs: List<RecentFileItem>,
|
||||||
|
isTabsEnabled: Boolean,
|
||||||
|
onTabCloseClick: (String) -> Unit,
|
||||||
|
onCloseAllTabsClick: () -> Unit,
|
||||||
pinnedHomeBookIds: Set<String>,
|
pinnedHomeBookIds: Set<String>,
|
||||||
selectedItemUris: Set<String>,
|
selectedItemUris: Set<String>,
|
||||||
onItemClick: (RecentFileItem) -> Unit,
|
onItemClick: (RecentFileItem) -> Unit,
|
||||||
|
|
@ -585,11 +635,66 @@ private fun RecentFilesGrid(
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
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(
|
||||||
text = stringResource(R.string.recent_files),
|
text = stringResource(R.string.recent_files),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
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(
|
LazyVerticalGrid(
|
||||||
columns = gridCells,
|
columns = gridCells,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
|
|
@ -777,7 +882,8 @@ fun DefaultTopAppBar(
|
||||||
onFolderSyncToggle: (Boolean) -> Unit,
|
onFolderSyncToggle: (Boolean) -> Unit,
|
||||||
onRecentFilesLimitChange: (Int) -> Unit,
|
onRecentFilesLimitChange: (Int) -> Unit,
|
||||||
onTabsToggle: (Boolean) -> Unit,
|
onTabsToggle: (Boolean) -> Unit,
|
||||||
onExternalFileBehaviorClick: () -> Unit
|
onExternalFileBehaviorClick: () -> Unit,
|
||||||
|
onStrictFilterToggleClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||||
var showLimitMenu by remember { mutableStateOf(false) }
|
var showLimitMenu by remember { mutableStateOf(false) }
|
||||||
|
|
@ -846,6 +952,15 @@ fun DefaultTopAppBar(
|
||||||
showOptionsMenu = false
|
showOptionsMenu = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DropdownMenuItem(text = { Text("Use Strict File Filter") }, onClick = {
|
||||||
|
onStrictFilterToggleClick()
|
||||||
|
showOptionsMenu = false
|
||||||
|
}, trailingIcon = {
|
||||||
|
if (uiState.useStrictFileFilter) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = "Enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
||||||
onClearCache()
|
onClearCache()
|
||||||
|
|
@ -1382,4 +1497,35 @@ fun ExternalFileBehaviorDialog(
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
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) {
|
if (isContextualModeActive) {
|
||||||
viewModel.clearContextualAction()
|
viewModel.clearContextualAction()
|
||||||
}
|
}
|
||||||
|
val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*")
|
||||||
try {
|
try {
|
||||||
pickFileLauncher.launch(arrayOf("*/*"))
|
pickFileLauncher.launch(mimeTypes)
|
||||||
} catch (_: android.content.ActivityNotFoundException) {
|
} catch (_: android.content.ActivityNotFoundException) {
|
||||||
Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.")
|
Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.")
|
||||||
try {
|
try {
|
||||||
|
|
@ -1553,7 +1554,7 @@ private fun FolderSyncScreen(
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (syncedFolders.size < 3) {
|
if (syncedFolders.size < 10) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
text = { Text(stringResource(R.string.fab_add_folder)) },
|
text = { Text(stringResource(R.string.fab_add_folder)) },
|
||||||
icon = { Icon(Icons.Default.Add, "Add") },
|
icon = { Icon(Icons.Default.Add, "Add") },
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ data class ReaderScreenState(
|
||||||
val activeTabBookId: String? = null,
|
val activeTabBookId: String? = null,
|
||||||
val showExternalFileSavePromptFor: String? = null,
|
val showExternalFileSavePromptFor: String? = null,
|
||||||
val externalFileBehavior: String = "ASK",
|
val externalFileBehavior: String = "ASK",
|
||||||
|
val useStrictFileFilter: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
@ -352,7 +353,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
} catch(_: Exception) { emptyList() }
|
} catch(_: Exception) { emptyList() }
|
||||||
} ?: emptyList(),
|
} ?: emptyList(),
|
||||||
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null),
|
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 {
|
companion object {
|
||||||
private const val KEY_SORT_ORDER = "sort_order"
|
private const val KEY_SORT_ORDER = "sort_order"
|
||||||
internal const val KEY_SHELVES = "shelf_names"
|
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_SYNCED_FOLDER_URI = "synced_folder_uri"
|
||||||
internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
|
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 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_HOME = "pinned_home_books"
|
||||||
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
|
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
|
||||||
private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit"
|
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_OPEN_TAB_IDS = "open_tab_ids"
|
||||||
private const val KEY_ACTIVE_TAB = "active_tab_book_id"
|
private const val KEY_ACTIVE_TAB = "active_tab_book_id"
|
||||||
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
|
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 {
|
object LocalSyncUtils {
|
||||||
private const val TAG = "FolderSync"
|
private const val TAG = "FolderSync"
|
||||||
private const val ANNOTATION_SUFFIX = "_annotations"
|
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(
|
suspend fun saveMetadataToFolder(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -22,12 +69,13 @@ object LocalSyncUtils {
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||||
|
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
||||||
|
|
||||||
val syncFileName = ".${metadata.bookId}.json"
|
val syncFileName = ".${metadata.bookId}.json"
|
||||||
val legacyVisibleName = "${metadata.bookId}.json"
|
val legacyVisibleName = "${metadata.bookId}.json"
|
||||||
|
|
||||||
val existingHidden = rootTree.findFile(syncFileName)
|
val existingHidden = syncDir.findFile(syncFileName)
|
||||||
val existingVisible = rootTree.findFile(legacyVisibleName)
|
val existingVisible = syncDir.findFile(legacyVisibleName)
|
||||||
val fileToCheck = existingHidden ?: existingVisible
|
val fileToCheck = existingHidden ?: existingVisible
|
||||||
|
|
||||||
if (fileToCheck != null && fileToCheck.exists()) {
|
if (fileToCheck != null && fileToCheck.exists()) {
|
||||||
|
|
@ -50,9 +98,9 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
val tempFileName = ".${metadata.bookId}.tmp"
|
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) {
|
if (tempFile == null) {
|
||||||
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
|
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
|
||||||
return@withContext
|
return@withContext
|
||||||
|
|
@ -116,12 +164,13 @@ object LocalSyncUtils {
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
Timber.tag("FolderAnnotationSync").d("saveAnnotationSidecar called for bookId: $bookId, timestamp: $timestamp")
|
Timber.tag("FolderAnnotationSync").d("saveAnnotationSidecar called for bookId: $bookId, timestamp: $timestamp")
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: run {
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||||
Timber.tag("FolderAnnotationSync").w("Could not get DocumentFile from sourceFolderUri")
|
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
||||||
return@withContext
|
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
||||||
}
|
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
|
||||||
|
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
|
||||||
val currentBest = resolveAndCleanAnnotationConflicts(context, rootTree, bookId)
|
val tempFile = syncDir.createFile("application/json", tempName)
|
||||||
|
val existingMain = syncDir.findFile(targetName)
|
||||||
|
|
||||||
if (currentBest != null) {
|
if (currentBest != null) {
|
||||||
val (remoteTs, _) = currentBest
|
val (remoteTs, _) = currentBest
|
||||||
|
|
@ -137,12 +186,8 @@ object LocalSyncUtils {
|
||||||
wrapper.put("data", JSONObject(jsonPayload))
|
wrapper.put("data", JSONObject(jsonPayload))
|
||||||
val contentBytes = wrapper.toString().toByteArray()
|
val contentBytes = wrapper.toString().toByteArray()
|
||||||
|
|
||||||
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
|
syncDir.findFile(tempName)?.delete()
|
||||||
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
|
|
||||||
|
|
||||||
rootTree.findFile(tempName)?.delete()
|
|
||||||
|
|
||||||
val tempFile = rootTree.createFile("application/json", tempName)
|
|
||||||
if (tempFile == null) {
|
if (tempFile == null) {
|
||||||
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
|
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
|
||||||
return@withContext
|
return@withContext
|
||||||
|
|
@ -191,7 +236,9 @@ object LocalSyncUtils {
|
||||||
val results = mutableMapOf<String, Pair<Long, String>>()
|
val results = mutableMapOf<String, Pair<Long, String>>()
|
||||||
|
|
||||||
try {
|
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 annotationFiles = allFiles.filter { file ->
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
|
|
@ -256,8 +303,8 @@ object LocalSyncUtils {
|
||||||
): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null
|
||||||
|
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext null
|
||||||
val bestFile = resolveAndCleanAnnotationConflicts(context, rootTree, bookId)
|
val bestFile = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
||||||
return@withContext bestFile
|
return@withContext bestFile
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -268,12 +315,12 @@ object LocalSyncUtils {
|
||||||
|
|
||||||
private fun resolveAndCleanAnnotationConflicts(
|
private fun resolveAndCleanAnnotationConflicts(
|
||||||
context: Context,
|
context: Context,
|
||||||
rootTree: DocumentFile,
|
syncDir: DocumentFile,
|
||||||
bookId: String
|
bookId: String
|
||||||
): Pair<Long, String>? {
|
): Pair<Long, String>? {
|
||||||
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
||||||
|
|
||||||
val allFiles = rootTree.listFiles()
|
val allFiles = syncDir.listFiles()
|
||||||
|
|
||||||
val candidates = allFiles.filter { file ->
|
val candidates = allFiles.filter { file ->
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
|
|
@ -332,7 +379,7 @@ object LocalSyncUtils {
|
||||||
val correctName = "${basePattern}.json"
|
val correctName = "${basePattern}.json"
|
||||||
if (bestFile.name != correctName) {
|
if (bestFile.name != correctName) {
|
||||||
Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $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) {
|
if (existingTarget != null && existingTarget.uri != bestFile.uri) {
|
||||||
existingTarget.delete()
|
existingTarget.delete()
|
||||||
}
|
}
|
||||||
|
|
@ -431,8 +478,9 @@ object LocalSyncUtils {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
|
||||||
|
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
||||||
val allFiles = rootTree.listFiles()
|
if (syncDir == null || !syncDir.isDirectory) return@withContext finalResults
|
||||||
|
val allFiles = syncDir.listFiles()
|
||||||
|
|
||||||
val groupedFiles = allFiles
|
val groupedFiles = allFiles
|
||||||
.filter {
|
.filter {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ interface RecentFileDao {
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertOrUpdateFile(file: RecentFileEntity)
|
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")
|
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||||
fun getRecentFiles(): Flow<List<RecentFileEntity>>
|
fun getRecentFiles(): Flow<List<RecentFileEntity>>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -444,4 +444,54 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
return deleted
|
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,
|
onClick = onPlayPauseToggle,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
colors = IconButtonDefaults.filledIconButtonColors(
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -1212,8 +1212,8 @@ fun AutoScrollControls(
|
||||||
onClick = onPlayPauseToggle,
|
onClick = onPlayPauseToggle,
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
colors = IconButtonDefaults.filledIconButtonColors(
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -1225,7 +1225,7 @@ fun AutoScrollControls(
|
||||||
if (isTempPaused && isPlaying) {
|
if (isTempPaused && isPlaying) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||||
strokeWidth = 3.dp
|
strokeWidth = 3.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
<string name="action_apply">Apply</string>
|
<string name="action_apply">Apply</string>
|
||||||
<string name="tab_free">Free</string>
|
<string name="tab_free">Free</string>
|
||||||
<string name="action_save_catalog">Save</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 -->
|
<!-- 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>
|
<string name="legal_agreement_full">%1$s you agree to our %2$s and acknowledge you have read our %3$s.</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue