From a37e1ac62efa422f4c579eae639563a5f0281bcd Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:45:34 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BF=AE=E5=A4=8D]=20WebDav=E5=92=8C=E4=B9=A6?= =?UTF-8?q?=E7=AD=BE=E6=B7=BB=E5=8A=A0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/legado/app/help/WebDavManager.kt | 347 ++++++++++++++++++ .../io/legado/app/ui/book/toc/TocScreen.kt | 2 +- .../io/legado/app/ui/book/toc/TocViewModel.kt | 32 ++ .../config/backupConfig/BackupConfigScreen.kt | 15 + .../backupConfig/BackupConfigViewModel.kt | 4 + 5 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/legado/app/help/WebDavManager.kt diff --git a/app/src/main/java/io/legado/app/help/WebDavManager.kt b/app/src/main/java/io/legado/app/help/WebDavManager.kt new file mode 100644 index 000000000..ac7df6e64 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/WebDavManager.kt @@ -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.Unconfigured) + val state: StateFlow = _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 { + val auth = requireAuthorization() + val rootUrl = buildRootUrl(configFlow.value) + val names = arrayListOf() + 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 { + 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(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() + 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" + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt index 2fa214810..b9620c419 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt @@ -222,7 +222,7 @@ fun TocScreen( ActionItem( text = "添加书签", icon = { Icon(Icons.Default.BookmarkAdd, contentDescription = null) }, - onClick = { /* TODO: viewModel.addBookmarksForSelected() */ } + onClick = { viewModel.addBookmarksForSelected() } ) ) } diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt index 5b19b799e..afc22db53 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -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() diff --git a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt index 459a41a87..f7503aa85 100644 --- a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt @@ -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 -> diff --git a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt index 1fcf351e7..58aeebec9 100644 --- a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt @@ -17,6 +17,10 @@ class BackupConfigViewModel : ViewModel() { } } + suspend fun refreshWebDavConfig() { + syncWebDavConfig() + } + fun setWebDavAccount(account: String, password: String) { BackupConfig.webDavAccount = account BackupConfig.webDavPassword = password