Refactor local storage manager

pull/104/head
Koitharu 4 years ago
parent 02980ea1e6
commit 51d6a073e0
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -8,10 +8,10 @@ import android.widget.BaseAdapter
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.inflate import org.koitharu.kotatsu.utils.ext.inflate
import java.io.File import java.io.File
@ -20,15 +20,18 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
fun show() = delegate.show() 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) private val delegate = MaterialAlertDialogBuilder(context)
init { init {
if (adapter.isEmpty) { if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage) delegate.setMessage(R.string.cannot_find_available_storage)
} else { } else {
val defaultValue = runBlocking {
storageManager.getDefaultWriteableDir()
}
adapter.selectedItemPosition = adapter.volumes.indexOfFirst { adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath it.first.canonicalPath == defaultValue?.canonicalPath
} }
@ -57,10 +60,10 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
fun create() = StorageSelectDialog(delegate.create()) fun create() = StorageSelectDialog(delegate.create())
} }
private class VolumesAdapter(context: Context) : BaseAdapter() { private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
var selectedItemPosition: Int = -1 var selectedItemPosition: Int = -1
val volumes = getAvailableVolumes(context) val volumes = getAvailableVolumes(storageManager)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage) 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 override fun hasStableIds() = true
private fun getAvailableVolumes(context: Context): List<Pair<File, String>> { private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
return LocalMangaRepository.getAvailableStorageDirs(context).map { return runBlocking {
it to it.getStorageName(context) 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.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.* import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File import java.io.File
import java.text.DateFormat import java.text.DateFormat
@ -115,14 +114,14 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false) val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
fun getStorageDir(context: Context): File? { fun getFallbackStorageDir(): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { return prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it) File(it)
}?.takeIf { it.exists() && it.canWrite() } }?.takeIf { it.exists() }
return value ?: LocalMangaRepository.getFallbackStorageDir(context)
} }
fun setStorageDir(context: Context, file: File?) { @Deprecated("Use LocalStorageManager instead")
fun setStorageDir(file: File?) {
prefs.edit { prefs.edit {
if (file == null) { if (file == null) {
remove(KEY_LOCAL_STORAGE) 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.model.Manga
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository 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.MangaZip
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
@ -30,7 +29,6 @@ import java.io.File
class DownloadManager( class DownloadManager(
private val context: Context, private val context: Context,
private val settings: AppSettings,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
private val cache: PagesCache, private val cache: PagesCache,
@ -50,7 +48,7 @@ class DownloadManager(
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int) = flow<State> { fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int) = flow<State> {
emit(State.Preparing(startId, manga, null)) emit(State.Preparing(startId, manga, null))
var cover: Drawable? = null var cover: Drawable? = null
val destination = settings.getStorageDir(context) val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null var output: MangaZip? = null
try { try {

@ -53,7 +53,7 @@ class DownloadService : BaseService() {
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .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) DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
} }

@ -6,13 +6,15 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository 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.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.ui.LocalListViewModel import org.koitharu.kotatsu.local.ui.LocalListViewModel
val localModule val localModule
get() = module { get() = module {
single { LocalMangaRepository(androidContext()) } single { LocalStorageManager(androidContext(), get()) }
single { LocalMangaRepository(get()) }
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() } factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
viewModel { LocalListViewModel(get(), get(), get(), get()) } 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 package org.koitharu.kotatsu.local.domain
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
@ -9,20 +8,23 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter 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.MangaIndex
import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.utils.AlphanumComparator 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.io.File
import java.util.* import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
class LocalMangaRepository(private val context: Context) : MangaRepository { class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
override val source = MangaSource.LOCAL override val source = MangaSource.LOCAL
private val filenameFilter = CbzFilter() 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() val files = getAllFiles()
for (file in files) { return runInterruptible(Dispatchers.IO) {
val index = ZipFile(file).use { zip -> for (file in files) {
val entry = zip.getEntry(MangaZip.INDEX_ENTRY) val index = ZipFile(file).use { zip ->
entry?.let(zip::readText)?.let(::MangaIndex) val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
} ?: continue entry?.let(zip::readText)?.let(::MangaIndex)
val info = index.getMangaInfo() ?: continue } ?: continue
if (info.id == remoteManga.id) { val info = index.getMangaInfo() ?: continue
val fileUri = file.toUri().toString() if (info.id == remoteManga.id) {
return@runInterruptible info.copy( val fileUri = file.toUri().toString()
source = MangaSource.LOCAL, return@runInterruptible info.copy(
url = fileUri, source = MangaSource.LOCAL,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) } url = fileUri,
) chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
)
}
} }
null
} }
null
} }
private fun zipUri(file: File, entryName: String) = private fun zipUri(file: File, entryName: String) =
@ -193,32 +197,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
override suspend fun getTags() = emptySet<MangaTag>() override suspend fun getTags() = emptySet<MangaTag>()
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir -> fun isFileSupported(name: String): Boolean {
dir.listFiles(filenameFilter)?.toList().orEmpty() val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
} }
companion object { suspend fun getOutputDir(): File? {
return storageManager.getDefaultWriteableDir()
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? { private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
return context.getExternalFilesDir(DIR_NAME) ?: context.filesDir.sub(DIR_NAME).takeIf { dir.listFiles(filenameFilter)?.toList().orEmpty()
(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.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.resolveName import org.koitharu.kotatsu.utils.ext.resolveName
import java.io.File
import java.io.IOException import java.io.IOException
class LocalListViewModel( class LocalListViewModel(
private val repository: LocalMangaRepository, private val repository: LocalMangaRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings, settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository, private val shortcutsRepository: ShortcutsRepository,
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
@ -77,10 +76,10 @@ class LocalListViewModel(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val name = contentResolver.resolveName(uri) val name = contentResolver.resolveName(uri)
?: throw IOException("Cannot fetch name from uri: $uri") ?: throw IOException("Cannot fetch name from uri: $uri")
if (!LocalMangaRepository.isFileSupported(name)) { if (!repository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri") 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") ?: throw IOException("External files dir unavailable")
runInterruptible { runInterruptible {
contentResolver.openInputStream(uri)?.use { source -> contentResolver.openInputStream(uri)?.use { source ->

@ -12,18 +12,22 @@ import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference import androidx.preference.SwitchPreference
import kotlinx.coroutines.launch
import leakcanary.LeakCanary import leakcanary.LeakCanary
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode 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.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.names import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import java.io.File import java.io.File
import java.util.* import java.util.*
@ -32,6 +36,8 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener, SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener { StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -70,10 +76,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.run { findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked = findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
!settings.appPassword.isNullOrEmpty() !settings.appPassword.isNullOrEmpty()
settings.subscribe(this) settings.subscribe(this)
@ -114,10 +117,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required) findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required)
} }
AppSettings.KEY_LOCAL_STORAGE -> { AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.run { findPreference<Preference>(key)?.bindStorageName()
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
} }
AppSettings.KEY_APP_PASSWORD -> { AppSettings.KEY_APP_PASSWORD -> {
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP) findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)
@ -140,7 +140,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
return when (preference.key) { return when (preference.key) {
AppSettings.KEY_LOCAL_STORAGE -> { AppSettings.KEY_LOCAL_STORAGE -> {
val ctx = context ?: return false val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx), this) StorageSelectDialog.Builder(ctx, storageManager, this)
.setTitle(preference.title ?: "") .setTitle(preference.title ?: "")
.setNegativeButton(android.R.string.cancel) .setNegativeButton(android.R.string.cancel)
.create() .create()
@ -162,7 +162,13 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
} }
override fun onStorageSelected(file: File) { 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)
}
}
} }
Loading…
Cancel
Save