diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index da98accad..335150091 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -281,6 +281,10 @@
+
+
{
+ launch {
+ val uri = Uri.parse(viewModel.bookData.value?.bookUrl.toString())
+ if (RemoteBookWebDav.upload(uri))
+ toastOnUi(getString(R.string.upload_book_success))
+ else
+ toastOnUi(getString(R.string.upload_book_fail))
+
+ }
+ }
}
return super.onCompatOptionsItemSelected(item)
}
diff --git a/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookActivity.kt
new file mode 100644
index 000000000..0ebcd901e
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookActivity.kt
@@ -0,0 +1,65 @@
+package io.legado.app.ui.book.remote
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.recyclerview.widget.LinearLayoutManager
+import io.legado.app.R
+import io.legado.app.base.VMBaseActivity
+
+
+import io.legado.app.databinding.ActivityRemoteBookBinding
+import io.legado.app.utils.toastOnUi
+
+import io.legado.app.utils.viewbindingdelegate.viewBinding
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.launch
+
+/**
+ * 展示远程书籍
+ * @author qianfanguojin
+ * @time 2022/05/12
+ */
+class RemoteBookActivity : VMBaseActivity(),
+ RemoteBookAdapter.CallBack {
+ override val binding by viewBinding(ActivityRemoteBookBinding::inflate)
+ override val viewModel by viewModels()
+ private val adapter by lazy { RemoteBookAdapter(this, this) }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ initView()
+// initEvent()
+ initData()
+// toastOnUi("远程书籍")
+ onFinally()
+ }
+
+
+
+
+ private fun initView() {
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = adapter
+ }
+ private fun initData() {
+ binding.refreshProgressBar.isAutoLoading = true
+ viewModel.loadRemoteBookList()
+ launch {
+ viewModel.dataFlow.conflate().collect { remoteBooks ->
+ adapter.setItems(remoteBooks)
+ }
+ binding.refreshProgressBar.isAutoLoading = false
+ }
+ }
+
+ private fun onFinally() {
+
+ }
+ @SuppressLint("NotifyDataSetChanged")
+ override fun addToBookshelf(remoteBook: RemoteBook) {
+ viewModel.addToBookshelf(remoteBook){
+ toastOnUi(getString(R.string.download_book_fail))
+ adapter.notifyDataSetChanged()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookAdapter.kt b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookAdapter.kt
new file mode 100644
index 000000000..36dd38f86
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookAdapter.kt
@@ -0,0 +1,58 @@
+package io.legado.app.ui.book.remote
+
+import android.content.Context
+import android.view.ViewGroup
+import cn.hutool.core.date.LocalDateTimeUtil
+import io.legado.app.base.adapter.ItemViewHolder
+import io.legado.app.base.adapter.RecyclerAdapter
+import io.legado.app.databinding.ItemRemoteBookBinding
+import io.legado.app.utils.ConvertUtils
+
+
+/**
+ * 适配器
+ * @author qianfanguojin
+ */
+class RemoteBookAdapter (context: Context, val callBack: CallBack) :
+ RecyclerAdapter(context){
+
+ override fun getViewBinding(parent: ViewGroup): ItemRemoteBookBinding {
+ return ItemRemoteBookBinding.inflate(inflater, parent, false)
+ }
+
+ override fun onCurrentListChanged() {
+
+ }
+
+ /**
+ * 绑定RecycleView 中每一个项的视图和数据
+ */
+ override fun convert(
+ holder: ItemViewHolder,
+ binding: ItemRemoteBookBinding,
+ item: RemoteBook,
+ payloads: MutableList
+ ) {
+ binding.run {
+ //Todo:需要判断书籍是否已经加入书架,来改变“下载”按钮的文本,暂时还没有比较好的方案
+ tvName.text = item.filename.substringBeforeLast(".")
+ tvContentType.text = item.contentType
+ tvSize.text = ConvertUtils.formatFileSize(item.size)
+ tvDate.text = LocalDateTimeUtil.format(LocalDateTimeUtil.of(item.lastModify), "yyyy-MM-dd")
+ }
+ }
+
+ override fun registerListener(holder: ItemViewHolder, binding: ItemRemoteBookBinding) {
+ binding.btnDownload.setOnClickListener {
+ getItem(holder.layoutPosition)?.let {
+ callBack.addToBookshelf(it)
+ }
+ }
+
+
+ }
+
+ interface CallBack {
+ fun addToBookshelf(remoteBook: RemoteBook)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookManager.kt b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookManager.kt
new file mode 100644
index 000000000..9fd1d1faf
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookManager.kt
@@ -0,0 +1,19 @@
+package io.legado.app.ui.book.remote
+
+import android.net.Uri
+
+
+
+abstract class RemoteBookManager {
+ protected val remoteBookFolder : String = "books"
+ protected val contentTypeList: ArrayList = arrayListOf("epub","txt")
+ abstract suspend fun initRemoteContext()
+ abstract suspend fun getRemoteBookList(): MutableList
+ abstract suspend fun upload(localBookUri: Uri): Boolean
+ abstract suspend fun delete(remoteBookUrl: String): Boolean
+
+ /**
+ * @return String:下载到本地的路径
+ */
+ abstract suspend fun getRemoteBook(remoteBook: RemoteBook): String?
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookViewModel.kt
new file mode 100644
index 000000000..44f4071b6
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/remote/RemoteBookViewModel.kt
@@ -0,0 +1,104 @@
+package io.legado.app.ui.book.remote
+
+import android.app.Application
+import android.net.Uri
+import androidx.lifecycle.MutableLiveData
+import io.legado.app.base.BaseViewModel
+import io.legado.app.model.localBook.LocalBook
+import io.legado.app.ui.book.remote.manager.RemoteBookWebDav
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOn
+import java.util.*
+
+class RemoteBookViewModel(application: Application): BaseViewModel(application){
+ private val remoteBookFolderName = "book_remote"
+ private var dataCallback : DataCallback? = null
+ var isRemoteBookLiveData = MutableLiveData()
+ var dataFlowStart: (() -> Unit)? = null
+
+
+ val dataFlow = callbackFlow> {
+
+ val list = Collections.synchronizedList(ArrayList())
+
+ dataCallback = object : DataCallback {
+
+ override fun setItems(remoteFiles: List) {
+ list.clear()
+ list.addAll(remoteFiles)
+ trySend(list)
+ }
+
+ override fun addItems(remoteFiles: List) {
+ list.addAll(remoteFiles)
+ trySend(list)
+ }
+
+ override fun clear() {
+ list.clear()
+ trySend(emptyList())
+ }
+ }
+// withContext(Dispatchers.Main) {
+// dataFlowStart?.invoke()
+// }
+
+ awaitClose {
+ dataCallback = null
+ }
+ }.flowOn(Dispatchers.IO)
+
+ fun loadRemoteBookList() {
+ execute {
+ dataCallback?.clear()
+ val bookList = RemoteBookWebDav.getRemoteBookList()
+ dataCallback?.setItems(bookList)
+ }
+ }
+
+
+
+ fun addToBookshelf(uriList: HashSet, finally: () -> Unit) {
+ execute {
+ uriList.forEach {
+ LocalBook.importFile(Uri.parse(it))
+ }
+ }.onFinally {
+ finally.invoke()
+ }
+ }
+
+ /**
+ * 添加书籍到本地书架
+ */
+ fun addToBookshelf(remoteBook: RemoteBook, finally: () -> Unit) {
+ execute {
+ val downloadBookPath = RemoteBookWebDav.getRemoteBook(remoteBook)
+ downloadBookPath?.let {
+ LocalBook.importFile(Uri.parse(it))
+ }
+ }.onFinally {
+ finally.invoke()
+ }
+ }
+ interface DataCallback {
+
+ fun setItems(remoteFiles: List)
+
+ fun addItems(remoteFiles: List)
+
+ fun clear()
+
+ }
+}
+
+data class RemoteBook(
+ val filename: String,
+ val urlName: String,
+ val size: Long,
+ val contentType: String,
+ val lastModify: Long
+)
+
diff --git a/app/src/main/java/io/legado/app/ui/book/remote/manager/RemoteBookWebDav.kt b/app/src/main/java/io/legado/app/ui/book/remote/manager/RemoteBookWebDav.kt
new file mode 100644
index 000000000..c099180c3
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/remote/manager/RemoteBookWebDav.kt
@@ -0,0 +1,287 @@
+package io.legado.app.ui.book.remote.manager
+
+
+import android.net.Uri
+import io.legado.app.constant.PreferKey
+
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.config.AppConfig
+
+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.ui.book.info.BookInfoActivity
+
+import io.legado.app.ui.book.remote.RemoteBook
+import io.legado.app.ui.book.remote.RemoteBookManager
+import io.legado.app.utils.*
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import splitties.init.appCtx
+import java.io.File
+import java.nio.charset.Charset
+
+object RemoteBookWebDav : RemoteBookManager() {
+ private const val defaultWebDavUrl = "https://dav.jianguoyun.com/dav/"
+ private var authorization: Authorization? = null
+ private val remoteBookUrl get() = "${rootWebDavUrl}${remoteBookFolder}"
+ private val localSaveFolder get() = "${appCtx.externalFiles.absolutePath}${File.separator}${remoteBookFolder}"
+ init {
+ runBlocking {
+ initRemoteContext()
+ }
+ }
+
+ private val rootWebDavUrl: String
+ get() {
+ val configUrl = appCtx.getPrefString(PreferKey.webDavUrl)?.trim()
+ var url = if (configUrl.isNullOrEmpty()) defaultWebDavUrl else configUrl
+ if (!url.endsWith("/")) url = "${url}/"
+ AppConfig.webDavDir?.trim()?.let {
+ if (it.isNotEmpty()) {
+ url = "${url}${it}/"
+ }
+ }
+ return url
+ }
+
+ override suspend fun initRemoteContext() {
+ kotlin.runCatching {
+ authorization = null
+ val account = appCtx.getPrefString(PreferKey.webDavAccount)
+ val password = appCtx.getPrefString(PreferKey.webDavPassword)
+ if (!account.isNullOrBlank() && !password.isNullOrBlank()) {
+ val mAuthorization = Authorization(account, password)
+ WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
+ WebDav(remoteBookUrl, mAuthorization).makeAsDir()
+ authorization = mAuthorization
+ }
+ }.onFailure {
+ it.printStackTrace()
+ }
+ }
+
+ @Throws(Exception::class)
+ override suspend fun getRemoteBookList(): MutableList {
+ val remoteBooks = mutableListOf()
+ authorization?.let {
+ //读取文件列表
+ var remoteWebDavFileList : List? = null
+ kotlin.runCatching {
+ remoteWebDavFileList = WebDav(remoteBookUrl, it).listFiles()
+ }
+ //逆序文件排序
+ remoteWebDavFileList = remoteWebDavFileList!!.reversed()
+ //转化远程文件信息到本地对象
+ remoteWebDavFileList!!.forEach { webDavFile ->
+ val webDavFileName = webDavFile.displayName
+ val webDavUrlName = "${remoteBookUrl}${File.separator}${webDavFile.displayName}"
+
+ // 转码
+ //val trueFileName = String(webDavFileName.toByteArray(Charset.forName("GBK")), Charset.forName("UTF-8"))
+ //val trueUrlName = String(webDavUrlName.toByteArray(Charset.forName("GBK")), Charset.forName("UTF-8"))
+
+ //分割后缀
+ val fileExtension = webDavFileName.substringAfterLast(".")
+
+ //扩展名符合阅读的格式则认为是书籍
+ if (contentTypeList.contains(fileExtension)) {
+ remoteBooks.add(RemoteBook(webDavFileName,webDavUrlName,webDavFile.size,fileExtension,webDavFile.lastModify))
+ }
+ }
+ } ?: throw NoStackTraceException("webDav没有配置")
+ return remoteBooks
+ }
+
+ override suspend fun getRemoteBook(remoteBook: RemoteBook): String? {
+ val saveFilePath= "${localSaveFolder}${File.separator}${remoteBook.filename}"
+ kotlin.runCatching {
+ authorization?.let {
+ FileUtils.createFolderIfNotExist(localSaveFolder).run{
+ val webdav = WebDav(
+ remoteBook.urlName,
+ it
+ )
+ webdav.downloadTo(saveFilePath, true)
+ }
+ }
+ }.onFailure {
+ it.printStackTrace()
+ return null
+ }
+ return saveFilePath
+ }
+
+ /**
+ * 上传本地导入的书籍到远程
+ */
+ override suspend fun upload(localBookUri: Uri): Boolean {
+ if (!NetworkUtils.isAvailable()) return false
+
+ val localBookName = localBookUri.path?.substringAfterLast(File.separator)
+ val putUrl = "${remoteBookUrl}${File.separator}${localBookName}"
+ kotlin.runCatching {
+ authorization?.let {
+ if (localBookUri.isContentScheme()){
+ WebDav(putUrl, it).upload(byteArray = localBookUri.readBytes(appCtx),contentType = "application/octet-stream")
+ }else{
+ WebDav(putUrl, it).upload(localBookUri.path!!)
+ }
+ }
+ }.onFailure {
+ return false
+ }
+ return true
+ }
+
+ override suspend fun delete(remoteBookUrl: String): Boolean {
+ TODO("Not yet implemented")
+ }
+
+// suspend fun showRestoreDialog(context: Context) {
+// val names = withContext(Dispatchers.IO) { getBackupNames() }
+// if (names.isNotEmpty()) {
+// withContext(Dispatchers.Main) {
+// context.selector(
+// title = context.getString(R.string.select_restore_file),
+// items = names
+// ) { _, index ->
+// if (index in 0 until names.size) {
+// Coroutine.async {
+// restoreWebDav(names[index])
+// }.onError {
+// appCtx.toastOnUi("WebDav恢复出错\n${it.localizedMessage}")
+// }
+// }
+// }
+// }
+// } else {
+// throw NoStackTraceException("Web dav no back up file")
+// }
+// }
+//
+// @Throws(WebDavException::class)
+// suspend fun restoreWebDav(name: String) {
+// authorization?.let {
+// val webDav = WebDav(rootWebDavUrl + name, it)
+// webDav.downloadTo(zipFilePath, true)
+// @Suppress("BlockingMethodInNonBlockingContext")
+// ZipUtils.unzipFile(zipFilePath, Backup.backupPath)
+// Restore.restoreDatabase()
+// Restore.restoreConfig()
+// }
+// }
+//
+// suspend fun hasBackUp(): Boolean {
+// authorization?.let {
+// val url = "${rootWebDavUrl}${backupFileName}"
+// return WebDav(url, it).exists()
+// }
+// return false
+// }
+//
+// suspend fun lastBackUp(): Result {
+// return kotlin.runCatching {
+// authorization?.let {
+// var lastBackupFile: WebDavFile? = null
+// WebDav(rootWebDavUrl, it).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(path: String) {
+// if (!NetworkUtils.isAvailable()) return
+// authorization?.let {
+// val paths = arrayListOf(*Backup.backupFileNames)
+// for (i in 0 until paths.size) {
+// paths[i] = path + File.separator + paths[i]
+// }
+// FileUtils.delete(zipFilePath)
+// if (ZipUtils.zipFiles(paths, zipFilePath)) {
+// val putUrl = "${rootWebDavUrl}${backupFileName}"
+// WebDav(putUrl, it).upload(zipFilePath)
+// }
+// }
+// }
+//
+// suspend fun exportWebDav(byteArray: ByteArray, fileName: String) {
+// if (!NetworkUtils.isAvailable()) return
+// try {
+// authorization?.let {
+// // 如果导出的本地文件存在,开始上传
+// val putUrl = exportsWebDavUrl + fileName
+// WebDav(putUrl, it).upload(byteArray, "text/plain")
+// }
+// } catch (e: Exception) {
+// val msg = "WebDav导出\n${e.localizedMessage}"
+// AppLog.put(msg)
+// appCtx.toastOnUi(msg)
+// }
+// }
+//
+// fun uploadBookProgress(book: Book) {
+// val authorization = authorization ?: return
+// if (!syncBookProgress) return
+// if (!NetworkUtils.isAvailable()) return
+// Coroutine.async {
+// val bookProgress = BookProgress(book)
+// val json = GSON.toJson(bookProgress)
+// val url = getProgressUrl(book)
+// WebDav(url, authorization).upload(json.toByteArray(), "application/json")
+// }.onError {
+// AppLog.put("上传进度失败\n${it.localizedMessage}")
+// }
+// }
+//
+// private fun getProgressUrl(book: Book): String {
+// return bookProgressUrl + book.name + "_" + book.author + ".json"
+// }
+//
+// /**
+// * 获取书籍进度
+// */
+// suspend fun getBookProgress(book: Book): BookProgress? {
+// authorization?.let {
+// val url = getProgressUrl(book)
+// kotlin.runCatching {
+// WebDav(url, it).download().let { byteArray ->
+// val json = String(byteArray)
+// if (json.isJson()) {
+// return GSON.fromJsonObject(json).getOrNull()
+// }
+// }
+// }
+// }
+// return null
+// }
+//
+// suspend fun downloadAllBookProgress() {
+// authorization ?: return
+// if (!NetworkUtils.isAvailable()) return
+// appDb.bookDao.all.forEach { book ->
+// 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
+// appDb.bookDao.update(book)
+// }
+// }
+// }
+// }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt
index 78d16eb92..1d1ca0b51 100644
--- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt
+++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt
@@ -1,6 +1,7 @@
package io.legado.app.ui.main.bookshelf
import android.annotation.SuppressLint
+import android.content.Intent
import android.view.Menu
import android.view.MenuItem
import androidx.fragment.app.activityViewModels
@@ -23,6 +24,7 @@ import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.ui.book.group.GroupManageDialog
import io.legado.app.ui.book.local.ImportBookActivity
import io.legado.app.ui.book.manage.BookshelfManageActivity
+import io.legado.app.ui.book.remote.RemoteBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.main.MainViewModel
@@ -74,6 +76,8 @@ abstract class BaseBookshelfFragment(layoutId: Int) : VMBaseFragment startActivity()
R.id.menu_search -> startActivity()
R.id.menu_update_toc -> activityViewModel.upToc(books)
R.id.menu_bookshelf_layout -> configBookshelf()
diff --git a/app/src/main/res/layout/activity_remote_book.xml b/app/src/main/res/layout/activity_remote_book.xml
new file mode 100644
index 000000000..6af80a958
--- /dev/null
+++ b/app/src/main/res/layout/activity_remote_book.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_remote_book.xml b/app/src/main/res/layout/item_remote_book.xml
new file mode 100644
index 000000000..ef032b4e3
--- /dev/null
+++ b/app/src/main/res/layout/item_remote_book.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/book_info.xml b/app/src/main/res/menu/book_info.xml
index 911ba2332..80633d3cb 100644
--- a/app/src/main/res/menu/book_info.xml
+++ b/app/src/main/res/menu/book_info.xml
@@ -14,6 +14,11 @@
android:title="@string/share"
app:showAsAction="ifRoom" />
+
+
+
+
- Legado needs storage access to find and read books. please go "App Settings" to allow "Storage permission".
+ Upload Success
+ Upload Fail
+ Download Success
+ Download Fail
+ 丨
+ Upload
+ Add Remote
Home
Restore
Import Legado data