From 51d6a073e0da9f5d9269d961063f324ac5b25e9a Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 13 Feb 2022 10:21:37 +0200 Subject: [PATCH] Refactor local storage manager --- .../base/ui/dialog/StorageSelectDialog.kt | 23 +++--- .../kotatsu/core/prefs/AppSettings.kt | 11 ++- .../download/domain/DownloadManager.kt | 4 +- .../download/ui/service/DownloadService.kt | 2 +- .../org/koitharu/kotatsu/local/LocalModule.kt | 4 +- .../kotatsu/local/data/LocalStorageManager.kt | 67 ++++++++++++++++ .../local/domain/LocalMangaRepository.kt | 78 ++++++++----------- .../kotatsu/local/ui/LocalListViewModel.kt | 7 +- .../kotatsu/settings/MainSettingsFragment.kt | 26 ++++--- 9 files changed, 143 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt index a72672272..3c048aa54 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt @@ -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> { - return LocalMangaRepository.getAvailableStorageDirs(context).map { - it to it.getStorageName(context) + private fun getAvailableVolumes(storageManager: LocalStorageManager): List> { + return runBlocking { + storageManager.getWriteableDirs().map { + it to storageManager.getStorageDisplayName(it) + } } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 8fac79601..688428ee7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 1a36ec7fc..849dca7ca 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -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?, startId: Int) = flow { 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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 03712d889..f5681df51 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -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)) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt index dbe2d43e1..9b5b87822 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt @@ -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(named(MangaSource.LOCAL)) { get() } viewModel { LocalListViewModel(get(), get(), get(), get()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt new file mode 100644 index 000000000..b51cb2367 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -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 = runInterruptible(Dispatchers.IO) { + getConfiguredStorageDirs() + .filter { it.isReadable() } + } + + suspend fun getWriteableDirs(): List = 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 { + val set = getAvailableStorageDirs() + settings.getFallbackStorageDir()?.let { + set.add(it) + } + return set + } + + @WorkerThread + private fun getAvailableStorageDirs(): MutableSet { + val result = LinkedHashSet() + 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) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 2dfb798f2..2b861592f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -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() - 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 { - val result = ArrayList(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() - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index b1fc2493e..442591984 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -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 -> diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt index 1bc8be9fc..73f20bad6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt @@ -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() + 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(AppSettings.KEY_LOCAL_STORAGE)?.run { - summary = settings.getStorageDir(context)?.getStorageName(context) - ?: getString(R.string.not_available) - } + findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = !settings.appPassword.isNullOrEmpty() settings.subscribe(this) @@ -114,10 +117,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), findPreference(key)?.setSummary(R.string.restart_required) } AppSettings.KEY_LOCAL_STORAGE -> { - findPreference(key)?.run { - summary = settings.getStorageDir(context)?.getStorageName(context) - ?: getString(R.string.not_available) - } + findPreference(key)?.bindStorageName() } AppSettings.KEY_APP_PASSWORD -> { findPreference(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) + } + } } \ No newline at end of file