Select storage where save manga

This commit is contained in:
Koitharu
2020-04-26 12:22:36 +03:00
parent f95cf9b231
commit de9a07a680
12 changed files with 149 additions and 32 deletions

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.safe import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
@@ -29,8 +30,8 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
sortOrder: SortOrder?, sortOrder: SortOrder?,
tag: MangaTag? tag: MangaTag?
): List<Manga> { ): List<Manga> {
val files = context.getExternalFilesDirs("manga") val files = getAvailableStorageDirs(context)
.flatMap { x -> x?.listFiles(CbzFilter())?.toList().orEmpty() } .flatMap { x -> x.listFiles(CbzFilter())?.toList().orEmpty() }
return files.mapNotNull { x -> safe { getFromFile(x) } } return files.mapNotNull { x -> safe { getFromFile(x) } }
} }
@@ -133,9 +134,18 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
companion object { companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean { fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT) val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
return ext == "cbz" || ext == "zip" return ext == "cbz" || ext == "zip"
} }
fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5)
result += context.filesDir.sub(DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
}
} }
} }

View File

@@ -3,11 +3,15 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import android.os.StatFs
import android.provider.Settings import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.* import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File
class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) : class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) :
SharedPreferences by prefs { SharedPreferences by prefs {
@@ -88,6 +92,26 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden)) var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden))
fun getStorageDir(context: Context): File? {
val value = prefs.getString(context.getString(R.string.key_local_storage), null)?.let {
File(it)
}?.takeIf { it.exists() && it.canWrite() }
return value ?: LocalMangaRepository.getAvailableStorageDirs(context).maxBy {
StatFs(it.path).availableBytes
}
}
fun setStorageDir(context: Context, file: File?) {
val key = context.getString(R.string.key_local_storage)
prefs.edit {
if (file == null) {
remove(key)
} else {
putString(key, file.path)
}
}
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
} }

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.ui.common.dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Environment
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
@@ -10,7 +9,8 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import kotlinx.android.synthetic.main.item_storage.view.* import kotlinx.android.synthetic.main.item_storage.view.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.findParent import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.inflate import org.koitharu.kotatsu.utils.ext.inflate
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File import java.io.File
@@ -20,12 +20,24 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
fun show() = delegate.show() fun show() = delegate.show()
class Builder(context: Context) { class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
private val adapter = VolumesAdapter(context)
private val delegate = AlertDialog.Builder(context) private val delegate = AlertDialog.Builder(context)
.setAdapter(VolumesAdapter(context)) { _, _ ->
init {
if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage)
} else {
val checked = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath
}
delegate.setSingleChoiceItems(adapter, checked) { d, i ->
listener.onStorageSelected(adapter.getItem(i).first)
d.dismiss()
}
} }
}
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId) delegate.setTitle(titleResId)
@@ -37,12 +49,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return this return this
} }
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
fun create() = StorageSelectDialog(delegate.create()) fun create() = StorageSelectDialog(delegate.create())
} }
private class VolumesAdapter(context: Context): BaseAdapter() { private class VolumesAdapter(context: Context) : BaseAdapter() {
private val volumes = getAvailableVolumes(context) val volumes = getAvailableVolumes(context)
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)
@@ -52,7 +69,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return view return view
} }
override fun getItem(position: Int): Any = volumes[position] override fun getItem(position: Int): Pair<File, String> = volumes[position]
override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode() override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode()
@@ -60,15 +77,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
} }
interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
private companion object { private companion object {
@JvmStatic @JvmStatic
fun getAvailableVolumes(context: Context): List<Pair<File,String>> = context.getExternalFilesDirs(null).mapNotNull { fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
val root = it.findParent { x -> x.name == "Android" }?.parentFile ?: return@mapNotNull null return LocalMangaRepository.getAvailableStorageDirs(context).map {
root to when { it to it.getStorageName(context)
Environment.isExternalStorageEmulated(root) -> context.getString(R.string.internal_storage)
Environment.isExternalStorageRemovable(root) -> context.getString(R.string.external_storage)
else -> root.name
} }
} }
} }

View File

@@ -39,6 +39,7 @@ class DownloadService : BaseService() {
private val okHttp by inject<OkHttpClient>() private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>() private val cache by inject<PagesCache>()
private val settings by inject<AppSettings>()
private val jobs = HashMap<Int, Job>() private val jobs = HashMap<Int, Job>()
private val mutex = Mutex() private val mutex = Mutex()
@@ -80,7 +81,8 @@ class DownloadService : BaseService() {
notification.setCancelId(startId) notification.setCancelId(startId)
startForeground(DownloadNotification.NOTIFICATION_ID, notification()) startForeground(DownloadNotification.NOTIFICATION_ID, notification())
} }
val destination = getExternalFilesDir("manga")!! val destination = settings.getStorageDir(this@DownloadService)
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null var output: MangaZip? = null
try { try {
val repo = MangaProviderFactory.create(manga.source) val repo = MangaProviderFactory.create(manga.source)

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.history.HistoryRepository import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.common.BasePresenter import org.koitharu.kotatsu.ui.common.BasePresenter
@@ -64,7 +65,7 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
if (!LocalMangaRepository.isFileSupported(name)) { if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri") throw UnsupportedFileException("Unsupported file on $uri")
} }
val dest = context.getExternalFilesDir("manga")?.sub(name) val dest = get<AppSettings>().getStorageDir(context)?.sub(name)
?: throw IOException("External files dir unavailable") ?: throw IOException("External files dir unavailable")
context.contentResolver.openInputStream(uri)?.use { source -> context.contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output -> dest.outputStream().use { output ->

View File

@@ -16,13 +16,17 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog
import org.koitharu.kotatsu.ui.main.list.ListModeSelectDialog import org.koitharu.kotatsu.ui.main.list.ListModeSelectDialog
import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.ui.tracker.TrackWorker import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File
class MainSettingsFragment : BasePreferenceFragment(R.string.settings), class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_main) addPreferencesFromResource(R.xml.pref_main)
@@ -40,15 +44,25 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
findPreference<Preference>(R.string.key_app_update_auto)?.run { findPreference<Preference>(R.string.key_app_update_auto)?.run {
isVisible = AppUpdateService.isUpdateSupported(context) isVisible = AppUpdateService.isUpdateSupported(context)
} }
findPreference<Preference>(R.string.key_local_storage)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) { when (key) {
getString(R.string.key_list_mode) -> findPreference<Preference>(R.string.key_list_mode)?.summary = getString(R.string.key_list_mode) -> findPreference<Preference>(R.string.key_list_mode)?.summary =
LIST_MODES[settings.listMode]?.let(::getString) LIST_MODES[settings.listMode]?.let(::getString)
getString(R.string.key_theme) -> { getString(R.string.key_theme) -> {
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
} }
getString(R.string.key_local_storage) -> {
findPreference<Preference>(R.string.key_local_storage)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
}
} }
} }
@@ -89,10 +103,23 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
} }
true true
} }
getString(R.string.key_local_storage) -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx),this)
.setTitle(preference.title)
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
override fun onStorageSelected(file: File) {
settings.setStorageDir(context ?: return, file)
}
private companion object { private companion object {
val LIST_MODES = arrayMapOf( val LIST_MODES = arrayMapOf(

View File

@@ -1,5 +1,10 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import org.koitharu.kotatsu.R
import java.io.File import java.io.File
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@@ -22,8 +27,22 @@ fun File.computeSize(): Long = listFiles()?.sumByLong { x ->
inline fun File.findParent(predicate: (File) -> Boolean): File? { inline fun File.findParent(predicate: (File) -> Boolean): File? {
var current = this var current = this
while(!predicate(current)) { while (!predicate(current)) {
current = current.parentFile ?: return null current = current.parentFile ?: return null
} }
return current return current
}
fun File.getStorageName(context: Context): String {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
manager.getStorageVolume(this)?.getDescription(context)?.let {
return it
}
}
return when {
Environment.isExternalStorageEmulated(this) -> context.getString(R.string.internal_storage)
Environment.isExternalStorageRemovable(this) -> context.getString(R.string.external_storage)
else -> context.getString(R.string.other_storage)
}
} }

View File

@@ -2,34 +2,35 @@
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:minHeight="?listPreferredItemHeightLarge" android:layout_height="wrap_content"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:gravity="center_vertical"
android:background="?selectableItemBackground" android:background="?selectableItemBackground"
android:layout_height="wrap_content"> android:gravity="center_vertical"
android:minHeight="?listPreferredItemHeightLarge"
android:orientation="vertical"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingTop="16dp"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:paddingBottom="16dp">
<TextView <TextView
android:id="@+id/textView_title" android:id="@+id/textView_title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
tools:text="@tools:sample/lorem[3]" android:maxLines="1"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" /> tools:text="@tools:sample/lorem[3]" />
<TextView <TextView
android:id="@+id/textView_subtitle" android:id="@+id/textView_subtitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary" android:textColor="?android:textColorSecondary"
tools:text="@tools:sample/lorem[3]" tools:text="@tools:sample/lorem[20]" />
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />
</LinearLayout> </LinearLayout>

View File

@@ -126,4 +126,8 @@
<string name="manga_shelf">Полка с мангой</string> <string name="manga_shelf">Полка с мангой</string>
<string name="recent_manga">Недавняя манга</string> <string name="recent_manga">Недавняя манга</string>
<string name="pages_animation">Анимация листания</string> <string name="pages_animation">Анимация листания</string>
<string name="manga_save_location">Место сохранения манги</string>
<string name="not_available">Недоступно</string>
<string name="cannot_find_available_storage">Не удалось найти ни одного доступного хранилища</string>
<string name="other_storage">Другое хранилище</string>
</resources> </resources>

View File

@@ -10,6 +10,7 @@
<string name="key_search_history_clear">search_history_clear</string> <string name="key_search_history_clear">search_history_clear</string>
<string name="key_grid_size">grid_size</string> <string name="key_grid_size">grid_size</string>
<string name="key_remote_sources">remote_sources</string> <string name="key_remote_sources">remote_sources</string>
<string name="key_local_storage">local_storage</string>
<string name="key_reader_switchers">reader_switchers</string> <string name="key_reader_switchers">reader_switchers</string>
<string name="key_app_update">app_update</string> <string name="key_app_update">app_update</string>
<string name="key_app_update_auto">app_update_auto</string> <string name="key_app_update_auto">app_update_auto</string>

View File

@@ -127,4 +127,8 @@
<string name="manga_shelf">Manga shelf</string> <string name="manga_shelf">Manga shelf</string>
<string name="recent_manga">Recent manga</string> <string name="recent_manga">Recent manga</string>
<string name="pages_animation">Pages animation</string> <string name="pages_animation">Pages animation</string>
<string name="manga_save_location">Manga download location</string>
<string name="not_available">Not available</string>
<string name="cannot_find_available_storage">Cannot find any available storage</string>
<string name="other_storage">Other storage</string>
</resources> </resources>

View File

@@ -38,6 +38,11 @@
app:allowDividerAbove="true" app:allowDividerAbove="true"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<Preference
android:key="@string/key_local_storage"
android:title="@string/manga_save_location"
app:iconSpaceReserved="false" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.koitharu.kotatsu.ui.settings.HistorySettingsFragment" android:fragment="org.koitharu.kotatsu.ui.settings.HistorySettingsFragment"
android:title="@string/history_and_cache" android:title="@string/history_and_cache"