本地书籍无权限则保存到自己选定的文件夹

pull/1486/head
gedoor 3 years ago
parent 448e25c04d
commit c3295483e4
  1. 1
      app/src/main/AndroidManifest.xml
  2. 1
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 10
      app/src/main/java/io/legado/app/help/AppConfig.kt
  4. 90
      app/src/main/java/io/legado/app/help/BookMediaStore.kt
  5. 16
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  6. 83
      app/src/main/java/io/legado/app/help/storage/BackupRestore.kt
  7. 70
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  8. 2
      app/src/main/java/io/legado/app/lib/permission/Permissions.kt
  9. 1
      app/src/main/java/io/legado/app/model/localBook/LocalBook.kt
  10. 81
      app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt
  11. 52
      app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt
  12. 14
      app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt

@ -4,6 +4,7 @@
package="io.legado.app">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />

@ -90,6 +90,7 @@ object PreferKey {
const val expandTextMenu = "expandTextMenu"
const val doublePageHorizontal = "doublePageHorizontal"
const val readUrlOpenInBrowser = "readUrlInBrowser"
const val defaultBookTreeUri = "defaultBookTreeUri"
const val cPrimary = "colorPrimary"
const val cAccent = "colorAccent"

@ -133,6 +133,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
}
var defaultBookTreeUri: String?
get() = appCtx.getPrefString(PreferKey.defaultBookTreeUri)
set(value) {
if (value.isNullOrEmpty()) {
appCtx.removePref(PreferKey.defaultBookTreeUri)
} else {
appCtx.putPrefString(PreferKey.defaultBookTreeUri, value)
}
}
val showDiscovery: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true)

@ -1,90 +0,0 @@
package io.legado.app.help
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import io.legado.app.constant.AppConst
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils.getMimeType
import splitties.init.appCtx
import java.io.File
import java.util.*
object BookMediaStore {
private val DOWNLOAD_DIR =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
fun insertBook(doc: DocumentFile): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val bookDetails = ContentValues().apply {
put(MediaStore.Downloads.RELATIVE_PATH, "Download${File.separator}books")
put(MediaStore.MediaColumns.DISPLAY_NAME, doc.name)
put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(doc.name!!))
put(MediaStore.MediaColumns.SIZE, doc.length())
}
appCtx.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, bookDetails)
} else {
val destinyFile = File(DOWNLOAD_DIR, doc.name!!)
FileProvider.getUriForFile(appCtx, AppConst.authority, destinyFile)
}?.also { uri ->
appCtx.contentResolver.openOutputStream(uri).use { outputStream ->
val brr = ByteArray(1024)
var len: Int
val bufferedInputStream = appCtx.contentResolver.openInputStream(doc.uri)!!
while ((bufferedInputStream.read(brr, 0, brr.size).also { len = it }) != -1) {
outputStream?.write(brr, 0, len)
}
outputStream?.flush()
bufferedInputStream.close()
}
}
}
fun getBook(name: String): FileDoc? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val projection = arrayOf(
MediaStore.Downloads._ID,
MediaStore.Downloads.DISPLAY_NAME,
MediaStore.Downloads.SIZE,
MediaStore.Downloads.DATE_MODIFIED
)
val selection =
"${MediaStore.Downloads.RELATIVE_PATH} like 'Download${File.separator}books${File.separator}%'"
val sortOrder = "${MediaStore.Downloads.DISPLAY_NAME} ASC"
appCtx.contentResolver.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
projection,
selection,
emptyArray(),
sortOrder
)?.use {
val idColumn = it.getColumnIndex(projection[0])
val nameColumn = it.getColumnIndex(projection[1])
val sizeColumn = it.getColumnIndex(projection[2])
val dateColumn = it.getColumnIndex(projection[3])
if (it.moveToNext()) {
val id = it.getLong(idColumn)
return FileDoc(
name = it.getString(nameColumn),
isDir = false,
size = it.getLong(sizeColumn),
date = Date(it.getLong(dateColumn)),
uri = ContentUris.withAppendedId(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
id
)
)
}
}
}
return null
}
}

@ -20,7 +20,7 @@ import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
object Backup {
object Backup : BackupRestore() {
val backupPath: String by lazy {
appCtx.filesDir.getFile("backup").absolutePath
@ -90,12 +90,14 @@ object Backup {
Preferences.getSharedPreferences(appCtx, backupPath, "config")?.let { sp ->
val edit = sp.edit()
appCtx.defaultSharedPreferences.all.forEach { (key, value) ->
when (value) {
is Int -> edit.putInt(key, value)
is Boolean -> edit.putBoolean(key, value)
is Long -> edit.putLong(key, value)
is Float -> edit.putFloat(key, value)
is String -> edit.putString(key, value)
if (keyIsNotIgnore(key)) {
when (value) {
is Int -> edit.putInt(key, value)
is Boolean -> edit.putBoolean(key, value)
is Long -> edit.putLong(key, value)
is Float -> edit.putFloat(key, value)
is String -> edit.putString(key, value)
}
}
}
edit.commit()

@ -0,0 +1,83 @@
package io.legado.app.help.storage
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import splitties.init.appCtx
abstract class BackupRestore {
private val ignoreConfigPath = FileUtils.getPath(appCtx.filesDir, "restoreIgnore.json")
val ignoreConfig: HashMap<String, Boolean> by lazy {
val file = FileUtils.createFileIfNotExist(ignoreConfigPath)
val json = file.readText()
GSON.fromJsonObject<HashMap<String, Boolean>>(json) ?: hashMapOf()
}
//忽略key
val ignoreKeys = arrayOf(
"readConfig",
PreferKey.themeMode,
PreferKey.bookshelfLayout,
PreferKey.showRss,
PreferKey.threadCount,
PreferKey.defaultBookTreeUri
)
//忽略标题
val ignoreTitle = arrayOf(
appCtx.getString(R.string.read_config),
appCtx.getString(R.string.theme_mode),
appCtx.getString(R.string.bookshelf_layout),
appCtx.getString(R.string.show_rss),
appCtx.getString(R.string.thread_count)
)
//默认忽略keys
private val ignorePrefKeys = arrayOf(
PreferKey.themeMode,
PreferKey.defaultCover,
PreferKey.defaultCoverDark
)
//阅读配置
private val readPrefKeys = arrayOf(
PreferKey.readStyleSelect,
PreferKey.shareLayout,
PreferKey.hideStatusBar,
PreferKey.hideNavigationBar,
PreferKey.autoReadSpeed
)
protected fun keyIsNotIgnore(key: String): Boolean {
return when {
ignorePrefKeys.contains(key) -> false
readPrefKeys.contains(key) && ignoreReadConfig -> false
PreferKey.themeMode == key && ignoreThemeMode -> false
PreferKey.bookshelfLayout == key && ignoreBookshelfLayout -> false
PreferKey.showRss == key && ignoreShowRss -> false
PreferKey.threadCount == key && ignoreThreadCount -> false
else -> true
}
}
protected val ignoreReadConfig: Boolean
get() = ignoreConfig["readConfig"] == true
private val ignoreThemeMode: Boolean
get() = ignoreConfig[PreferKey.themeMode] == true
private val ignoreBookshelfLayout: Boolean
get() = ignoreConfig[PreferKey.bookshelfLayout] == true
private val ignoreShowRss: Boolean
get() = ignoreConfig[PreferKey.showRss] == true
private val ignoreThreadCount: Boolean
get() = ignoreConfig[PreferKey.threadCount] == true
fun saveIgnoreConfig() {
val json = GSON.toJson(ignoreConfig)
FileUtils.createFileIfNotExist(ignoreConfigPath).writeText(json)
}
}

@ -24,47 +24,7 @@ import timber.log.Timber
import java.io.File
object Restore {
private val ignoreConfigPath = FileUtils.getPath(appCtx.filesDir, "restoreIgnore.json")
val ignoreConfig: HashMap<String, Boolean> by lazy {
val file = FileUtils.createFileIfNotExist(ignoreConfigPath)
val json = file.readText()
GSON.fromJsonObject<HashMap<String, Boolean>>(json) ?: hashMapOf()
}
//忽略key
val ignoreKeys = arrayOf(
"readConfig",
PreferKey.themeMode,
PreferKey.bookshelfLayout,
PreferKey.showRss,
PreferKey.threadCount
)
//忽略标题
val ignoreTitle = arrayOf(
appCtx.getString(R.string.read_config),
appCtx.getString(R.string.theme_mode),
appCtx.getString(R.string.bookshelf_layout),
appCtx.getString(R.string.show_rss),
appCtx.getString(R.string.thread_count)
)
//默认忽略keys
private val ignorePrefKeys = arrayOf(
PreferKey.themeMode,
PreferKey.defaultCover,
PreferKey.defaultCoverDark
)
//阅读配置
private val readPrefKeys = arrayOf(
PreferKey.readStyleSelect,
PreferKey.shareLayout,
PreferKey.hideStatusBar,
PreferKey.hideNavigationBar,
PreferKey.autoReadSpeed
)
object Restore : BackupRestore() {
suspend fun restore(context: Context, path: String) {
withContext(IO) {
@ -229,34 +189,6 @@ object Restore {
}
}
private fun keyIsNotIgnore(key: String): Boolean {
return when {
ignorePrefKeys.contains(key) -> false
readPrefKeys.contains(key) && ignoreReadConfig -> false
PreferKey.themeMode == key && ignoreThemeMode -> false
PreferKey.bookshelfLayout == key && ignoreBookshelfLayout -> false
PreferKey.showRss == key && ignoreShowRss -> false
PreferKey.threadCount == key && ignoreThreadCount -> false
else -> true
}
}
private val ignoreReadConfig: Boolean
get() = ignoreConfig["readConfig"] == true
private val ignoreThemeMode: Boolean
get() = ignoreConfig[PreferKey.themeMode] == true
private val ignoreBookshelfLayout: Boolean
get() = ignoreConfig[PreferKey.bookshelfLayout] == true
private val ignoreShowRss: Boolean
get() = ignoreConfig[PreferKey.showRss] == true
private val ignoreThreadCount: Boolean
get() = ignoreConfig[PreferKey.threadCount] == true
fun saveIgnoreConfig() {
val json = GSON.toJson(ignoreConfig)
FileUtils.createFileIfNotExist(ignoreConfigPath).writeText(json)
}
private inline fun <reified T> fileToListT(path: String, fileName: String): List<T>? {
try {
val file = FileUtils.createFileIfNotExist(path + File.separator + fileName)

@ -36,6 +36,8 @@ object Permissions {
const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE"
const val WRITE_EXTERNAL_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE"
const val ACCESS_MEDIA_LOCATION = "android.permission.ACCESS_MEDIA_LOCATION"
object Group {
val STORAGE = arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE)

@ -20,6 +20,7 @@ import java.util.regex.Pattern
import javax.script.SimpleBindings
object LocalBook {
private const val folderName = "bookTxt"
val cacheFolder: File by lazy {
FileUtils.createFolderIfNotExist(appCtx.externalFiles, folderName)

@ -1,25 +1,60 @@
package io.legado.app.ui.association
import android.net.Uri
import android.os.Bundle
import androidx.activity.viewModels
import androidx.documentfile.provider.DocumentFile
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.databinding.ActivityTranslucenceBinding
import io.legado.app.help.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.ui.document.HandleFileContract
import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FileAssociationActivity :
VMBaseActivity<ActivityTranslucenceBinding, FileAssociationViewModel>() {
private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
it.uri?.let { treeUri ->
intent.data?.let { uri ->
importBook(treeUri, uri)
}
}
}
override val binding by viewBinding(ActivityTranslucenceBinding::inflate)
override val viewModel by viewModels<FileAssociationViewModel>()
override fun onActivityCreated(savedInstanceState: Bundle?) {
binding.rotateLoading.show()
viewModel.importBookLiveData.observe(this) { uri ->
if (uri.isContentScheme()) {
val treeUriStr = AppConfig.defaultBookTreeUri
if (treeUriStr.isNullOrEmpty()) {
localBookTreeSelect.launch {
title = "选择保存书籍的文件夹"
}
} else {
importBook(Uri.parse(treeUriStr), uri)
}
} else {
PermissionsCompat.Builder(this)
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
viewModel.importBook(uri)
}.request()
}
}
viewModel.onLineImportLive.observe(this) {
startActivity<OnLineImportActivity> {
data = it
@ -38,23 +73,55 @@ class FileAssociationActivity :
binding.rotateLoading.hide()
showDialogFragment(ImportReplaceRuleDialog(it, true))
}
viewModel.errorLiveData.observe(this, {
viewModel.errorLiveData.observe(this) {
binding.rotateLoading.hide()
toastOnUi(it)
finish()
})
viewModel.openBookLiveData.observe(this, {
}
viewModel.openBookLiveData.observe(this) {
binding.rotateLoading.hide()
startActivity<ReadBookActivity> {
putExtra("bookUrl", it)
}
finish()
})
}
intent.data?.let { data ->
viewModel.dispatchIndent(data, this::finallyDialog)
}
}
private fun importBook(treeUri: Uri, uri: Uri) {
val treeDoc = DocumentFile.fromTreeUri(this, treeUri)
val bookDoc = DocumentFile.fromSingleUri(this, uri)
launch {
runCatching {
withContext(IO) {
val name = bookDoc?.name!!
val doc = treeDoc!!.findFile(name)
if (doc != null) {
viewModel.importBook(doc.uri)
} else {
val nDoc = treeDoc.createFile(FileUtils.getMimeType(name), name)!!
contentResolver.openOutputStream(nDoc.uri)!!.use { oStream ->
contentResolver.openInputStream(bookDoc.uri)!!.use { iStream ->
val brr = ByteArray(1024)
var len: Int
while ((iStream.read(brr, 0, brr.size)
.also { len = it }) != -1
) {
oStream.write(brr, 0, len)
}
oStream.flush()
}
}
}
}
}.onFailure {
toastOnUi(it.localizedMessage)
}
}
}
private fun finallyDialog(title: String, msg: String) {
alert(title, msg) {
okButton()

@ -1,30 +1,24 @@
package io.legado.app.ui.association
import android.app.Application
import android.app.RecoverableSecurityException
import android.content.IntentSender
import android.net.Uri
import android.os.Build
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import io.legado.app.help.BookMediaStore
import io.legado.app.model.NoStackTraceException
import io.legado.app.model.localBook.LocalBook
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.isJson
import io.legado.app.utils.readText
import splitties.init.appCtx
import timber.log.Timber
import java.io.File
class FileAssociationViewModel(application: Application) : BaseAssociationViewModel(application) {
val importBookLiveData = MutableLiveData<Uri>()
val onLineImportLive = MutableLiveData<Uri>()
val importBookSourceLive = MutableLiveData<String>()
val importRssSourceLive = MutableLiveData<String>()
val importReplaceRuleLive = MutableLiveData<String>()
val openBookLiveData = MutableLiveData<String>()
val errorLiveData = MutableLiveData<String>()
val recoverErrorLiveData = MutableLiveData<IntentSender>()
@Suppress("BlockingMethodInNonBlockingContext")
fun dispatchIndent(uri: Uri, finally: (title: String, msg: String) -> Unit) {
@ -54,51 +48,19 @@ class FileAssociationViewModel(application: Application) : BaseAssociationViewMo
else -> errorLiveData.postValue("格式不对")
}
} else {
if (uri.isContentScheme()) {
val doc = DocumentFile.fromSingleUri(appCtx, uri)!!
val bookDoc = BookMediaStore.getBook(doc.name!!)
if (bookDoc == null) {
val bookUri = BookMediaStore.insertBook(doc)
val book = LocalBook.importFile(bookUri!!)
openBookLiveData.postValue(book.bookUrl)
} else {
if (doc.lastModified() > bookDoc.date.time) {
context.contentResolver.openOutputStream(bookDoc.uri)
.use { outputStream ->
val brr = ByteArray(1024)
var len: Int
val bufferedInputStream =
appCtx.contentResolver.openInputStream(doc.uri)!!
while ((bufferedInputStream.read(brr, 0, brr.size)
.also { len = it }) != -1
) {
outputStream?.write(brr, 0, len)
}
outputStream?.flush()
bufferedInputStream.close()
}
}
val book = LocalBook.importFile(bookDoc.uri)
openBookLiveData.postValue(book.bookUrl)
}
} else {
val book = LocalBook.importFile(uri)
openBookLiveData.postValue(book.bookUrl)
}
importBookLiveData.postValue(uri)
}
} else {
onLineImportLive.postValue(uri)
}
}.onError {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (it is RecoverableSecurityException) {
val intentSender = it.userAction.actionIntent.intentSender
recoverErrorLiveData.postValue(intentSender)
return@onError
}
}
Timber.e(it)
errorLiveData.postValue(it.localizedMessage)
}
}
fun importBook(uri: Uri) {
val book = LocalBook.importFile(uri)
openBookLiveData.postValue(book.bookUrl)
}
}

@ -207,7 +207,7 @@ class BackupConfigFragment : BasePreferenceFragment(),
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
when (preference?.key) {
PreferKey.backupPath -> selectBackupPath.launch()
PreferKey.restoreIgnore -> restoreIgnore()
PreferKey.restoreIgnore -> backupIgnore()
"web_dav_backup" -> backup()
"web_dav_restore" -> restore()
"import_old" -> restoreOld.launch()
@ -216,16 +216,16 @@ class BackupConfigFragment : BasePreferenceFragment(),
}
private fun restoreIgnore() {
val checkedItems = BooleanArray(Restore.ignoreKeys.size) {
Restore.ignoreConfig[Restore.ignoreKeys[it]] ?: false
private fun backupIgnore() {
val checkedItems = BooleanArray(Backup.ignoreKeys.size) {
Backup.ignoreConfig[Backup.ignoreKeys[it]] ?: false
}
alert(R.string.restore_ignore) {
multiChoiceItems(Restore.ignoreTitle, checkedItems) { _, which, isChecked ->
Restore.ignoreConfig[Restore.ignoreKeys[which]] = isChecked
multiChoiceItems(Backup.ignoreTitle, checkedItems) { _, which, isChecked ->
Backup.ignoreConfig[Backup.ignoreKeys[which]] = isChecked
}
onDismiss {
Restore.saveIgnoreConfig()
Backup.saveIgnoreConfig()
}
}
}

Loading…
Cancel
Save