Quellcode durchsuchen

[优化] 优化本地书籍界面

HapeLee vor 7 Monaten
Ursprung
Commit
6a354acd29

+ 52 - 13
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.import.local
 
 import android.net.Uri
+import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.animateContentSize
@@ -21,12 +22,14 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.AddCircleOutline
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.ChevronRight
 import androidx.compose.material.icons.filled.CloudDownload
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Folder
 import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.outlined.AddCircleOutline
 import androidx.compose.material.icons.outlined.Book
 import androidx.compose.material.icons.outlined.Description
 import androidx.compose.material3.CircularProgressIndicator
@@ -58,7 +61,7 @@ import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppPattern
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
-import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.config.importBookConfig.ImportBookConfig
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.lib.permission.Permissions
@@ -69,11 +72,13 @@ import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.EmptyMessageView
 import io.legado.app.ui.widget.components.SelectionActions
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.SmallIconButton
 import io.legado.app.ui.widget.components.button.SmallTonalIconButton
 import io.legado.app.ui.widget.components.button.TopBarActionButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
+import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.list.ListScaffold
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
@@ -102,8 +107,10 @@ private fun ImportBookContent(
     onNavigateBack: () -> Unit,
     onNavigateToLevel: (Int) -> Unit,
     onSelectAll: () -> Unit,
+    onClearSelection: () -> Unit,
     onSelectInvert: () -> Unit,
     onAddToBookshelf: () -> Unit,
+    onItemAddToBookshelf: (ImportBook) -> Unit,
     onDeleteSelection: () -> Unit,
     onItemClick: (ImportBook) -> Unit
 ) {
@@ -182,6 +189,7 @@ private fun ImportBookContent(
             )
         },
         selectionActions = SelectionActions(
+            onClearSelection = onClearSelection,
             onSelectAll = onSelectAll,
             onSelectInvert = onSelectInvert,
             primaryAction = ActionItem(
@@ -227,7 +235,8 @@ private fun ImportBookContent(
                                 modifier = Modifier.animateItem(),
                                 item = item,
                                 isSelected = item.selectionId in state.selectedIds,
-                                onClick = { onItemClick(item) }
+                                onClick = { onItemClick(item) },
+                                onAddToBookshelf = { onItemAddToBookshelf(item) }
                             )
                         }
                     }
@@ -246,13 +255,25 @@ fun ImportBookScreen(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     var showFolderPicker by remember { mutableStateOf(false) }
     var showImportFileNameDialog by remember { mutableStateOf(false) }
-    var fileNameJs by remember { mutableStateOf(AppConfig.bookImportFileName.orEmpty()) }
+    var pendingSingleAddBook by remember { mutableStateOf<ImportBook?>(null) }
+    var fileNameJs by remember { mutableStateOf(ImportBookConfig.bookImportFileName.orEmpty()) }
     var pickerTarget by remember { mutableStateOf(ImportFolderPickTarget.IMPORT_FOLDER) }
     val selectDocTree = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocumentTree()
     ) { uri ->
         viewModel.dispatch(ImportBookIntent.FolderPicked(uri, pickerTarget))
     }
+    val handleBack = {
+        when {
+            uiState.selectedIds.isNotEmpty() -> viewModel.clearSelection()
+            uiState.canGoBack -> viewModel.dispatch(ImportBookIntent.NavigateBack)
+            else -> onBackClick()
+        }
+    }
+
+    BackHandler(enabled = !showFolderPicker && !showImportFileNameDialog) {
+        handleBack()
+    }
 
     LaunchedEffect(viewModel) {
         viewModel.dispatch(ImportBookIntent.Initialize)
@@ -320,30 +341,48 @@ fun ImportBookScreen(
         },
         confirmText = stringResource(android.R.string.ok),
         onConfirm = {
-            AppConfig.bookImportFileName = fileNameJs
+            ImportBookConfig.bookImportFileName = fileNameJs
             showImportFileNameDialog = false
         },
         dismissText = stringResource(android.R.string.cancel),
         onDismiss = { showImportFileNameDialog = false }
     )
 
+    pendingSingleAddBook?.let { book ->
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { pendingSingleAddBook = null },
+            title = stringResource(R.string.add_to_bookshelf),
+            text = stringResource(R.string.check_add_bookshelf, book.name),
+            confirmText = stringResource(android.R.string.ok),
+            onConfirm = {
+                viewModel.dispatch(ImportBookIntent.AddSingleToBookshelf(book))
+                pendingSingleAddBook = null
+            },
+            dismissText = stringResource(android.R.string.cancel),
+            onDismiss = { pendingSingleAddBook = null }
+        )
+    }
+
     ImportBookContent(
         state = uiState,
-        onBackClick = onBackClick,
+        onBackClick = handleBack,
         onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
         onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
         onSelectFolder = { showFolderPicker = true },
         onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) },
         onImportFileName = {
-            fileNameJs = AppConfig.bookImportFileName.orEmpty()
+            fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
             showImportFileNameDialog = true
         },
         onSortChange = { viewModel.dispatch(ImportBookIntent.SortChange(it)) },
         onNavigateBack = { viewModel.dispatch(ImportBookIntent.NavigateBack) },
         onNavigateToLevel = { viewModel.dispatch(ImportBookIntent.NavigateToLevel(it)) },
         onSelectAll = { viewModel.dispatch(ImportBookIntent.SelectAll) },
+        onClearSelection = { viewModel.clearSelection() },
         onSelectInvert = { viewModel.dispatch(ImportBookIntent.SelectInvert) },
         onAddToBookshelf = { viewModel.dispatch(ImportBookIntent.AddToBookshelf) },
+        onItemAddToBookshelf = { pendingSingleAddBook = it },
         onDeleteSelection = { viewModel.dispatch(ImportBookIntent.DeleteSelection) },
         onItemClick = { viewModel.dispatch(ImportBookIntent.ItemClick(it)) }
     )
@@ -417,7 +456,8 @@ private fun ImportBookItem(
     modifier: Modifier,
     item: ImportBook,
     isSelected: Boolean,
-    onClick: () -> Unit
+    onClick: () -> Unit,
+    onAddToBookshelf: () -> Unit
 ) {
     val containerColor = if (isSelected) {
         LegadoTheme.colorScheme.secondaryContainer
@@ -438,7 +478,7 @@ private fun ImportBookItem(
                 .padding(horizontal = 16.dp, vertical = 12.dp),
             verticalAlignment = Alignment.CenterVertically
         ) {
-            Icon(
+            AppIcon(
                 imageVector = when {
                     item.isDir -> Icons.Default.Folder
                     item.isOnBookShelf -> Icons.Outlined.Book
@@ -446,7 +486,7 @@ private fun ImportBookItem(
                 },
                 contentDescription = null,
                 modifier = Modifier.size(20.dp),
-                tint = if (item.isDir) {
+                tint = if (item.isOnBookShelf) {
                     LegadoTheme.colorScheme.primary
                 } else {
                     LegadoTheme.colorScheme.onSurfaceVariant
@@ -486,10 +526,9 @@ private fun ImportBookItem(
 
             if (!item.isDir && !item.isOnBookShelf) {
                 Spacer(modifier = Modifier.width(8.dp))
-                Icon(
-                    imageVector = if (isSelected) Icons.Default.Check else Icons.Default.ChevronRight,
-                    contentDescription = null,
-                    tint = LegadoTheme.colorScheme.primary
+                SmallIconButton(
+                    onClick = onAddToBookshelf,
+                    imageVector = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
                 )
             }
         }

+ 243 - 62
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt

@@ -2,6 +2,8 @@ package io.legado.app.ui.book.import.local
 
 import android.app.Application
 import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.getTreeDocumentId
 import androidx.core.net.toUri
 import androidx.documentfile.provider.DocumentFile
 import androidx.compose.runtime.Immutable
@@ -11,29 +13,28 @@ import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern.archiveFileRegex
 import io.legado.app.constant.AppPattern.bookFileRegex
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
-import io.legado.app.help.config.AppConfig
 import io.legado.app.model.localBook.LocalBook
+import io.legado.app.ui.config.importBookConfig.ImportBookConfig
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.utils.AlphanumComparator
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.delete
-import io.legado.app.utils.getPrefInt
+import io.legado.app.utils.exists
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.isUri
 import io.legado.app.utils.list
 import io.legado.app.utils.mapParallel
-import io.legado.app.utils.putPrefInt
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -49,6 +50,7 @@ import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.io.File
@@ -85,6 +87,7 @@ sealed interface ImportBookIntent {
     data object SelectAll : ImportBookIntent
     data object SelectInvert : ImportBookIntent
     data object AddToBookshelf : ImportBookIntent
+    data class AddSingleToBookshelf(val item: ImportBook) : ImportBookIntent
     data object DeleteSelection : ImportBookIntent
     data class ItemClick(val item: ImportBook) : ImportBookIntent
     data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent
@@ -105,6 +108,11 @@ sealed interface ImportBookEffect {
 
 class ImportBookViewModel(application: Application) : BaseViewModel(application) {
 
+    private enum class SourceMode {
+        CURRENT_DIR,
+        SCAN_RECURSIVE
+    }
+
     private data class InternalState(
         val rootDoc: FileDoc? = null,
         val subDocs: List<FileDoc> = emptyList(),
@@ -112,16 +120,18 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         val selectedIds: Set<String> = emptySet(),
         val searchKey: String = "",
         val sort: Int,
+        val sourceMode: SourceMode = SourceMode.CURRENT_DIR,
         val interaction: InteractionState = InteractionState()
     )
 
     private val _state = MutableStateFlow(
-        InternalState(sort = context.getPrefInt(PreferKey.localBookImportSort))
+        InternalState(sort = ImportBookConfig.localBookImportSort)
     )
     private val _effects = MutableSharedFlow<ImportBookEffect>(extraBufferCapacity = 1)
     val effects = _effects.asSharedFlow()
 
     private var scanDocJob: Job? = null
+    private var autoSyncJob: Job? = null
 
     fun dispatch(intent: ImportBookIntent) {
         when (intent) {
@@ -130,7 +140,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 _effects.tryEmit(
                     ImportBookEffect.RequestFolderPicker(
                         target = ImportFolderPickTarget.IMPORT_FOLDER,
-                        initialUri = AppConfig.importBookPath
+                        initialUri = ImportBookConfig.importBookPath
                             ?.takeIf { it.isUri() }
                             ?.toUri()
                     )
@@ -147,6 +157,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             ImportBookIntent.SelectAll -> selectAllCheckable()
             ImportBookIntent.SelectInvert -> invertSelection()
             ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf()
+            is ImportBookIntent.AddSingleToBookshelf -> addSingleToBookshelf(intent.item)
             ImportBookIntent.DeleteSelection -> deleteSelectedDocs()
             is ImportBookIntent.ItemClick -> onItemClick(intent.item)
             is ImportBookIntent.ArchiveEntrySelected -> onArchiveEntrySelected(
@@ -220,34 +231,66 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
     fun hasRootDoc(): Boolean = _state.value.rootDoc != null
 
     private fun initialize() {
-        if (AppConfig.defaultBookTreeUri.isNullOrBlank()) {
+        val defaultPath = ImportBookConfig.defaultBookTreeUri?.takeIf { it.isUri() }
+        val effectiveDefaultPath = defaultPath ?: firstPersistedTreeUri()?.toString()
+        if (effectiveDefaultPath.isNullOrBlank()) {
             _effects.tryEmit(
                 ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
             )
             return
         }
-        if (AppConfig.importBookPath.isNullOrBlank()) {
-            AppConfig.importBookPath = AppConfig.defaultBookTreeUri
+        if (ImportBookConfig.defaultBookTreeUri != effectiveDefaultPath) {
+            ImportBookConfig.defaultBookTreeUri = effectiveDefaultPath
+        }
+        val importPath = ImportBookConfig.importBookPath
+        if (importPath.isNullOrBlank() || !importPath.isUri()) {
+            ImportBookConfig.importBookPath = effectiveDefaultPath
         }
-        initRootDoc(changedFolder = false)
+        initRootDoc(changedFolder = true)
+        startAutoSync()
     }
 
     private fun onFolderPicked(uri: Uri?, target: ImportFolderPickTarget) {
         uri ?: return
-        uri.takePersistablePermissionSafely(context)
+        val pickedUri = persistFolderPermission(uri)
         when (target) {
             ImportFolderPickTarget.DEFAULT_BOOK -> {
-                AppConfig.defaultBookTreeUri = uri.toString()
-                if (AppConfig.importBookPath.isNullOrBlank()) {
-                    AppConfig.importBookPath = AppConfig.defaultBookTreeUri
+                ImportBookConfig.defaultBookTreeUri = pickedUri.toString()
+                if (ImportBookConfig.importBookPath.isNullOrBlank()) {
+                    ImportBookConfig.importBookPath = ImportBookConfig.defaultBookTreeUri
                 }
             }
 
             ImportFolderPickTarget.IMPORT_FOLDER -> {
-                AppConfig.importBookPath = uri.toString()
+                ImportBookConfig.importBookPath = pickedUri.toString()
+                if (ImportBookConfig.defaultBookTreeUri.isNullOrBlank()) {
+                    ImportBookConfig.defaultBookTreeUri = pickedUri.toString()
+                }
             }
         }
         initRootDoc(changedFolder = true)
+        startAutoSync()
+    }
+
+    private fun persistFolderPermission(uri: Uri): Uri {
+        val normalizedUri = normalizeFolderUri(uri)
+        uri.takePersistablePermissionSafely(context)
+        if (normalizedUri != uri) {
+            normalizedUri.takePersistablePermissionSafely(context)
+        }
+        return normalizedUri
+    }
+
+    private fun normalizeFolderUri(uri: Uri): Uri {
+        if (!uri.isContentScheme()) return uri
+        return runCatching {
+            DocumentsContract.buildTreeDocumentUri(uri.authority, getTreeDocumentId(uri))
+        }.recoverCatching {
+            DocumentsContract.buildTreeDocumentUri(
+                uri.authority,
+                DocumentsContract.getDocumentId(uri)
+            )
+        }.getOrDefault(uri)
     }
 
     private fun initRootDoc(changedFolder: Boolean) {
@@ -255,48 +298,111 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             reloadCurrentDoc()
             return
         }
-        val lastPath = AppConfig.importBookPath
-        if (lastPath.isNullOrBlank()) {
+        val candidates = linkedSetOf<String>().apply {
+            ImportBookConfig.importBookPath
+                ?.takeIf { it.isNotBlank() && it.isUri() }
+                ?.let(::add)
+            ImportBookConfig.defaultBookTreeUri
+                ?.takeIf { it.isNotBlank() && it.isUri() }
+                ?.let(::add)
+        }
+        if (candidates.isEmpty()) {
+            firstPersistedTreeUri()?.toString()?.let { persistedPath ->
+                if (trySetRootDoc(persistedPath)) {
+                    ImportBookConfig.defaultBookTreeUri = persistedPath
+                    ImportBookConfig.importBookPath = persistedPath
+                    return
+                }
+            }
             _effects.tryEmit(
-                ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
+                ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
             )
             return
         }
 
-        val rootUri = if (lastPath.isUri()) {
-            lastPath.toUri()
-        } else {
-            Uri.fromFile(File(lastPath))
+        for (path in candidates) {
+            if (trySetRootDoc(path)) {
+                if (ImportBookConfig.importBookPath != path) {
+                    ImportBookConfig.importBookPath = path
+                }
+                return
+            }
+            val persistedUri = path.takeIf { it.isUri() }
+                ?.toUri()
+                ?.let(::findPersistedTreeUri)
+            if (persistedUri != null) {
+                val persistedPath = persistedUri.toString()
+                if (trySetRootDoc(persistedPath)) {
+                    ImportBookConfig.importBookPath = persistedPath
+                    return
+                }
+            }
         }
 
-        when {
-            rootUri.isContentScheme() -> {
-                kotlin.runCatching {
-                    val doc = DocumentFile.fromTreeUri(context, rootUri)
-                    if (doc == null || doc.name.isNullOrEmpty()) {
-                        _effects.tryEmit(
-                            ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
-                        )
-                    } else {
-                        setRootDoc(FileDoc.fromDocumentFile(doc))
-                    }
-                }.onFailure {
-                    _effects.tryEmit(
-                        ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
-                    )
-                }
+        firstPersistedTreeUri()?.toString()?.let { persistedPath ->
+            if (trySetRootDoc(persistedPath)) {
+                ImportBookConfig.defaultBookTreeUri = persistedPath
+                ImportBookConfig.importBookPath = persistedPath
+                return
             }
+        }
 
-            else -> {
-                kotlin.runCatching {
-                    setRootDoc(FileDoc.fromFile(File(rootUri.path!!)))
-                }.onFailure {
-                    _effects.tryEmit(
-                        ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
+        _effects.tryEmit(
+            ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
+        )
+    }
+
+    private fun trySetRootDoc(path: String): Boolean {
+        val rootUri = if (path.isUri()) path.toUri() else Uri.fromFile(File(path))
+        return kotlin.runCatching {
+            if (rootUri.isContentScheme()) {
+                val doc = DocumentFile.fromTreeUri(context, rootUri)
+                    ?: DocumentFile.fromSingleUri(context, rootUri)
+                    ?: return false
+                val rootName = doc.name
+                    ?.takeIf { it.isNotBlank() }
+                    ?: runCatching {
+                        getTreeDocumentId(rootUri).substringAfter(':').ifBlank { "root" }
+                    }.getOrElse { "root" }
+                setRootDoc(
+                    FileDoc(
+                        name = rootName,
+                        isDir = true,
+                        size = doc.length(),
+                        lastModified = doc.lastModified(),
+                        uri = doc.uri
                     )
-                }
+                )
+            } else {
+                val file = File(rootUri.path ?: return false)
+                if (!file.exists() || !file.isDirectory) return false
+                setRootDoc(FileDoc.fromFile(file))
             }
-        }
+            true
+        }.getOrDefault(false)
+    }
+
+    private fun findPersistedTreeUri(targetUri: Uri): Uri? {
+        if (!targetUri.isContentScheme()) return null
+        val permissions = context.contentResolver.persistedUriPermissions
+        if (permissions.isEmpty()) return null
+        val targetAuthority = targetUri.authority
+        val targetTreeId = runCatching { getTreeDocumentId(targetUri) }.getOrNull()
+
+        return permissions.firstOrNull { permission ->
+            permission.isReadPermission
+                    && permission.uri.authority == targetAuthority
+                    && runCatching { getTreeDocumentId(permission.uri) }.getOrNull() == targetTreeId
+        }?.uri ?: permissions.firstOrNull { permission ->
+            permission.isReadPermission && permission.uri.authority == targetAuthority
+        }?.uri
+    }
+
+    private fun firstPersistedTreeUri(): Uri? {
+        return context.contentResolver.persistedUriPermissions
+            .firstOrNull { it.isReadPermission && it.uri.isContentScheme() }
+            ?.uri
+            ?.let(::normalizeFolderUri)
     }
 
     fun clearRoot() {
@@ -305,7 +411,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 rootDoc = null,
                 subDocs = emptyList(),
                 sourceDocs = emptyList(),
-                selectedIds = emptySet()
+                selectedIds = emptySet(),
+                sourceMode = SourceMode.CURRENT_DIR
             )
         }
     }
@@ -315,7 +422,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             it.copy(
                 rootDoc = rootDoc,
                 subDocs = emptyList(),
-                selectedIds = emptySet()
+                selectedIds = emptySet(),
+                sourceMode = SourceMode.CURRENT_DIR
             )
         }
         loadCurrentDoc()
@@ -369,7 +477,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
 
     fun setSort(sort: Int) {
         _state.update { it.copy(sort = sort) }
-        context.putPrefInt(PreferKey.localBookImportSort, sort)
+        ImportBookConfig.localBookImportSort = sort
     }
 
     fun setSearchMode(isSearch: Boolean) {
@@ -439,6 +547,22 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         }
     }
 
+    private fun addSingleToBookshelf(item: ImportBook) {
+        if (item.isDir || item.isOnBookShelf) return
+        execute {
+            LocalBook.importFiles(listOf(item.file.uri))
+        }.onError {
+            context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
+            AppLog.put("添加书架失败\n${it.localizedMessage}", it)
+        }.onSuccess {
+            context.toastOnUi("添加书架成功")
+        }.onFinally {
+            _state.update { state ->
+                state.copy(selectedIds = state.selectedIds - item.selectionId)
+            }
+        }
+    }
+
     private fun onItemClick(item: ImportBook) {
         when {
             item.isDir -> navigateNext(item.file)
@@ -506,12 +630,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 .filter { it.toString() in selectedIds }
                 .forEach { it.delete() }
         }.onFinally {
-            _state.update { state ->
-                state.copy(
-                    sourceDocs = state.sourceDocs.filterNot { it.toString() in selectedIds },
-                    selectedIds = emptySet()
-                )
-            }
+            clearSelection()
+            refreshCurrentSource()
         }
     }
 
@@ -523,6 +643,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             it.copy(
                 sourceDocs = emptyList(),
                 selectedIds = emptySet(),
+                sourceMode = SourceMode.SCAN_RECURSIVE,
                 interaction = it.interaction.copy(isLoading = true)
             )
         }
@@ -534,6 +655,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 _state.update {
                     it.copy(
                         sourceDocs = docs,
+                        selectedIds = emptySet(),
                         interaction = it.interaction.copy(isLoading = false)
                     )
                 }
@@ -562,22 +684,18 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             it.copy(
                 sourceDocs = emptyList(),
                 selectedIds = emptySet(),
+                sourceMode = SourceMode.CURRENT_DIR,
                 interaction = it.interaction.copy(isLoading = true)
             )
         }
 
         execute {
-            fileDoc.list { item ->
-                when {
-                    item.name.startsWith(".") -> false
-                    item.isDir -> true
-                    else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
-                }
-            } ?: emptyList()
+            listCurrentDocDocs(fileDoc)
         }.onSuccess { docs ->
             _state.update {
                 it.copy(
                     sourceDocs = docs,
+                    selectedIds = emptySet(),
                     interaction = it.interaction.copy(isLoading = false)
                 )
             }
@@ -589,6 +707,68 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         }
     }
 
+    private fun listCurrentDocDocs(fileDoc: FileDoc): List<FileDoc> {
+        return fileDoc.list { item ->
+            when {
+                item.name.startsWith(".") -> false
+                item.isDir -> true
+                else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
+            }
+        } ?: emptyList()
+    }
+
+    private fun refreshCurrentSource() {
+        when (_state.value.sourceMode) {
+            SourceMode.CURRENT_DIR -> loadCurrentDoc()
+            SourceMode.SCAN_RECURSIVE -> scanCurrentDoc()
+        }
+    }
+
+    private fun startAutoSync() {
+        if (autoSyncJob?.isActive == true) return
+        autoSyncJob = viewModelScope.launch(IO) {
+            while (isActive) {
+                delay(1500)
+                syncSourceDocs()
+            }
+        }
+    }
+
+    private suspend fun syncSourceDocs() {
+        if (scanDocJob?.isActive == true) return
+        val snapshot = _state.value
+        if (snapshot.interaction.isLoading || snapshot.sourceDocs.isEmpty()) return
+
+        val current = currentDoc(snapshot) ?: return
+        when (snapshot.sourceMode) {
+            SourceMode.CURRENT_DIR -> {
+                val latestDocs = kotlin.runCatching { listCurrentDocDocs(current) }.getOrNull() ?: return
+                val latestIds = latestDocs.asSequence().map { it.toString() }.toSet()
+                val sourceIds = snapshot.sourceDocs.asSequence().map { it.toString() }.toSet()
+                if (latestIds == sourceIds) return
+
+                _state.update { state ->
+                    state.copy(
+                        sourceDocs = latestDocs,
+                        selectedIds = state.selectedIds.filterTo(hashSetOf()) { it in latestIds }
+                    )
+                }
+            }
+
+            SourceMode.SCAN_RECURSIVE -> {
+                if (snapshot.sourceDocs.none { !it.exists() }) return
+                val latestDocs = kotlin.runCatching { scanDoc(current) }.getOrNull() ?: return
+                val latestIds = latestDocs.asSequence().map { it.toString() }.toSet()
+                _state.update { state ->
+                    state.copy(
+                        sourceDocs = latestDocs,
+                        selectedIds = state.selectedIds.filterTo(hashSetOf()) { it in latestIds }
+                    )
+                }
+            }
+        }
+    }
+
     private suspend fun scanDoc(fileDoc: FileDoc): List<FileDoc> {
         val channel = Channel<FileDoc>(UNLIMITED)
         var n = 1
@@ -617,6 +797,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
     }
 
     override fun onCleared() {
+        autoSyncJob?.cancel()
         scanDocJob?.cancel()
         super.onCleared()
     }

+ 27 - 0
app/src/main/java/io/legado/app/ui/config/importBookConfig/ImportBookConfig.kt

@@ -0,0 +1,27 @@
+package io.legado.app.ui.config.importBookConfig
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.prefDelegate
+
+object ImportBookConfig {
+
+    var defaultBookTreeUri by prefDelegate(
+        PreferKey.defaultBookTreeUri,
+        null as String?
+    )
+
+    var importBookPath by prefDelegate(
+        PreferKey.importBookPath,
+        null as String?
+    )
+
+    var bookImportFileName by prefDelegate(
+        PreferKey.bookImportFileName,
+        null as String?
+    )
+
+    var localBookImportSort by prefDelegate(
+        PreferKey.localBookImportSort,
+        0
+    )
+}