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

This commit is contained in:
HapeLee committed 2026-04-14 00:50:43 +08:00
1 parent 591d8dd542
commit 6a354acd29
3 files changed
+323 -76

No files matched your search

@@ -1,6 +1,7 @@
package io.legado.app.ui.book.import.local package io.legado.app.ui.book.import.local
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
@@ -21,12 +22,14 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Check
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderOpen 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.Book
import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.CircularProgressIndicator 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.constant.AppPattern
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book 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.alert
import io.legado.app.lib.dialogs.selector import io.legado.app.lib.dialogs.selector
import io.legado.app.lib.permission.Permissions 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.EmptyMessageView
import io.legado.app.ui.widget.components.SelectionActions import io.legado.app.ui.widget.components.SelectionActions
import io.legado.app.ui.widget.components.alert.AppAlertDialog 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.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.TopBarActionButton 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.GlassCard
import io.legado.app.ui.widget.components.card.TextCard 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.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.list.ListScaffold
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
@@ -102,8 +107,10 @@ private fun ImportBookContent(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToLevel: (Int) -> Unit, onNavigateToLevel: (Int) -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onClearSelection: () -> Unit,
onSelectInvert: () -> Unit, onSelectInvert: () -> Unit,
onAddToBookshelf: () -> Unit, onAddToBookshelf: () -> Unit,
onItemAddToBookshelf: (ImportBook) -> Unit,
onDeleteSelection: () -> Unit, onDeleteSelection: () -> Unit,
onItemClick: (ImportBook) -> Unit onItemClick: (ImportBook) -> Unit
) { ) {
@@ -182,6 +189,7 @@ private fun ImportBookContent(
) )
}, },
selectionActions = SelectionActions( selectionActions = SelectionActions(
onClearSelection = onClearSelection,
onSelectAll = onSelectAll, onSelectAll = onSelectAll,
onSelectInvert = onSelectInvert, onSelectInvert = onSelectInvert,
primaryAction = ActionItem( primaryAction = ActionItem(
@@ -227,7 +235,8 @@ private fun ImportBookContent(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
item = item, item = item,
isSelected = item.selectionId in state.selectedIds, 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() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showFolderPicker by remember { mutableStateOf(false) } var showFolderPicker by remember { mutableStateOf(false) }
var showImportFileNameDialog 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) } var pickerTarget by remember { mutableStateOf(ImportFolderPickTarget.IMPORT_FOLDER) }
val selectDocTree = rememberLauncherForActivityResult( val selectDocTree = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree() contract = ActivityResultContracts.OpenDocumentTree()
) { uri -> ) { uri ->
viewModel.dispatch(ImportBookIntent.FolderPicked(uri, pickerTarget)) 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) { LaunchedEffect(viewModel) {
viewModel.dispatch(ImportBookIntent.Initialize) viewModel.dispatch(ImportBookIntent.Initialize)
@@ -320,30 +341,48 @@ fun ImportBookScreen(
}, },
confirmText = stringResource(android.R.string.ok), confirmText = stringResource(android.R.string.ok),
onConfirm = { onConfirm = {
AppConfig.bookImportFileName = fileNameJs ImportBookConfig.bookImportFileName = fileNameJs
showImportFileNameDialog = false showImportFileNameDialog = false
}, },
dismissText = stringResource(android.R.string.cancel), dismissText = stringResource(android.R.string.cancel),
onDismiss = { showImportFileNameDialog = false } 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( ImportBookContent(
state = uiState, state = uiState,
onBackClick = onBackClick, onBackClick = handleBack,
onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) }, onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) }, onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
onSelectFolder = { showFolderPicker = true }, onSelectFolder = { showFolderPicker = true },
onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) }, onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) },
onImportFileName = { onImportFileName = {
fileNameJs = AppConfig.bookImportFileName.orEmpty() fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
showImportFileNameDialog = true showImportFileNameDialog = true
}, },
onSortChange = { viewModel.dispatch(ImportBookIntent.SortChange(it)) }, onSortChange = { viewModel.dispatch(ImportBookIntent.SortChange(it)) },
onNavigateBack = { viewModel.dispatch(ImportBookIntent.NavigateBack) }, onNavigateBack = { viewModel.dispatch(ImportBookIntent.NavigateBack) },
onNavigateToLevel = { viewModel.dispatch(ImportBookIntent.NavigateToLevel(it)) }, onNavigateToLevel = { viewModel.dispatch(ImportBookIntent.NavigateToLevel(it)) },
onSelectAll = { viewModel.dispatch(ImportBookIntent.SelectAll) }, onSelectAll = { viewModel.dispatch(ImportBookIntent.SelectAll) },
onClearSelection = { viewModel.clearSelection() },
onSelectInvert = { viewModel.dispatch(ImportBookIntent.SelectInvert) }, onSelectInvert = { viewModel.dispatch(ImportBookIntent.SelectInvert) },
onAddToBookshelf = { viewModel.dispatch(ImportBookIntent.AddToBookshelf) }, onAddToBookshelf = { viewModel.dispatch(ImportBookIntent.AddToBookshelf) },
onItemAddToBookshelf = { pendingSingleAddBook = it },
onDeleteSelection = { viewModel.dispatch(ImportBookIntent.DeleteSelection) }, onDeleteSelection = { viewModel.dispatch(ImportBookIntent.DeleteSelection) },
onItemClick = { viewModel.dispatch(ImportBookIntent.ItemClick(it)) } onItemClick = { viewModel.dispatch(ImportBookIntent.ItemClick(it)) }
) )
@@ -417,7 +456,8 @@ private fun ImportBookItem(
modifier: Modifier, modifier: Modifier,
item: ImportBook, item: ImportBook,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit onClick: () -> Unit,
onAddToBookshelf: () -> Unit
) { ) {
val containerColor = if (isSelected) { val containerColor = if (isSelected) {
LegadoTheme.colorScheme.secondaryContainer LegadoTheme.colorScheme.secondaryContainer
@@ -438,7 +478,7 @@ private fun ImportBookItem(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( AppIcon(
imageVector = when { imageVector = when {
item.isDir -> Icons.Default.Folder item.isDir -> Icons.Default.Folder
item.isOnBookShelf -> Icons.Outlined.Book item.isOnBookShelf -> Icons.Outlined.Book
@@ -446,7 +486,7 @@ private fun ImportBookItem(
}, },
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = if (item.isDir) { tint = if (item.isOnBookShelf) {
LegadoTheme.colorScheme.primary LegadoTheme.colorScheme.primary
} else { } else {
LegadoTheme.colorScheme.onSurfaceVariant LegadoTheme.colorScheme.onSurfaceVariant
@@ -486,10 +526,9 @@ private fun ImportBookItem(
if (!item.isDir && !item.isOnBookShelf) { if (!item.isDir && !item.isOnBookShelf) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Icon( SmallIconButton(
imageVector = if (isSelected) Icons.Default.Check else Icons.Default.ChevronRight, onClick = onAddToBookshelf,
contentDescription = null, imageVector = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
tint = LegadoTheme.colorScheme.primary
) )
} }
} }
@@ -2,6 +2,8 @@ package io.legado.app.ui.book.import.local
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract
import android.provider.DocumentsContract.getTreeDocumentId
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.compose.runtime.Immutable 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
import io.legado.app.constant.AppPattern.archiveFileRegex import io.legado.app.constant.AppPattern.archiveFileRegex
import io.legado.app.constant.AppPattern.bookFileRegex import io.legado.app.constant.AppPattern.bookFileRegex
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book 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.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.InteractionState
import io.legado.app.ui.widget.components.list.ListUiState import io.legado.app.ui.widget.components.list.ListUiState
import io.legado.app.utils.AlphanumComparator import io.legado.app.utils.AlphanumComparator
import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.FileDoc import io.legado.app.utils.FileDoc
import io.legado.app.utils.delete 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.isContentScheme
import io.legado.app.utils.isUri import io.legado.app.utils.isUri
import io.legado.app.utils.list import io.legado.app.utils.list
import io.legado.app.utils.mapParallel import io.legado.app.utils.mapParallel
import io.legado.app.utils.putPrefInt
import io.legado.app.utils.takePersistablePermissionSafely import io.legado.app.utils.takePersistablePermissionSafely
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -49,6 +50,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -85,6 +87,7 @@ sealed interface ImportBookIntent {
data object SelectAll : ImportBookIntent data object SelectAll : ImportBookIntent
data object SelectInvert : ImportBookIntent data object SelectInvert : ImportBookIntent
data object AddToBookshelf : ImportBookIntent data object AddToBookshelf : ImportBookIntent
data class AddSingleToBookshelf(val item: ImportBook) : ImportBookIntent
data object DeleteSelection : ImportBookIntent data object DeleteSelection : ImportBookIntent
data class ItemClick(val item: ImportBook) : ImportBookIntent data class ItemClick(val item: ImportBook) : ImportBookIntent
data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent
@@ -105,6 +108,11 @@ sealed interface ImportBookEffect {
class ImportBookViewModel(application: Application) : BaseViewModel(application) { class ImportBookViewModel(application: Application) : BaseViewModel(application) {
private enum class SourceMode {
CURRENT_DIR,
SCAN_RECURSIVE
}
private data class InternalState( private data class InternalState(
val rootDoc: FileDoc? = null, val rootDoc: FileDoc? = null,
val subDocs: List<FileDoc> = emptyList(), val subDocs: List<FileDoc> = emptyList(),
@@ -112,16 +120,18 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
val selectedIds: Set<String> = emptySet(), val selectedIds: Set<String> = emptySet(),
val searchKey: String = "", val searchKey: String = "",
val sort: Int, val sort: Int,
val sourceMode: SourceMode = SourceMode.CURRENT_DIR,
val interaction: InteractionState = InteractionState() val interaction: InteractionState = InteractionState()
) )
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
InternalState(sort = context.getPrefInt(PreferKey.localBookImportSort)) InternalState(sort = ImportBookConfig.localBookImportSort)
) )
private val _effects = MutableSharedFlow<ImportBookEffect>(extraBufferCapacity = 1) private val _effects = MutableSharedFlow<ImportBookEffect>(extraBufferCapacity = 1)
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
private var scanDocJob: Job? = null private var scanDocJob: Job? = null
private var autoSyncJob: Job? = null
fun dispatch(intent: ImportBookIntent) { fun dispatch(intent: ImportBookIntent) {
when (intent) { when (intent) {
@@ -130,7 +140,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
_effects.tryEmit( _effects.tryEmit(
ImportBookEffect.RequestFolderPicker( ImportBookEffect.RequestFolderPicker(
target = ImportFolderPickTarget.IMPORT_FOLDER, target = ImportFolderPickTarget.IMPORT_FOLDER,
initialUri = AppConfig.importBookPath initialUri = ImportBookConfig.importBookPath
?.takeIf { it.isUri() } ?.takeIf { it.isUri() }
?.toUri() ?.toUri()
) )
@@ -147,6 +157,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
ImportBookIntent.SelectAll -> selectAllCheckable() ImportBookIntent.SelectAll -> selectAllCheckable()
ImportBookIntent.SelectInvert -> invertSelection() ImportBookIntent.SelectInvert -> invertSelection()
ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf() ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf()
is ImportBookIntent.AddSingleToBookshelf -> addSingleToBookshelf(intent.item)
ImportBookIntent.DeleteSelection -> deleteSelectedDocs() ImportBookIntent.DeleteSelection -> deleteSelectedDocs()
is ImportBookIntent.ItemClick -> onItemClick(intent.item) is ImportBookIntent.ItemClick -> onItemClick(intent.item)
is ImportBookIntent.ArchiveEntrySelected -> onArchiveEntrySelected( is ImportBookIntent.ArchiveEntrySelected -> onArchiveEntrySelected(
@@ -220,34 +231,66 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
fun hasRootDoc(): Boolean = _state.value.rootDoc != null fun hasRootDoc(): Boolean = _state.value.rootDoc != null
private fun initialize() { private fun initialize() {
if (AppConfig.defaultBookTreeUri.isNullOrBlank()) { val defaultPath = ImportBookConfig.defaultBookTreeUri?.takeIf { it.isUri() }
val effectiveDefaultPath = defaultPath ?: firstPersistedTreeUri()?.toString()
if (effectiveDefaultPath.isNullOrBlank()) {
_effects.tryEmit( _effects.tryEmit(
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK) ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
) )
return return
} }
if (AppConfig.importBookPath.isNullOrBlank()) { if (ImportBookConfig.defaultBookTreeUri != effectiveDefaultPath) {
AppConfig.importBookPath = AppConfig.defaultBookTreeUri ImportBookConfig.defaultBookTreeUri = effectiveDefaultPath
} }
initRootDoc(changedFolder = false) val importPath = ImportBookConfig.importBookPath
if (importPath.isNullOrBlank() || !importPath.isUri()) {
ImportBookConfig.importBookPath = effectiveDefaultPath
}
initRootDoc(changedFolder = true)
startAutoSync()
} }
private fun onFolderPicked(uri: Uri?, target: ImportFolderPickTarget) { private fun onFolderPicked(uri: Uri?, target: ImportFolderPickTarget) {
uri ?: return uri ?: return
uri.takePersistablePermissionSafely(context) val pickedUri = persistFolderPermission(uri)
when (target) { when (target) {
ImportFolderPickTarget.DEFAULT_BOOK -> { ImportFolderPickTarget.DEFAULT_BOOK -> {
AppConfig.defaultBookTreeUri = uri.toString() ImportBookConfig.defaultBookTreeUri = pickedUri.toString()
if (AppConfig.importBookPath.isNullOrBlank()) { if (ImportBookConfig.importBookPath.isNullOrBlank()) {
AppConfig.importBookPath = AppConfig.defaultBookTreeUri ImportBookConfig.importBookPath = ImportBookConfig.defaultBookTreeUri
} }
} }
ImportFolderPickTarget.IMPORT_FOLDER -> { ImportFolderPickTarget.IMPORT_FOLDER -> {
AppConfig.importBookPath = uri.toString() ImportBookConfig.importBookPath = pickedUri.toString()
if (ImportBookConfig.defaultBookTreeUri.isNullOrBlank()) {
ImportBookConfig.defaultBookTreeUri = pickedUri.toString()
}
} }
} }
initRootDoc(changedFolder = true) 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) { private fun initRootDoc(changedFolder: Boolean) {
@@ -255,48 +298,111 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
reloadCurrentDoc() reloadCurrentDoc()
return return
} }
val lastPath = AppConfig.importBookPath val candidates = linkedSetOf<String>().apply {
if (lastPath.isNullOrBlank()) { 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( _effects.tryEmit(
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER) ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
) )
return return
} }
val rootUri = if (lastPath.isUri()) { for (path in candidates) {
lastPath.toUri() if (trySetRootDoc(path)) {
} else { if (ImportBookConfig.importBookPath != path) {
Uri.fromFile(File(lastPath)) ImportBookConfig.importBookPath = path
}
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)
)
} }
return
} }
val persistedUri = path.takeIf { it.isUri() }
else -> { ?.toUri()
kotlin.runCatching { ?.let(::findPersistedTreeUri)
setRootDoc(FileDoc.fromFile(File(rootUri.path!!))) if (persistedUri != null) {
}.onFailure { val persistedPath = persistedUri.toString()
_effects.tryEmit( if (trySetRootDoc(persistedPath)) {
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER) ImportBookConfig.importBookPath = persistedPath
) return
} }
} }
} }
firstPersistedTreeUri()?.toString()?.let { persistedPath ->
if (trySetRootDoc(persistedPath)) {
ImportBookConfig.defaultBookTreeUri = persistedPath
ImportBookConfig.importBookPath = persistedPath
return
}
}
_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() { fun clearRoot() {
@@ -305,7 +411,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
rootDoc = null, rootDoc = null,
subDocs = emptyList(), subDocs = emptyList(),
sourceDocs = emptyList(), sourceDocs = emptyList(),
selectedIds = emptySet() selectedIds = emptySet(),
sourceMode = SourceMode.CURRENT_DIR
) )
} }
} }
@@ -315,7 +422,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
it.copy( it.copy(
rootDoc = rootDoc, rootDoc = rootDoc,
subDocs = emptyList(), subDocs = emptyList(),
selectedIds = emptySet() selectedIds = emptySet(),
sourceMode = SourceMode.CURRENT_DIR
) )
} }
loadCurrentDoc() loadCurrentDoc()
@@ -369,7 +477,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
fun setSort(sort: Int) { fun setSort(sort: Int) {
_state.update { it.copy(sort = sort) } _state.update { it.copy(sort = sort) }
context.putPrefInt(PreferKey.localBookImportSort, sort) ImportBookConfig.localBookImportSort = sort
} }
fun setSearchMode(isSearch: Boolean) { 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) { private fun onItemClick(item: ImportBook) {
when { when {
item.isDir -> navigateNext(item.file) item.isDir -> navigateNext(item.file)
@@ -506,12 +630,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
.filter { it.toString() in selectedIds } .filter { it.toString() in selectedIds }
.forEach { it.delete() } .forEach { it.delete() }
}.onFinally { }.onFinally {
_state.update { state -> clearSelection()
state.copy( refreshCurrentSource()
sourceDocs = state.sourceDocs.filterNot { it.toString() in selectedIds },
selectedIds = emptySet()
)
}
} }
} }
@@ -523,6 +643,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
it.copy( it.copy(
sourceDocs = emptyList(), sourceDocs = emptyList(),
selectedIds = emptySet(), selectedIds = emptySet(),
sourceMode = SourceMode.SCAN_RECURSIVE,
interaction = it.interaction.copy(isLoading = true) interaction = it.interaction.copy(isLoading = true)
) )
} }
@@ -534,6 +655,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
_state.update { _state.update {
it.copy( it.copy(
sourceDocs = docs, sourceDocs = docs,
selectedIds = emptySet(),
interaction = it.interaction.copy(isLoading = false) interaction = it.interaction.copy(isLoading = false)
) )
} }
@@ -562,22 +684,18 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
it.copy( it.copy(
sourceDocs = emptyList(), sourceDocs = emptyList(),
selectedIds = emptySet(), selectedIds = emptySet(),
sourceMode = SourceMode.CURRENT_DIR,
interaction = it.interaction.copy(isLoading = true) interaction = it.interaction.copy(isLoading = true)
) )
} }
execute { execute {
fileDoc.list { item -> listCurrentDocDocs(fileDoc)
when {
item.name.startsWith(".") -> false
item.isDir -> true
else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
}
} ?: emptyList()
}.onSuccess { docs -> }.onSuccess { docs ->
_state.update { _state.update {
it.copy( it.copy(
sourceDocs = docs, sourceDocs = docs,
selectedIds = emptySet(),
interaction = it.interaction.copy(isLoading = false) 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> { private suspend fun scanDoc(fileDoc: FileDoc): List<FileDoc> {
val channel = Channel<FileDoc>(UNLIMITED) val channel = Channel<FileDoc>(UNLIMITED)
var n = 1 var n = 1
@@ -617,6 +797,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
} }
override fun onCleared() { override fun onCleared() {
autoSyncJob?.cancel()
scanDocJob?.cancel() scanDocJob?.cancel()
super.onCleared() super.onCleared()
} }
@@ -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
)
}