[修复] WebDav和书签添加问题

This commit is contained in:
HapeLee
2026-04-08 01:45:34 +08:00
parent 191f639b29
commit a37e1ac62e
5 changed files with 399 additions and 1 deletions
@@ -0,0 +1,347 @@
package io.legado.app.help
import android.content.SharedPreferences
import android.net.Uri
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookProgress
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig
import io.legado.app.help.storage.Backup
import io.legado.app.help.storage.Restore
import io.legado.app.lib.webdav.Authorization
import io.legado.app.lib.webdav.WebDav
import io.legado.app.lib.webdav.WebDavException
import io.legado.app.lib.webdav.WebDavFile
import io.legado.app.model.remote.RemoteBookWebDav
import io.legado.app.utils.AlphanumComparator
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.UrlUtil
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getPrefString
import io.legado.app.utils.isJson
import io.legado.app.utils.normalizeFileName
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
data class WebDavConfig(
val url: String,
val account: String,
val password: String,
val dir: String,
val deviceName: String
)
sealed class WebDavState {
data object Unconfigured : WebDavState()
data object Checking : WebDavState()
data class Ready(
val rootUrl: String,
val authorization: Authorization,
val defaultBookWebDav: RemoteBookWebDav
) : WebDavState()
data class Error(
val message: String,
val cause: Throwable? = null
) : WebDavState()
}
class WebDavManager(
private val prefs: SharedPreferences = appCtx.defaultSharedPreferences,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : SharedPreferences.OnSharedPreferenceChangeListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val configFlow = MutableStateFlow(readConfig())
private val _state = MutableStateFlow<WebDavState>(WebDavState.Unconfigured)
val state: StateFlow<WebDavState> = _state.asStateFlow()
private var refreshJob: Job? = null
init {
prefs.registerOnSharedPreferenceChangeListener(this)
refresh()
}
fun close() {
prefs.unregisterOnSharedPreferenceChangeListener(this)
scope.cancel()
}
fun refresh() {
refreshJob?.cancel()
refreshJob = scope.launch(ioDispatcher) {
syncConfig(configFlow.value)
}
}
suspend fun testWebDav(): Boolean {
val config = configFlow.value
if (config.account.isBlank() || config.password.isBlank()) {
_state.value = WebDavState.Unconfigured
return false
}
return withContext(ioDispatcher) {
try {
val auth = Authorization(config.account, config.password)
checkAuthorization(buildRootUrl(config), auth)
true
} catch (_: Exception) {
false
}
}
}
fun defaultBookWebDavOrNull(): RemoteBookWebDav? {
return (state.value as? WebDavState.Ready)?.defaultBookWebDav
}
@Throws(Exception::class)
suspend fun getBackupNames(): ArrayList<String> {
val auth = requireAuthorization()
val rootUrl = buildRootUrl(configFlow.value)
val names = arrayListOf<String>()
var files = WebDav(rootUrl, auth).listFiles()
files = files.sortedWith { o1, o2 ->
AlphanumComparator.compare(o1.displayName, o2.displayName)
}.reversed()
files.forEach { webDav ->
val name = webDav.displayName
if (name.startsWith("backup")) {
names.add(name)
}
}
return names
}
@Throws(WebDavException::class)
suspend fun restoreWebDav(name: String) {
val auth = requireAuthorization()
val rootUrl = buildRootUrl(configFlow.value)
val webDav = WebDav(rootUrl + name, auth)
webDav.downloadTo(Backup.zipFilePath, true)
FileUtils.delete(Backup.backupPath)
ZipUtils.unZipToPath(File(Backup.zipFilePath), Backup.backupPath)
Restore.restoreLocked(Backup.backupPath)
}
suspend fun hasBackUp(backUpName: String): Boolean {
val auth = requireAuthorization()
val rootUrl = buildRootUrl(configFlow.value)
val url = "$rootUrl$backUpName"
return WebDav(url, auth).exists()
}
suspend fun lastBackUp(): Result<WebDavFile?> {
return kotlin.runCatching {
val auth = requireAuthorization()
val rootUrl = buildRootUrl(configFlow.value)
var lastBackupFile: WebDavFile? = null
WebDav(rootUrl, auth).listFiles().reversed().forEach { webDavFile ->
if (webDavFile.displayName.startsWith("backup")) {
if (lastBackupFile == null || webDavFile.lastModify > lastBackupFile.lastModify) {
lastBackupFile = webDavFile
}
}
}
lastBackupFile
}
}
@Throws(Exception::class)
suspend fun backUpWebDav(fileName: String) {
if (!NetworkUtils.isAvailable()) return
val auth = requireAuthorization()
val rootUrl = buildRootUrl(configFlow.value)
val putUrl = "$rootUrl$fileName"
WebDav(putUrl, auth).upload(Backup.zipFilePath)
}
suspend fun exportWebDav(byteArray: ByteArray, fileName: String) {
if (!NetworkUtils.isAvailable()) return
val auth = requireAuthorization()
val exportsUrl = buildRootUrl(configFlow.value) + "books/"
val putUrl = exportsUrl + fileName
WebDav(putUrl, auth).upload(byteArray, "text/plain")
}
suspend fun exportWebDav(uri: Uri, fileName: String) {
if (!NetworkUtils.isAvailable()) return
val auth = requireAuthorization()
val exportsUrl = buildRootUrl(configFlow.value) + "books/"
val putUrl = exportsUrl + fileName
WebDav(putUrl, auth).upload(uri, "text/plain")
}
suspend fun uploadBookProgress(
book: Book,
toast: Boolean = false,
onSuccess: (() -> Unit)? = null
) {
val auth = requireAuthorization()
if (!AppConfig.syncBookProgress) return
if (!NetworkUtils.isAvailable()) return
try {
val bookProgress = BookProgress(book)
val json = GSON.toJson(bookProgress)
val url = getProgressUrl(book.name, book.author)
WebDav(url, auth).upload(json.toByteArray(), "application/json")
book.syncTime = System.currentTimeMillis()
onSuccess?.invoke()
} catch (e: Exception) {
if (toast) {
throw e
}
}
}
suspend fun uploadBookProgress(bookProgress: BookProgress, onSuccess: (() -> Unit)? = null) {
val auth = requireAuthorization()
if (!AppConfig.syncBookProgress) return
if (!NetworkUtils.isAvailable()) return
val json = GSON.toJson(bookProgress)
val url = getProgressUrl(bookProgress.name, bookProgress.author)
WebDav(url, auth).upload(json.toByteArray(), "application/json")
onSuccess?.invoke()
}
suspend fun getBookProgress(book: Book): BookProgress? {
val auth = requireAuthorization()
val url = getProgressUrl(book.name, book.author)
return kotlin.runCatching {
WebDav(url, auth).download().let { byteArray ->
val json = String(byteArray)
if (json.isJson()) {
return GSON.fromJsonObject<BookProgress>(json).getOrNull()
}
}
null
}.getOrNull()
}
suspend fun downloadAllBookProgress() {
val auth = requireAuthorization()
if (!NetworkUtils.isAvailable()) return
val progressUrl = buildRootUrl(configFlow.value) + "bookProgress/"
val bookProgressFiles = WebDav(progressUrl, auth).listFiles()
val map = hashMapOf<String, WebDavFile>()
bookProgressFiles.forEach {
map[it.displayName] = it
}
appDb.bookDao.all.forEach { book ->
val progressFileName = getProgressFileName(book.name, book.author)
val webDavFile = map[progressFileName] ?: return
if (webDavFile.lastModify <= book.syncTime) {
return
}
getBookProgress(book)?.let { bookProgress ->
if (bookProgress.durChapterIndex > book.durChapterIndex
|| (bookProgress.durChapterIndex == book.durChapterIndex
&& bookProgress.durChapterPos > book.durChapterPos)
) {
book.durChapterIndex = bookProgress.durChapterIndex
book.durChapterPos = bookProgress.durChapterPos
book.durChapterTitle = bookProgress.durChapterTitle
book.durChapterTime = bookProgress.durChapterTime
book.syncTime = System.currentTimeMillis()
appDb.bookDao.update(book)
}
}
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == PreferKey.webDavUrl
|| key == PreferKey.webDavAccount
|| key == PreferKey.webDavPassword
|| key == PreferKey.webDavDir
|| key == PreferKey.webDavDeviceName
) {
configFlow.value = readConfig()
refresh()
}
}
private fun readConfig(): WebDavConfig {
return WebDavConfig(
url = appCtx.getPrefString(PreferKey.webDavUrl, "") ?: "",
account = appCtx.getPrefString(PreferKey.webDavAccount, "") ?: "",
password = appCtx.getPrefString(PreferKey.webDavPassword, "") ?: "",
dir = appCtx.getPrefString(PreferKey.webDavDir, "legado") ?: "legado",
deviceName = appCtx.getPrefString(PreferKey.webDavDeviceName, "") ?: ""
)
}
private suspend fun syncConfig(config: WebDavConfig) {
if (config.account.isBlank() || config.password.isBlank()) {
_state.value = WebDavState.Unconfigured
return
}
_state.value = WebDavState.Checking
try {
val rootUrl = buildRootUrl(config)
val auth = Authorization(config.account, config.password)
checkAuthorization(rootUrl, auth)
WebDav(rootUrl, auth).makeAsDir()
WebDav(rootUrl + "bookProgress/", auth).makeAsDir()
WebDav(rootUrl + "books/", auth).makeAsDir()
WebDav(rootUrl + "background/", auth).makeAsDir()
val defaultBookWebDav = RemoteBookWebDav(rootUrl + "books/", auth)
_state.value = WebDavState.Ready(rootUrl, auth, defaultBookWebDav)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_state.value = WebDavState.Error(e.localizedMessage ?: "WebDav init failed", e)
}
}
@Throws(WebDavException::class)
private suspend fun checkAuthorization(rootUrl: String, authorization: Authorization) {
if (!WebDav(rootUrl, authorization).check()) {
throw WebDavException("WebDav authorization failed")
}
}
private fun buildRootUrl(config: WebDavConfig): String {
val defaultUrl = "https://dav.jianguoyun.com/dav/"
var url = if (config.url.isBlank()) defaultUrl else config.url.trim()
if (!url.endsWith("/")) url = "$url/"
val dir = config.dir.trim()
if (dir.isNotEmpty()) {
url = "$url$dir/"
}
return url
}
private fun requireAuthorization(): Authorization {
return (state.value as? WebDavState.Ready)?.authorization
?: throw NoStackTraceException("webDav not configured")
}
private fun getProgressUrl(name: String, author: String): String {
val progressUrl = buildRootUrl(configFlow.value) + "bookProgress/"
return progressUrl + getProgressFileName(name, author)
}
private fun getProgressFileName(name: String, author: String): String {
return UrlUtil.replaceReservedChar("${name}_${author}".normalizeFileName()) + ".json"
}
}
@@ -222,7 +222,7 @@ fun TocScreen(
ActionItem(
text = "添加书签",
icon = { Icon(Icons.Default.BookmarkAdd, contentDescription = null) },
onClick = { /* TODO: viewModel.addBookmarksForSelected() */ }
onClick = { viewModel.addBookmarksForSelected() }
)
)
}
@@ -488,6 +488,38 @@ class TocViewModel(
fun deleteBookmark(bookmark: Bookmark) =
viewModelScope.launch(Dispatchers.IO) { appDb.bookmarkDao.delete(bookmark) }
fun addBookmarksForSelected() = viewModelScope.launch(Dispatchers.IO) {
val book = bookState.value ?: return@launch
val selectedItems = uiState.value.items
.asSequence()
.filter { it.id in uiState.value.selectedIds }
.filterNot { it.isVolume }
.toList()
if (selectedItems.isEmpty()) {
context.toastOnUi("请选择章节")
return@launch
}
val bookmarks = selectedItems.map { item ->
Bookmark(
bookName = book.name,
bookAuthor = book.author,
chapterIndex = item.id,
chapterPos = 0,
chapterName = item.title,
bookText = "",
content = ""
)
}
appDb.bookmarkDao.insert(*bookmarks.toTypedArray())
context.toastOnUi("已添加 ${bookmarks.size} 个书签")
withContext(Dispatchers.Main) {
clearSelection()
}
}
fun downloadSelected() {
val book = bookState.value ?: return
val indices = uiState.value.selectedIds.toList()
@@ -24,6 +24,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -95,6 +96,20 @@ fun BackupConfigScreen(
var showLoadingDialog by remember { mutableStateOf(false) }
var loadingText by remember { mutableStateOf("") }
val webDavUrl = BackupConfig.webDavUrl
val webDavDir = BackupConfig.webDavDir
val webDavAccount = BackupConfig.webDavAccount
val webDavPassword = BackupConfig.webDavPassword
var webDavSyncReady by remember { mutableStateOf(false) }
LaunchedEffect(webDavUrl, webDavDir, webDavAccount, webDavPassword) {
if (webDavSyncReady) {
viewModel.refreshWebDavConfig()
} else {
webDavSyncReady = true
}
}
val selectBackupPathLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
@@ -17,6 +17,10 @@ class BackupConfigViewModel : ViewModel() {
}
}
suspend fun refreshWebDavConfig() {
syncWebDavConfig()
}
fun setWebDavAccount(account: String, password: String) {
BackupConfig.webDavAccount = account
BackupConfig.webDavPassword = password