Refactor local storage manager
This commit is contained in:
@@ -8,10 +8,10 @@ import android.widget.BaseAdapter
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.utils.ext.inflate
|
||||
import java.io.File
|
||||
|
||||
@@ -20,15 +20,18 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
|
||||
fun show() = delegate.show()
|
||||
|
||||
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
|
||||
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
|
||||
|
||||
private val adapter = VolumesAdapter(context)
|
||||
private val adapter = VolumesAdapter(storageManager)
|
||||
private val delegate = MaterialAlertDialogBuilder(context)
|
||||
|
||||
init {
|
||||
if (adapter.isEmpty) {
|
||||
delegate.setMessage(R.string.cannot_find_available_storage)
|
||||
} else {
|
||||
val defaultValue = runBlocking {
|
||||
storageManager.getDefaultWriteableDir()
|
||||
}
|
||||
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
|
||||
it.first.canonicalPath == defaultValue?.canonicalPath
|
||||
}
|
||||
@@ -57,10 +60,10 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
fun create() = StorageSelectDialog(delegate.create())
|
||||
}
|
||||
|
||||
private class VolumesAdapter(context: Context) : BaseAdapter() {
|
||||
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
|
||||
|
||||
var selectedItemPosition: Int = -1
|
||||
val volumes = getAvailableVolumes(context)
|
||||
val volumes = getAvailableVolumes(storageManager)
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: parent.inflate(R.layout.item_storage)
|
||||
@@ -82,9 +85,11 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
|
||||
override fun hasStableIds() = true
|
||||
|
||||
private fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
|
||||
return LocalMangaRepository.getAvailableStorageDirs(context).map {
|
||||
it to it.getStorageName(context)
|
||||
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
|
||||
return runBlocking {
|
||||
storageManager.getWriteableDirs().map {
|
||||
it to storageManager.getStorageDisplayName(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.delegates.prefs.*
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
@@ -115,14 +114,14 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
fun getStorageDir(context: Context): File? {
|
||||
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
fun getFallbackStorageDir(): File? {
|
||||
return prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
File(it)
|
||||
}?.takeIf { it.exists() && it.canWrite() }
|
||||
return value ?: LocalMangaRepository.getFallbackStorageDir(context)
|
||||
}?.takeIf { it.exists() }
|
||||
}
|
||||
|
||||
fun setStorageDir(context: Context, file: File?) {
|
||||
@Deprecated("Use LocalStorageManager instead")
|
||||
fun setStorageDir(file: File?) {
|
||||
prefs.edit {
|
||||
if (file == null) {
|
||||
remove(KEY_LOCAL_STORAGE)
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -30,7 +29,6 @@ import java.io.File
|
||||
|
||||
class DownloadManager(
|
||||
private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
@@ -50,7 +48,7 @@ class DownloadManager(
|
||||
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int) = flow<State> {
|
||||
emit(State.Preparing(startId, manga, null))
|
||||
var cover: Drawable? = null
|
||||
val destination = settings.getStorageDir(context)
|
||||
val destination = localMangaRepository.getOutputDir()
|
||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||
var output: MangaZip? = null
|
||||
try {
|
||||
|
||||
@@ -53,7 +53,7 @@ class DownloadService : BaseService() {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
|
||||
downloadManager = DownloadManager(this, get(), get(), get(), get())
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
||||
|
||||
val localModule
|
||||
get() = module {
|
||||
|
||||
single { LocalMangaRepository(androidContext()) }
|
||||
single { LocalStorageManager(androidContext(), get()) }
|
||||
single { LocalMangaRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
|
||||
|
||||
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import java.io.File
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
|
||||
class LocalStorageManager(
|
||||
private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
suspend fun getReadableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
|
||||
getConfiguredStorageDirs()
|
||||
.filter { it.isReadable() }
|
||||
}
|
||||
|
||||
suspend fun getWriteableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
|
||||
getConfiguredStorageDirs()
|
||||
.filter { it.isWriteable() }
|
||||
}
|
||||
|
||||
suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) {
|
||||
val preferredDir = settings.getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
||||
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
||||
}
|
||||
|
||||
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
||||
|
||||
@WorkerThread
|
||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||
val set = getAvailableStorageDirs()
|
||||
settings.getFallbackStorageDir()?.let {
|
||||
set.add(it)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getAvailableStorageDirs(): MutableSet<File> {
|
||||
val result = LinkedHashSet<File>()
|
||||
result += File(context.filesDir, DIR_NAME)
|
||||
result += context.getExternalFilesDirs(DIR_NAME)
|
||||
result.retainAll { it.exists() || it.mkdirs() }
|
||||
return result
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getFallbackStorageDir(): File? {
|
||||
return context.getExternalFilesDir(DIR_NAME) ?: File(context.filesDir, DIR_NAME).takeIf {
|
||||
it.exists() || it.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.isReadable() = runCatching {
|
||||
canRead()
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun File.isWriteable() = runCatching {
|
||||
canWrite()
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.local.domain
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.collection.ArraySet
|
||||
@@ -9,20 +8,23 @@ import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.readText
|
||||
import org.koitharu.kotatsu.utils.ext.toCamelCase
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
|
||||
|
||||
override val source = MangaSource.LOCAL
|
||||
private val filenameFilter = CbzFilter()
|
||||
@@ -149,24 +151,26 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): Manga? = runInterruptible(Dispatchers.IO) {
|
||||
suspend fun findSavedManga(remoteManga: Manga): Manga? {
|
||||
val files = getAllFiles()
|
||||
for (file in files) {
|
||||
val index = ZipFile(file).use { zip ->
|
||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
} ?: continue
|
||||
val info = index.getMangaInfo() ?: continue
|
||||
if (info.id == remoteManga.id) {
|
||||
val fileUri = file.toUri().toString()
|
||||
return@runInterruptible info.copy(
|
||||
source = MangaSource.LOCAL,
|
||||
url = fileUri,
|
||||
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
||||
)
|
||||
return runInterruptible(Dispatchers.IO) {
|
||||
for (file in files) {
|
||||
val index = ZipFile(file).use { zip ->
|
||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
} ?: continue
|
||||
val info = index.getMangaInfo() ?: continue
|
||||
if (info.id == remoteManga.id) {
|
||||
val fileUri = file.toUri().toString()
|
||||
return@runInterruptible info.copy(
|
||||
source = MangaSource.LOCAL,
|
||||
url = fileUri,
|
||||
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun zipUri(file: File, entryName: String) =
|
||||
@@ -193,32 +197,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
|
||||
override suspend fun getTags() = emptySet<MangaTag>()
|
||||
|
||||
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
|
||||
suspend fun getOutputDir(): File? {
|
||||
return storageManager.getDefaultWriteableDir()
|
||||
}
|
||||
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
|
||||
dir.listFiles(filenameFilter)?.toList().orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
|
||||
fun getAvailableStorageDirs(context: Context): List<File> {
|
||||
val result = ArrayList<File?>(5)
|
||||
result += File(context.filesDir, DIR_NAME)
|
||||
result += context.getExternalFilesDirs(DIR_NAME)
|
||||
return result.filterNotNull()
|
||||
.distinctBy { it.canonicalPath }
|
||||
.filter { it.exists() || it.mkdir() }
|
||||
}
|
||||
|
||||
fun getFallbackStorageDir(context: Context): File? {
|
||||
return context.getExternalFilesDir(DIR_NAME) ?: context.filesDir.sub(DIR_NAME).takeIf {
|
||||
(it.exists() || it.mkdir()) && it.canWrite()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,12 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class LocalListViewModel(
|
||||
private val repository: LocalMangaRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
settings: AppSettings,
|
||||
private val shortcutsRepository: ShortcutsRepository,
|
||||
) : MangaListViewModel(settings) {
|
||||
|
||||
@@ -77,10 +76,10 @@ class LocalListViewModel(
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = contentResolver.resolveName(uri)
|
||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||
if (!repository.isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = settings.getStorageDir(context)?.let { File(it, name) }
|
||||
val dest = repository.getOutputDir()
|
||||
?: throw IOException("External files dir unavailable")
|
||||
runInterruptible {
|
||||
contentResolver.openInputStream(uri)?.use { source ->
|
||||
|
||||
@@ -12,18 +12,22 @@ import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreference
|
||||
import kotlinx.coroutines.launch
|
||||
import leakcanary.LeakCanary
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||
import org.koitharu.kotatsu.settings.utils.SliderPreference
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import org.koitharu.kotatsu.utils.ext.names
|
||||
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
@@ -32,6 +36,8 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
StorageSelectDialog.OnStorageSelectListener {
|
||||
|
||||
private val storageManager by inject<LocalStorageManager>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
@@ -70,10 +76,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.run {
|
||||
summary = settings.getStorageDir(context)?.getStorageName(context)
|
||||
?: getString(R.string.not_available)
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
|
||||
!settings.appPassword.isNullOrEmpty()
|
||||
settings.subscribe(this)
|
||||
@@ -114,10 +117,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required)
|
||||
}
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
findPreference<Preference>(key)?.run {
|
||||
summary = settings.getStorageDir(context)?.getStorageName(context)
|
||||
?: getString(R.string.not_available)
|
||||
}
|
||||
findPreference<Preference>(key)?.bindStorageName()
|
||||
}
|
||||
AppSettings.KEY_APP_PASSWORD -> {
|
||||
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)
|
||||
@@ -140,7 +140,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
val ctx = context ?: return false
|
||||
StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx), this)
|
||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
||||
.setTitle(preference.title ?: "")
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.create()
|
||||
@@ -162,7 +162,13 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
}
|
||||
|
||||
override fun onStorageSelected(file: File) {
|
||||
settings.setStorageDir(context ?: return, file)
|
||||
settings.setStorageDir(file)
|
||||
}
|
||||
|
||||
private fun Preference.bindStorageName() {
|
||||
viewLifecycleScope.launch {
|
||||
val storage = storageManager.getDefaultWriteableDir()
|
||||
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user