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