فهرست منبع

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

HapeLee 7 ماه پیش
والد
کامیت
a37e1ac62e

+ 347 - 0
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>(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"
+    }
+}

+ 1 - 1
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() }
             )
         )
     }

+ 32 - 0
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()

+ 15 - 0
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 ->

+ 4 - 0
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