添加上传文件转链接

pull/1284/head
gedoor 3 years ago
parent b1d4fb39f2
commit 37b287d341
  1. 9
      app/src/main/AndroidManifest.xml
  2. 4
      app/src/main/java/io/legado/app/data/entities/BaseSource.kt
  3. 5
      app/src/main/java/io/legado/app/help/CacheManager.kt
  4. 44
      app/src/main/java/io/legado/app/help/DirectLinkUpload.kt
  5. 18
      app/src/main/java/io/legado/app/help/http/OkHttpUtils.kt
  6. 5
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt
  7. 5
      app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt
  8. 4
      app/src/main/java/io/legado/app/ui/book/local/ImportBookActivity.kt
  9. 8
      app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt
  10. 8
      app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt
  11. 6
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt
  12. 8
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt
  13. 10
      app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt
  14. 63
      app/src/main/java/io/legado/app/ui/config/DirectLinkUploadConfig.kt
  15. 12
      app/src/main/java/io/legado/app/ui/document/FilePickerActivity.kt
  16. 4
      app/src/main/java/io/legado/app/ui/document/FilePickerDialog.kt
  17. 2
      app/src/main/java/io/legado/app/ui/document/HandleFileContract.kt
  18. 8
      app/src/main/java/io/legado/app/ui/login/RuleUiLoginDialog.kt
  19. 8
      app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt
  20. 8
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt
  21. 4
      app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt
  22. 8
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt
  23. 4
      app/src/main/java/io/legado/app/ui/widget/font/FontSelectDialog.kt
  24. 101
      app/src/main/res/layout/dialog_direct_link_upload_config.xml
  25. 2
      app/src/main/res/values-es-rES/strings.xml
  26. 2
      app/src/main/res/values-ja-rJP/strings.xml
  27. 2
      app/src/main/res/values-pt-rBR/strings.xml
  28. 2
      app/src/main/res/values-zh-rHK/strings.xml
  29. 2
      app/src/main/res/values-zh-rTW/strings.xml
  30. 2
      app/src/main/res/values-zh/strings.xml
  31. 2
      app/src/main/res/values/strings.xml

@ -295,7 +295,8 @@
<activity <activity
android:name="io.legado.app.ui.login.SourceLoginActivity" android:name="io.legado.app.ui.login.SourceLoginActivity"
android:configChanges="orientation|screenSize" android:configChanges="orientation|screenSize"
android:hardwareAccelerated="true" /> android:hardwareAccelerated="true"
android:theme="@style/AppTheme.Transparent" />
<!-- 阅读记录 --> <!-- 阅读记录 -->
<activity <activity
android:name=".ui.about.ReadRecordActivity" android:name=".ui.about.ReadRecordActivity"
@ -399,10 +400,10 @@
<service android:name=".service.WebService" /> <service android:name=".service.WebService" />
<service <service
android:name=".service.WebTileService" android:name=".service.WebTileService"
android:label="legado Web Service" android:exported="true"
android:icon="@drawable/ic_web_service_noti" android:icon="@drawable/ic_web_service_noti"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" android:label="legado Web Service"
android:exported="true"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>

@ -107,6 +107,10 @@ interface BaseSource : JsExtensions {
CacheManager.put("userInfo_${getStoreUrl()}", data) CacheManager.put("userInfo_${getStoreUrl()}", data)
} }
fun removeLoginInfo() {
CacheManager.delete("userInfo_${getStoreUrl()}")
}
/** /**
* 执行JS * 执行JS
*/ */

@ -59,4 +59,9 @@ object CacheManager {
} }
return null return null
} }
fun delete(key: String) {
appDb.cacheDao.delete(key)
ACache.get(appCtx).remove(key)
}
} }

@ -0,0 +1,44 @@
package io.legado.app.help
import io.legado.app.help.http.newCallStrResponse
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.postMultipart
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.RuleData
object DirectLinkUpload {
private const val uploadUrlKey = "directLinkUploadUrl"
private const val downloadUrlRuleKey = "directLinkDownloadUrlRule"
suspend fun upLoad(fileName: String, byteArray: ByteArray): String {
val res = okHttpClient.newCallStrResponse {
url("https://shuyuan.miaogongzi.site/index.html")
postMultipart(mapOf("file" to Triple(fileName, byteArray, null)))
}
val analyzeRule = AnalyzeRule(RuleData()).setContent(res.body, res.url)
return analyzeRule.getString("tag.b@text")
}
fun getUploadUrl(): String? {
return CacheManager.get(uploadUrlKey)
}
fun putUploadUrl(url: String) {
CacheManager.put(uploadUrlKey, url)
}
fun getDownloadUrlRule(): String? {
return CacheManager.get(uploadUrlKey)
}
fun putDownloadUrlRule(rule: String) {
CacheManager.put(downloadUrlRuleKey, rule)
}
fun delete() {
CacheManager.delete(uploadUrlKey)
CacheManager.delete(downloadUrlRuleKey)
}
}

@ -114,6 +114,24 @@ fun Request.Builder.postForm(form: Map<String, String>, encoded: Boolean = false
post(formBody.build()) post(formBody.build())
} }
fun Request.Builder.postMultipart(form: Map<String, Any>) {
val multipartBody = MultipartBody.Builder()
form.forEach {
when (it.value) {
is Triple<*, *, *> -> {
val triple = it.value as Triple<*, *, *>
val fileName = triple.first!!.toString()
val file = triple.second as ByteArray
val mediaType = triple.third?.toString()?.toMediaType()
val requestBody = file.toRequestBody(mediaType)
multipartBody.addFormDataPart(it.key, fileName, requestBody)
}
else -> multipartBody.addFormDataPart(it.key, it.value.toString())
}
}
post(multipartBody.build())
}
fun Request.Builder.postJson(json: String?) { fun Request.Builder.postJson(json: String?) {
json?.let { json?.let {
val requestBody = json.toRequestBody("application/json; charset=UTF-8".toMediaType()) val requestBody = json.toRequestBody("application/json; charset=UTF-8".toMediaType())

@ -25,7 +25,10 @@ import kotlin.collections.HashMap
*/ */
@Keep @Keep
@Suppress("unused", "RegExpRedundantEscape", "MemberVisibilityCanBePrivate") @Suppress("unused", "RegExpRedundantEscape", "MemberVisibilityCanBePrivate")
class AnalyzeRule(val ruleData: RuleDataInterface, private val source: BaseSource) : JsExtensions { class AnalyzeRule(
val ruleData: RuleDataInterface,
private val source: BaseSource? = null
) : JsExtensions {
var book = if (ruleData is BaseBook) ruleData else null var book = if (ruleData is BaseBook) ruleData else null

@ -24,8 +24,8 @@ import io.legado.app.help.BookHelp
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector import io.legado.app.lib.dialogs.selector
import io.legado.app.service.help.CacheBook import io.legado.app.service.help.CacheBook
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.widget.dialog.TextListDialog import io.legado.app.ui.widget.dialog.TextListDialog
import io.legado.app.utils.* import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
@ -53,7 +53,7 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
private val groupList: ArrayList<BookGroup> = arrayListOf() private val groupList: ArrayList<BookGroup> = arrayListOf()
private var groupId: Long = -1 private var groupId: Long = -1
private val exportDir = registerForActivityResult(FilePicker()) { uri -> private val exportDir = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
ACache.get(this@CacheActivity).put(exportBookPathKey, uri.toString()) ACache.get(this@CacheActivity).put(exportBookPathKey, uri.toString())
@ -304,6 +304,7 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
} }
} }
@SuppressLint("SetTextI18n")
private fun alertExportFileName() { private fun alertExportFileName() {
alert(R.string.export_file_name) { alert(R.string.export_file_name) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {

@ -21,7 +21,7 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.backgroundColor
import io.legado.app.ui.document.FilePicker import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.SelectActionBar
import io.legado.app.utils.* import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
@ -49,7 +49,7 @@ class ImportBookActivity : VMBaseActivity<ActivityImportBookBinding, ImportBookV
private lateinit var adapter: ImportBookAdapter private lateinit var adapter: ImportBookAdapter
private var sdPath = FileUtils.getSdCardPath() private var sdPath = FileUtils.getSdCardPath()
private var path = sdPath private var path = sdPath
private val selectFolder = registerForActivityResult(FilePicker()) { uri -> private val selectFolder = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
AppConfig.importBookPath = uri.toString() AppConfig.importBookPath = uri.toString()

@ -24,8 +24,8 @@ import io.legado.app.lib.theme.bottomBackground
import io.legado.app.lib.theme.getPrimaryTextColor import io.legado.app.lib.theme.getPrimaryTextColor
import io.legado.app.lib.theme.getSecondaryTextColor import io.legado.app.lib.theme.getSecondaryTextColor
import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.utils.* import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
import java.io.File import java.io.File
@ -48,11 +48,11 @@ class BgTextConfigDialog : BaseDialogFragment() {
setBgFromUri(uri) setBgFromUri(uri)
} }
} }
private val selectExportDir = registerForActivityResult(FilePicker()) { private val selectExportDir = registerForActivityResult(HandleFileContract()) {
it ?: return@registerForActivityResult it ?: return@registerForActivityResult
exportConfig(it) exportConfig(it)
} }
private val selectImportDoc = registerForActivityResult(FilePicker()) { private val selectImportDoc = registerForActivityResult(HandleFileContract()) {
it ?: return@registerForActivityResult it ?: return@registerForActivityResult
if (it.toString() == importFormNet) { if (it.toString() == importFormNet) {
importNetConfigAlert() importNetConfigAlert()
@ -190,7 +190,7 @@ class BgTextConfigDialog : BaseDialogFragment() {
binding.ivImport.setOnClickListener { binding.ivImport.setOnClickListener {
selectImportDoc.launch( selectImportDoc.launch(
FilePickerParam( FilePickerParam(
mode = FilePicker.FILE, mode = HandleFileContract.FILE,
title = getString(R.string.import_str), title = getString(R.string.import_str),
allowExtensions = arrayOf("zip"), allowExtensions = arrayOf("zip"),
otherActions = arrayOf(importFormNet) otherActions = arrayOf(importFormNet)

@ -25,8 +25,8 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.ATH import io.legado.app.lib.theme.ATH
import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryColor
import io.legado.app.service.help.ReadAloud import io.legado.app.service.help.ReadAloud
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.* import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
@ -41,12 +41,12 @@ class SpeakEngineDialog : BaseDialogFragment(), Toolbar.OnMenuItemClickListener
lateinit var adapter: Adapter lateinit var adapter: Adapter
private val viewModel: SpeakEngineViewModel by viewModels() private val viewModel: SpeakEngineViewModel by viewModels()
private var engineId = appCtx.getPrefLong(PreferKey.speakEngine) private var engineId = appCtx.getPrefLong(PreferKey.speakEngine)
private val importDocResult = registerForActivityResult(FilePicker()) { private val importDocResult = registerForActivityResult(HandleFileContract()) {
it?.let { it?.let {
viewModel.importLocal(it) viewModel.importLocal(it)
} }
} }
private val exportDirResult = registerForActivityResult(FilePicker()) { private val exportDirResult = registerForActivityResult(HandleFileContract()) {
it?.let { it?.let {
viewModel.export(it) viewModel.export(it)
} }
@ -116,7 +116,7 @@ class SpeakEngineDialog : BaseDialogFragment(), Toolbar.OnMenuItemClickListener
R.id.menu_default -> viewModel.importDefault() R.id.menu_default -> viewModel.importDefault()
R.id.menu_import_local -> importDocResult.launch( R.id.menu_import_local -> importDocResult.launch(
FilePickerParam( FilePickerParam(
mode = FilePicker.FILE, mode = HandleFileContract.FILE,
allowExtensions = arrayOf("txt", "json") allowExtensions = arrayOf("txt", "json")
) )
) )

@ -25,8 +25,8 @@ import io.legado.app.lib.dialogs.selector
import io.legado.app.lib.theme.ATH import io.legado.app.lib.theme.ATH
import io.legado.app.lib.theme.backgroundColor import io.legado.app.lib.theme.backgroundColor
import io.legado.app.ui.book.source.debug.BookSourceDebugActivity import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.qrcode.QrCodeResult
import io.legado.app.ui.widget.KeyboardToolPop import io.legado.app.ui.widget.KeyboardToolPop
@ -55,7 +55,7 @@ class BookSourceEditActivity :
upRecyclerView(source) upRecyclerView(source)
} }
} }
private val selectDoc = registerForActivityResult(FilePicker()) { uri -> private val selectDoc = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
sendText(uri.toString()) sendText(uri.toString())
@ -418,7 +418,7 @@ class BookSourceEditActivity :
2 -> showRegexHelp() 2 -> showRegexHelp()
3 -> selectDoc.launch( 3 -> selectDoc.launch(
FilePickerParam( FilePickerParam(
mode = FilePicker.FILE mode = HandleFileContract.FILE
) )
) )
} }

@ -31,8 +31,8 @@ import io.legado.app.service.help.CheckSource
import io.legado.app.ui.association.ImportBookSourceDialog import io.legado.app.ui.association.ImportBookSourceDialog
import io.legado.app.ui.book.source.debug.BookSourceDebugActivity import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.qrcode.QrCodeResult
import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.SelectActionBar
import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.dialog.TextDialog
@ -66,7 +66,7 @@ class BookSourceActivity : VMBaseActivity<ActivityBookSourceBinding, BookSourceV
it ?: return@registerForActivityResult it ?: return@registerForActivityResult
ImportBookSourceDialog.start(supportFragmentManager, it) ImportBookSourceDialog.start(supportFragmentManager, it)
} }
private val importDoc = registerForActivityResult(FilePicker()) { uri -> private val importDoc = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
try { try {
uri.readText(this)?.let { uri.readText(this)?.let {
@ -76,7 +76,7 @@ class BookSourceActivity : VMBaseActivity<ActivityBookSourceBinding, BookSourceV
toastOnUi("readTextError:${e.localizedMessage}") toastOnUi("readTextError:${e.localizedMessage}")
} }
} }
private val exportDir = registerForActivityResult(FilePicker()) { uri -> private val exportDir = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
DocumentFile.fromTreeUri(this, uri)?.let { DocumentFile.fromTreeUri(this, uri)?.let {
@ -125,7 +125,7 @@ class BookSourceActivity : VMBaseActivity<ActivityBookSourceBinding, BookSourceV
GroupManageDialog().show(supportFragmentManager, "groupManage") GroupManageDialog().show(supportFragmentManager, "groupManage")
R.id.menu_import_local -> importDoc.launch( R.id.menu_import_local -> importDoc.launch(
FilePickerParam( FilePickerParam(
mode = FilePicker.FILE, mode = HandleFileContract.FILE,
allowExtensions = arrayOf("txt", "json") allowExtensions = arrayOf("txt", "json")
) )
) )

@ -28,7 +28,7 @@ import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.lib.theme.ATH import io.legado.app.lib.theme.ATH
import io.legado.app.lib.theme.accentColor import io.legado.app.lib.theme.accentColor
import io.legado.app.ui.document.FilePicker import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.* import io.legado.app.utils.*
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
@ -38,7 +38,7 @@ import splitties.init.appCtx
class BackupConfigFragment : BasePreferenceFragment(), class BackupConfigFragment : BasePreferenceFragment(),
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
private val selectBackupPath = registerForActivityResult(FilePicker()) { uri -> private val selectBackupPath = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
AppConfig.backupPath = uri.toString() AppConfig.backupPath = uri.toString()
@ -46,7 +46,7 @@ class BackupConfigFragment : BasePreferenceFragment(),
AppConfig.backupPath = uri.path AppConfig.backupPath = uri.path
} }
} }
private val backupDir = registerForActivityResult(FilePicker()) { uri -> private val backupDir = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
AppConfig.backupPath = uri.toString() AppConfig.backupPath = uri.toString()
@ -70,7 +70,7 @@ class BackupConfigFragment : BasePreferenceFragment(),
} }
} }
} }
private val restoreDir = registerForActivityResult(FilePicker()) { uri -> private val restoreDir = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
AppConfig.backupPath = uri.toString() AppConfig.backupPath = uri.toString()
@ -86,7 +86,7 @@ class BackupConfigFragment : BasePreferenceFragment(),
} }
} }
} }
private val restoreOld = registerForActivityResult(FilePicker()) { uri -> private val restoreOld = registerForActivityResult(HandleFileContract()) { uri ->
uri?.let { uri?.let {
ImportOldData.importUri(appCtx, uri) ImportOldData.importUri(appCtx, uri)
} }

@ -0,0 +1,63 @@
package io.legado.app.ui.config
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.databinding.DialogDirectLinkUploadConfigBinding
import io.legado.app.help.DirectLinkUpload
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.windowSize
import splitties.views.onClick
class DirectLinkUploadConfig : BaseDialogFragment() {
private val binding by viewBinding(DialogDirectLinkUploadConfigBinding::bind)
override fun onStart() {
super.onStart()
val dm = requireActivity().windowSize
dialog?.window?.setLayout(
(dm.widthPixels * 0.9).toInt(),
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.dialog_direct_link_upload_config, container)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.editUploadUrl.setText(DirectLinkUpload.getUploadUrl())
binding.editDownloadUrlRule.setText(DirectLinkUpload.getDownloadUrlRule())
binding.tvCancel.onClick {
dismiss()
}
binding.tvFooterLeft.onClick {
DirectLinkUpload.delete()
}
binding.tvOk.onClick {
val uploadUrl = binding.editUploadUrl.text?.toString()
val downloadUrlRule = binding.editDownloadUrlRule.text?.toString()
uploadUrl ?: let {
toastOnUi("上传Url不能为空")
return@onClick
}
downloadUrlRule ?: let {
toastOnUi("下载Url规则不能为空")
return@onClick
}
DirectLinkUpload.putUploadUrl(uploadUrl)
DirectLinkUpload.putDownloadUrlRule(downloadUrlRule)
dismiss()
}
}
}

@ -46,7 +46,7 @@ class FilePickerActivity :
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {
val mode = intent.getIntExtra("mode", 0) val mode = intent.getIntExtra("mode", 0)
val allowExtensions = intent.getStringArrayExtra("allowExtensions") val allowExtensions = intent.getStringArrayExtra("allowExtensions")
val selectList = if (mode == FilePicker.DIRECTORY) { val selectList = if (mode == HandleFileContract.DIRECTORY) {
arrayListOf(getString(R.string.sys_folder_picker)) arrayListOf(getString(R.string.sys_folder_picker))
} else { } else {
arrayListOf(getString(R.string.sys_file_picker)) arrayListOf(getString(R.string.sys_file_picker))
@ -58,7 +58,7 @@ class FilePickerActivity :
selectList.addAll(it) selectList.addAll(it)
} }
val title = intent.getStringExtra("title") ?: let { val title = intent.getStringExtra("title") ?: let {
if (mode == FilePicker.DIRECTORY) { if (mode == HandleFileContract.DIRECTORY) {
return@let getString(R.string.select_folder) return@let getString(R.string.select_folder)
} else { } else {
return@let getString(R.string.select_file) return@let getString(R.string.select_file)
@ -67,23 +67,23 @@ class FilePickerActivity :
alert(title) { alert(title) {
items(selectList) { _, index -> items(selectList) { _, index ->
when (index) { when (index) {
0 -> if (mode == FilePicker.DIRECTORY) { 0 -> if (mode == HandleFileContract.DIRECTORY) {
selectDocTree.launch(null) selectDocTree.launch(null)
} else { } else {
selectDoc.launch(typesOfExtensions(allowExtensions)) selectDoc.launch(typesOfExtensions(allowExtensions))
} }
1 -> if (mode == FilePicker.DIRECTORY) { 1 -> if (mode == HandleFileContract.DIRECTORY) {
checkPermissions { checkPermissions {
FilePickerDialog.show( FilePickerDialog.show(
supportFragmentManager, supportFragmentManager,
mode = FilePicker.DIRECTORY mode = HandleFileContract.DIRECTORY
) )
} }
} else { } else {
checkPermissions { checkPermissions {
FilePickerDialog.show( FilePickerDialog.show(
supportFragmentManager, supportFragmentManager,
mode = FilePicker.FILE, mode = HandleFileContract.FILE,
allowExtensions = allowExtensions allowExtensions = allowExtensions
) )
} }

@ -16,8 +16,8 @@ import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R import io.legado.app.R
import io.legado.app.databinding.DialogFileChooserBinding import io.legado.app.databinding.DialogFileChooserBinding
import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.document.FilePicker.Companion.DIRECTORY import io.legado.app.ui.document.HandleFileContract.Companion.DIRECTORY
import io.legado.app.ui.document.FilePicker.Companion.FILE import io.legado.app.ui.document.HandleFileContract.Companion.FILE
import io.legado.app.ui.document.adapter.FileAdapter import io.legado.app.ui.document.adapter.FileAdapter
import io.legado.app.ui.document.adapter.PathAdapter import io.legado.app.ui.document.adapter.PathAdapter
import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.ui.widget.recycler.VerticalDivider

@ -7,7 +7,7 @@ import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
@Suppress("unused") @Suppress("unused")
class FilePicker : ActivityResultContract<FilePickerParam, Uri?>() { class HandleFileContract : ActivityResultContract<FilePickerParam, Uri?>() {
companion object { companion object {
const val DIRECTORY = 0 const val DIRECTORY = 0

@ -74,16 +74,22 @@ class RuleUiLoginDialog : BaseDialogFragment() {
binding.toolBar.setOnMenuItemClickListener { item -> binding.toolBar.setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.menu_ok -> { R.id.menu_ok -> {
val loginData = hashMapOf<String, String?>() val loginData = hashMapOf<String, String>()
loginUi?.forEachIndexed { index, rowUi -> loginUi?.forEachIndexed { index, rowUi ->
when (rowUi.type) { when (rowUi.type) {
"text", "password" -> { "text", "password" -> {
val value = binding.root.findViewById<TextInputLayout>(index) val value = binding.root.findViewById<TextInputLayout>(index)
.findViewById<EditText>(R.id.editText).text?.toString() .findViewById<EditText>(R.id.editText).text?.toString()
if (!value.isNullOrBlank()) {
loginData[rowUi.name] = value loginData[rowUi.name] = value
} }
} }
} }
}
if (loginData.isEmpty()) {
bookSource.removeLoginInfo()
return@setOnMenuItemClickListener true
}
bookSource.putLoginInfo(GSON.toJson(loginData)) bookSource.putLoginInfo(GSON.toJson(loginData))
bookSource.getLoginJs()?.let { bookSource.getLoginJs()?.let {
try { try {

@ -18,8 +18,8 @@ import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.ui.book.group.GroupManageDialog import io.legado.app.ui.book.group.GroupManageDialog
import io.legado.app.ui.book.local.ImportBookActivity import io.legado.app.ui.book.local.ImportBookActivity
import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.main.MainViewModel import io.legado.app.ui.main.MainViewModel
import io.legado.app.utils.* import io.legado.app.utils.*
@ -28,7 +28,7 @@ abstract class BaseBookshelfFragment(layoutId: Int) : VMBaseFragment<BookshelfVi
val activityViewModel by activityViewModels<MainViewModel>() val activityViewModel by activityViewModels<MainViewModel>()
override val viewModel by viewModels<BookshelfViewModel>() override val viewModel by viewModels<BookshelfViewModel>()
private val importBookshelf = registerForActivityResult(FilePicker()) { private val importBookshelf = registerForActivityResult(HandleFileContract()) {
it?.readText(requireContext())?.let { text -> it?.readText(requireContext())?.let { text ->
viewModel.importBookshelf(text, groupId) viewModel.importBookshelf(text, groupId)
} }
@ -56,7 +56,7 @@ abstract class BaseBookshelfFragment(layoutId: Int) : VMBaseFragment<BookshelfVi
putExtra("groupId", groupId) putExtra("groupId", groupId)
} }
R.id.menu_export_bookshelf -> viewModel.exportBookshelf(books) { R.id.menu_export_bookshelf -> viewModel.exportBookshelf(books) {
activity?.share(it)
} }
R.id.menu_import_bookshelf -> importBookshelfAlert(groupId) R.id.menu_import_bookshelf -> importBookshelfAlert(groupId)
} }
@ -136,7 +136,7 @@ abstract class BaseBookshelfFragment(layoutId: Int) : VMBaseFragment<BookshelfVi
neutralButton(R.string.select_file) { neutralButton(R.string.select_file) {
importBookshelf.launch( importBookshelf.launch(
FilePickerParam( FilePickerParam(
mode = FilePicker.FILE, mode = HandleFileContract.FILE,
allowExtensions = arrayOf("txt", "json") allowExtensions = arrayOf("txt", "json")
) )
) )

@ -26,8 +26,8 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.ATH import io.legado.app.lib.theme.ATH
import io.legado.app.lib.theme.primaryTextColor import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.ui.association.ImportReplaceRuleDialog import io.legado.app.ui.association.ImportReplaceRuleDialog
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.qrcode.QrCodeResult
import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.ui.replace.edit.ReplaceEditActivity
import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.SelectActionBar
@ -69,7 +69,7 @@ class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRu
setResult(RESULT_OK) setResult(RESULT_OK)
} }
} }
private val importDoc = registerForActivityResult(FilePicker()) { uri -> private val importDoc = registerForActivityResult(HandleFileContract()) { uri ->
kotlin.runCatching { kotlin.runCatching {
uri?.readText(this)?.let { uri?.readText(this)?.let {
ImportReplaceRuleDialog.start(supportFragmentManager, it) ImportReplaceRuleDialog.start(supportFragmentManager, it)
@ -78,7 +78,7 @@ class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRu
toastOnUi("readTextError:${it.localizedMessage}") toastOnUi("readTextError:${it.localizedMessage}")
} }
} }
private val exportDir = registerForActivityResult(FilePicker()) { uri -> private val exportDir = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
DocumentFile.fromTreeUri(this, uri)?.let { DocumentFile.fromTreeUri(this, uri)?.let {
@ -215,7 +215,7 @@ class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRu
R.id.menu_import_onLine -> showImportDialog() R.id.menu_import_onLine -> showImportDialog()
R.id.menu_import_local -> importDoc.launch( R.id.menu_import_local -> importDoc.launch(
FilePickerParam( FilePickerParam(
mode = FilePicker.FILE, mode = HandleFileContract.FILE,
allowExtensions = arrayOf("txt", "json") allowExtensions = arrayOf("txt", "json")
) )
) )

@ -22,8 +22,8 @@ import io.legado.app.lib.theme.DrawableUtils
import io.legado.app.lib.theme.primaryTextColor import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.service.help.Download import io.legado.app.service.help.Download
import io.legado.app.ui.association.OnLineImportActivity import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.utils.* import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -42,7 +42,7 @@ class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>
private var ttsMenuItem: MenuItem? = null private var ttsMenuItem: MenuItem? = null
private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null
private var webPic: String? = null private var webPic: String? = null
private val saveImage = registerForActivityResult(FilePicker()) { private val saveImage = registerForActivityResult(HandleFileContract()) {
it ?: return@registerForActivityResult it ?: return@registerForActivityResult
ACache.get(this).put(imagePathKey, it.toString()) ACache.get(this).put(imagePathKey, it.toString())
viewModel.saveImage(webPic, it.toString()) viewModel.saveImage(webPic, it.toString())

@ -22,8 +22,8 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.ATH import io.legado.app.lib.theme.ATH
import io.legado.app.lib.theme.primaryTextColor import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.ui.association.ImportRssSourceDialog import io.legado.app.ui.association.ImportRssSourceDialog
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.ui.qrcode.QrCodeResult import io.legado.app.ui.qrcode.QrCodeResult
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.widget.SelectActionBar import io.legado.app.ui.widget.SelectActionBar
@ -57,7 +57,7 @@ class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceView
it ?: return@registerForActivityResult it ?: return@registerForActivityResult
ImportRssSourceDialog.start(supportFragmentManager, it) ImportRssSourceDialog.start(supportFragmentManager, it)
} }
private val importDoc = registerForActivityResult(FilePicker()) { uri -> private val importDoc = registerForActivityResult(HandleFileContract()) { uri ->
kotlin.runCatching { kotlin.runCatching {
uri?.readText(this)?.let { uri?.readText(this)?.let {
ImportRssSourceDialog.start(supportFragmentManager, it) ImportRssSourceDialog.start(supportFragmentManager, it)
@ -66,7 +66,7 @@ class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceView
toastOnUi("readTextError:${it.localizedMessage}") toastOnUi("readTextError:${it.localizedMessage}")
} }
} }
private val exportDir = registerForActivityResult(FilePicker()) { uri -> private val exportDir = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.isContentScheme()) { if (uri.isContentScheme()) {
DocumentFile.fromTreeUri(this, uri)?.let { DocumentFile.fromTreeUri(this, uri)?.let {
@ -103,7 +103,7 @@ class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceView
R.id.menu_add -> startActivity<RssSourceEditActivity>() R.id.menu_add -> startActivity<RssSourceEditActivity>()
R.id.menu_import_local -> importDoc.launch( R.id.menu_import_local -> importDoc.launch(
FilePickerParam( FilePickerParam(
mode = FilePicker.FILE, mode = HandleFileContract.FILE,
allowExtensions = arrayOf("txt", "json") allowExtensions = arrayOf("txt", "json")
) )
) )

@ -19,8 +19,8 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.lib.theme.primaryColor import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.document.FilePicker
import io.legado.app.ui.document.FilePickerParam import io.legado.app.ui.document.FilePickerParam
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.utils.* import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
@ -39,7 +39,7 @@ class FontSelectDialog : BaseDialogFragment(),
} }
private var adapter: FontAdapter? = null private var adapter: FontAdapter? = null
private val binding by viewBinding(DialogFontSelectBinding::bind) private val binding by viewBinding(DialogFontSelectBinding::bind)
private val selectFontDir = registerForActivityResult(FilePicker()) { uri -> private val selectFontDir = registerForActivityResult(HandleFileContract()) { uri ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
if (uri.toString().isContentScheme()) { if (uri.toString().isContentScheme()) {
putPrefString(PreferKey.fontFolder, uri.toString()) putPrefString(PreferKey.fontFolder, uri.toString())

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/tool_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="?attr/actionBarStyle"
app:title="直链上传配置"
app:popupTheme="@style/AppTheme.PopupOverlay"
app:titleTextAppearance="@style/ToolbarTitle" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<io.legado.app.ui.widget.text.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="3dp">
<io.legado.app.ui.widget.text.EditText
android:id="@+id/edit_upload_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/upload_url"
tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
</io.legado.app.ui.widget.text.TextInputLayout>
<io.legado.app.ui.widget.text.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="3dp">
<io.legado.app.ui.widget.text.EditText
android:id="@+id/edit_download_url_rule"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/download_url_rule"
tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
</io.legado.app.ui.widget.text.TextInputLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.flexbox.FlexboxLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="12dp"
android:paddingRight="12dp"
app:flexWrap="wrap"
app:justifyContent="space_between">
<io.legado.app.ui.widget.text.AccentTextView
android:id="@+id/tv_footer_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:text="@string/delete"
tools:ignore="RtlHardcoded" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<io.legado.app.ui.widget.text.AccentTextView
android:id="@+id/tv_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:text="@string/cancel"
tools:ignore="RtlHardcoded" />
<io.legado.app.ui.widget.text.AccentTextView
android:id="@+id/tv_ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:text="@string/ok"
tools:ignore="RtlHardcoded" />
</LinearLayout>
</com.google.android.flexbox.FlexboxLayout>
</LinearLayout>

@ -864,4 +864,6 @@
<string name="background_image_hint">Disabled when 0, enable range from 1 to 25\nThe greater the radius, the stronger the effect of blurring</string> <string name="background_image_hint">Disabled when 0, enable range from 1 to 25\nThe greater the radius, the stronger the effect of blurring</string>
<string name="need_login">需登录</string> <string name="need_login">需登录</string>
<string name="pref_cronet_summary">使用Cronet网络组件</string> <string name="pref_cronet_summary">使用Cronet网络组件</string>
<string name="upload_url">上传URL</string>
<string name="download_url_rule">下载URL规则</string>
</resources> </resources>

@ -864,4 +864,6 @@
<string name="background_image_hint">Disabled when 0, enable range from 1 to 25\nThe greater the radius, the stronger the effect of blurring</string> <string name="background_image_hint">Disabled when 0, enable range from 1 to 25\nThe greater the radius, the stronger the effect of blurring</string>
<string name="need_login">需登录</string> <string name="need_login">需登录</string>
<string name="pref_cronet_summary">使用Cronet网络组件</string> <string name="pref_cronet_summary">使用Cronet网络组件</string>
<string name="upload_url">上传URL</string>
<string name="download_url_rule">下载URL规则</string>
</resources> </resources>

@ -864,5 +864,7 @@
<string name="background_image_hint">Desativado quando 0, Ativado entre 1 e 25\n Quanto maior o raio, mais forte o efeito de desfocagem</string> <string name="background_image_hint">Desativado quando 0, Ativado entre 1 e 25\n Quanto maior o raio, mais forte o efeito de desfocagem</string>
<string name="need_login">Login necessário</string> <string name="need_login">Login necessário</string>
<string name="pref_cronet_summary">Usando componentes de rede Cronet</string> <string name="pref_cronet_summary">Usando componentes de rede Cronet</string>
<string name="upload_url">上传URL</string>
<string name="download_url_rule">下载URL规则</string>
</resources> </resources>

@ -861,5 +861,7 @@
<string name="check_source_show_debug_message_summary">書源校驗時顯示網絡請求步驟和時間</string> <string name="check_source_show_debug_message_summary">書源校驗時顯示網絡請求步驟和時間</string>
<string name="need_login">需登录</string> <string name="need_login">需登录</string>
<string name="pref_cronet_summary">使用Cronet网络组件</string> <string name="pref_cronet_summary">使用Cronet网络组件</string>
<string name="upload_url">上传URL</string>
<string name="download_url_rule">下载URL规则</string>
</resources> </resources>

@ -862,5 +862,7 @@
<string name="check_source_show_debug_message_summary">書源校驗時顯示網絡請求步驟和時間</string> <string name="check_source_show_debug_message_summary">書源校驗時顯示網絡請求步驟和時間</string>
<string name="need_login">需登录</string> <string name="need_login">需登录</string>
<string name="pref_cronet_summary">使用Cronet网络组件</string> <string name="pref_cronet_summary">使用Cronet网络组件</string>
<string name="upload_url">上传URL</string>
<string name="download_url_rule">下载URL规则</string>
</resources> </resources>

@ -863,5 +863,7 @@
<string name="check_source_show_debug_message_summary">书源校验时显示网络请求步骤和时间</string> <string name="check_source_show_debug_message_summary">书源校验时显示网络请求步骤和时间</string>
<string name="need_login">需登录</string> <string name="need_login">需登录</string>
<string name="pref_cronet_summary">使用Cronet网络组件</string> <string name="pref_cronet_summary">使用Cronet网络组件</string>
<string name="upload_url">上传URL</string>
<string name="download_url_rule">下载URL规则</string>
</resources> </resources>

@ -864,5 +864,7 @@
<string name="check_source_show_debug_message_summary">Show network status and timestamp during source checking</string> <string name="check_source_show_debug_message_summary">Show network status and timestamp during source checking</string>
<string name="need_login">需登录</string> <string name="need_login">需登录</string>
<string name="pref_cronet_summary">使用Cronet网络组件</string> <string name="pref_cronet_summary">使用Cronet网络组件</string>
<string name="upload_url">上传URL</string>
<string name="download_url_rule">下载URL规则</string>
</resources> </resources>

Loading…
Cancel
Save