Configure manga directories
This commit is contained in:
@@ -15,8 +15,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 33
|
targetSdkVersion 33
|
||||||
versionCode 555
|
versionCode 556
|
||||||
versionName '5.2.3'
|
versionName '5.3'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,9 @@
|
|||||||
<data android:host="sync-settings" />
|
<data android:host="sync-settings" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
|
||||||
|
android:label="@string/local_manga_directories" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
|
|||||||
@@ -249,11 +249,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
var mangaStorageDir: File?
|
var mangaStorageDir: File?
|
||||||
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||||
File(it)
|
File(it)
|
||||||
}?.takeIf { it.exists() }
|
}?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
|
||||||
set(value) = prefs.edit {
|
set(value) = prefs.edit {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
remove(KEY_LOCAL_STORAGE)
|
remove(KEY_LOCAL_STORAGE)
|
||||||
} else {
|
} else {
|
||||||
|
val userDirs = userSpecifiedMangaDirectories
|
||||||
|
if (value !in userDirs) {
|
||||||
|
userSpecifiedMangaDirectories = userDirs + value
|
||||||
|
}
|
||||||
putString(KEY_LOCAL_STORAGE, value.path)
|
putString(KEY_LOCAL_STORAGE, value.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ abstract class MangaListFragment :
|
|||||||
private val spanSizeLookup = SpanSizeLookup()
|
private val spanSizeLookup = SpanSizeLookup()
|
||||||
private val listCommitCallback = Runnable {
|
private val listCommitCallback = Runnable {
|
||||||
spanSizeLookup.invalidateCache()
|
spanSizeLookup.invalidateCache()
|
||||||
viewBinding?.let {
|
|
||||||
paginationListener?.onScrolled(it.recyclerView, 0, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
open val isSwipeRefreshEnabled = true
|
open val isSwipeRefreshEnabled = true
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import java.io.File
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val DIR_NAME = "manga"
|
private const val DIR_NAME = "manga"
|
||||||
|
private const val NOMEDIA = ".nomedia"
|
||||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||||
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
||||||
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||||
@@ -77,18 +78,23 @@ class LocalStorageManager @Inject constructor(
|
|||||||
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getApplicationStorageDirs(): Set<File> = runInterruptible(Dispatchers.IO) {
|
||||||
|
getAvailableStorageDirs()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
|
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
|
||||||
uri.resolveFile(context)
|
uri.resolveFile(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setDirIsNoMedia(dir: File) = runInterruptible(Dispatchers.IO) {
|
||||||
|
File(dir, NOMEDIA).createNewFile()
|
||||||
|
}
|
||||||
|
|
||||||
fun takePermissions(uri: Uri) {
|
fun takePermissions(uri: Uri) {
|
||||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
contentResolver.takePersistableUriPermission(uri, flags)
|
contentResolver.takePersistableUriPermission(uri, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
|
||||||
|
|
||||||
suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) {
|
suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) {
|
||||||
val packageName = context.packageName
|
val packageName = context.packageName
|
||||||
if (dir.absolutePath.contains(packageName)) {
|
if (dir.absolutePath.contains(packageName)) {
|
||||||
@@ -104,9 +110,6 @@ class LocalStorageManager @Inject constructor(
|
|||||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||||
val set = getAvailableStorageDirs()
|
val set = getAvailableStorageDirs()
|
||||||
set.addAll(settings.userSpecifiedMangaDirectories)
|
set.addAll(settings.userSpecifiedMangaDirectories)
|
||||||
settings.mangaStorageDir?.let {
|
|
||||||
set.add(it)
|
|
||||||
}
|
|
||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
|
|||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
|
addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick))
|
||||||
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
|
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
|
|||||||
FilterSheetFragment.show(childFragmentManager)
|
FilterSheetFragment.show(childFragmentManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = viewModel.loadNextPage()
|
||||||
|
|
||||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||||
|
|
||||||
class LocalListMenuProvider(
|
class LocalListMenuProvider(
|
||||||
|
private val context: Context,
|
||||||
private val onImportClick: Function0<Unit>,
|
private val onImportClick: Function0<Unit>,
|
||||||
) : MenuProvider {
|
) : MenuProvider {
|
||||||
|
|
||||||
@@ -20,6 +23,12 @@ class LocalListMenuProvider(
|
|||||||
onImportClick()
|
onImportClick()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_settings -> {
|
||||||
|
context.startActivity(MangaDirectoriesActivity.newIntent(context))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -24,7 +25,7 @@ class LocalListViewModel @Inject constructor(
|
|||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
filter: FilterCoordinator,
|
filter: FilterCoordinator,
|
||||||
settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
downloadScheduler: DownloadWorker.Scheduler,
|
downloadScheduler: DownloadWorker.Scheduler,
|
||||||
listExtraProvider: ListExtraProvider,
|
listExtraProvider: ListExtraProvider,
|
||||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||||
@@ -36,7 +37,7 @@ class LocalListViewModel @Inject constructor(
|
|||||||
settings,
|
settings,
|
||||||
listExtraProvider,
|
listExtraProvider,
|
||||||
downloadScheduler,
|
downloadScheduler,
|
||||||
) {
|
), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
val onMangaRemoved = MutableEventFlow<Unit>()
|
val onMangaRemoved = MutableEventFlow<Unit>()
|
||||||
|
|
||||||
@@ -47,6 +48,18 @@ class LocalListViewModel @Inject constructor(
|
|||||||
loadList(filter.snapshot(), append = false).join()
|
loadList(filter.snapshot(), append = false).join()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
settings.subscribe(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
settings.unsubscribe(this)
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||||
|
if (key == AppSettings.KEY_LOCAL_MANGA_DIRS) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(ids: Set<Long>) {
|
fun delete(ids: Set<Long>) {
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
|||||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||||
import java.io.File
|
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class DownloadsSettingsFragment :
|
class DownloadsSettingsFragment :
|
||||||
BasePreferenceFragment(R.string.downloads),
|
BasePreferenceFragment(R.string.downloads),
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
StorageSelectDialog.OnStorageSelectListener {
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var storageManager: LocalStorageManager
|
lateinit var storageManager: LocalStorageManager
|
||||||
@@ -38,6 +37,7 @@ class DownloadsSettingsFragment :
|
|||||||
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)?.bindStorageName()
|
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||||
|
findPreference<Preference>(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount()
|
||||||
settings.subscribe(this)
|
settings.subscribe(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +52,10 @@ class DownloadsSettingsFragment :
|
|||||||
findPreference<Preference>(key)?.bindStorageName()
|
findPreference<Preference>(key)?.bindStorageName()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||||
|
findPreference<Preference>(key)?.bindDirectoriesCount()
|
||||||
|
}
|
||||||
|
|
||||||
AppSettings.KEY_DOWNLOADS_WIFI -> {
|
AppSettings.KEY_DOWNLOADS_WIFI -> {
|
||||||
updateDownloadsConstraints()
|
updateDownloadsConstraints()
|
||||||
}
|
}
|
||||||
@@ -65,14 +69,15 @@ class DownloadsSettingsFragment :
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||||
|
startActivity(MangaDirectoriesActivity.newIntent(preference.context))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStorageSelected(file: File) {
|
|
||||||
settings.mangaStorageDir = file
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Preference.bindStorageName() {
|
private fun Preference.bindStorageName() {
|
||||||
viewLifecycleScope.launch {
|
viewLifecycleScope.launch {
|
||||||
val storage = storageManager.getDefaultWriteableDir()
|
val storage = storageManager.getDefaultWriteableDir()
|
||||||
@@ -84,6 +89,13 @@ class DownloadsSettingsFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Preference.bindDirectoriesCount() {
|
||||||
|
viewLifecycleScope.launch {
|
||||||
|
val dirs = storageManager.getReadableDirs().size
|
||||||
|
summary = resources.getQuantityString(R.plurals.items, dirs, dirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateDownloadsConstraints() {
|
private fun updateDownloadsConstraints() {
|
||||||
val preference = findPreference<Preference>(AppSettings.KEY_DOWNLOADS_WIFI)
|
val preference = findPreference<Preference>(AppSettings.KEY_DOWNLOADS_WIFI)
|
||||||
viewLifecycleScope.launch {
|
viewLifecycleScope.launch {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class DirectoryModel(
|
|||||||
@StringRes val titleRes: Int,
|
@StringRes val titleRes: Int,
|
||||||
val file: File?,
|
val file: File?,
|
||||||
val isChecked: Boolean,
|
val isChecked: Boolean,
|
||||||
|
val isAvailable: Boolean,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -20,7 +21,8 @@ class DirectoryModel(
|
|||||||
if (title != other.title) return false
|
if (title != other.title) return false
|
||||||
if (titleRes != other.titleRes) return false
|
if (titleRes != other.titleRes) return false
|
||||||
if (file != other.file) return false
|
if (file != other.file) return false
|
||||||
return isChecked == other.isChecked
|
if (isChecked != other.isChecked) return false
|
||||||
|
return isAvailable == other.isAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
@@ -28,6 +30,7 @@ class DirectoryModel(
|
|||||||
result = 31 * result + titleRes
|
result = 31 * result + titleRes
|
||||||
result = 31 * result + (file?.hashCode() ?: 0)
|
result = 31 * result + (file?.hashCode() ?: 0)
|
||||||
result = 31 * result + isChecked.hashCode()
|
result = 31 * result + isChecked.hashCode()
|
||||||
|
result = 31 * result + isAvailable.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ class MangaDirectorySelectDialog : AlertDialogFragment<DialogDirectorySelectBind
|
|||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
pickFileTreeLauncher.launch(null)
|
if (it) {
|
||||||
|
viewModel.refresh()
|
||||||
|
pickFileTreeLauncher.launch(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding {
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding {
|
||||||
|
|||||||
@@ -24,26 +24,7 @@ class MangaDirectorySelectViewModel @Inject constructor(
|
|||||||
val onPickDirectory = MutableEventFlow<Unit>()
|
val onPickDirectory = MutableEventFlow<Unit>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
launchJob {
|
refresh()
|
||||||
val defaultValue = storageManager.getDefaultWriteableDir()
|
|
||||||
val available = storageManager.getWriteableDirs()
|
|
||||||
items.value = buildList(available.size + 1) {
|
|
||||||
available.mapTo(this) { dir ->
|
|
||||||
DirectoryModel(
|
|
||||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
|
||||||
titleRes = 0,
|
|
||||||
file = dir,
|
|
||||||
isChecked = dir == defaultValue,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this += DirectoryModel(
|
|
||||||
title = null,
|
|
||||||
titleRes = R.string.pick_custom_directory,
|
|
||||||
file = null,
|
|
||||||
isChecked = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onItemClick(item: DirectoryModel) {
|
fun onItemClick(item: DirectoryModel) {
|
||||||
@@ -62,9 +43,36 @@ class MangaDirectorySelectViewModel @Inject constructor(
|
|||||||
if (!dir.canWrite()) {
|
if (!dir.canWrite()) {
|
||||||
throw AccessDeniedException(dir)
|
throw AccessDeniedException(dir)
|
||||||
}
|
}
|
||||||
settings.userSpecifiedMangaDirectories += dir
|
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||||
settings.mangaStorageDir = dir
|
settings.mangaStorageDir = dir
|
||||||
|
storageManager.setDirIsNoMedia(dir)
|
||||||
|
}
|
||||||
onDismissDialog.call(Unit)
|
onDismissDialog.call(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
val defaultValue = storageManager.getDefaultWriteableDir()
|
||||||
|
val available = storageManager.getWriteableDirs()
|
||||||
|
items.value = buildList(available.size + 1) {
|
||||||
|
available.mapTo(this) { dir ->
|
||||||
|
DirectoryModel(
|
||||||
|
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||||
|
titleRes = 0,
|
||||||
|
file = dir,
|
||||||
|
isChecked = dir == defaultValue,
|
||||||
|
isAvailable = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this += DirectoryModel(
|
||||||
|
title = null,
|
||||||
|
titleRes = R.string.pick_custom_directory,
|
||||||
|
file = null,
|
||||||
|
isChecked = false,
|
||||||
|
isAvailable = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.storage.directories
|
||||||
|
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
|
||||||
|
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||||
|
|
||||||
|
fun directoryConfigAD(
|
||||||
|
clickListener: OnListItemClickListener<DirectoryModel>,
|
||||||
|
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageConfigBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) },
|
||||||
|
) {
|
||||||
|
|
||||||
|
binding.imageViewRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||||
|
|
||||||
|
bind {
|
||||||
|
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
||||||
|
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
||||||
|
binding.imageViewRemove.isVisible = item.isChecked
|
||||||
|
binding.textViewTitle.drawableStart = if (item.isAvailable) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.storage.directories
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
|
||||||
|
import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback
|
||||||
|
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||||
|
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
|
||||||
|
OnListItemClickListener<DirectoryModel>, View.OnClickListener {
|
||||||
|
|
||||||
|
private val viewModel: MangaDirectoriesViewModel by viewModels()
|
||||||
|
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||||
|
if (it != null) viewModel.onCustomDirectoryPicked(it)
|
||||||
|
}
|
||||||
|
private val permissionRequestLauncher = registerForActivityResult(
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
RequestStorageManagerPermissionContract()
|
||||||
|
} else {
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (it) {
|
||||||
|
viewModel.updateList()
|
||||||
|
pickFileTreeLauncher.launch(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this))
|
||||||
|
viewBinding.recyclerView.adapter = adapter
|
||||||
|
viewBinding.fabAdd.setOnClickListener(this)
|
||||||
|
viewModel.items.observe(this) { adapter.items = it }
|
||||||
|
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
|
||||||
|
viewModel.onError.observeEvent(
|
||||||
|
this,
|
||||||
|
SnackbarErrorObserver(viewBinding.root, null, exceptionResolver) {
|
||||||
|
if (it) viewModel.updateList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: DirectoryModel, view: View) {
|
||||||
|
viewModel.onRemoveClick(item.file ?: return)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View?) {
|
||||||
|
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
viewBinding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
rightMargin = topMargin + insets.right
|
||||||
|
leftMargin = topMargin + insets.left
|
||||||
|
bottomMargin = topMargin + insets.bottom
|
||||||
|
}
|
||||||
|
viewBinding.root.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
|
viewBinding.recyclerView.updatePadding(
|
||||||
|
bottom = insets.bottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.storage.directories
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import okio.FileNotFoundException
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
|
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MangaDirectoriesViewModel @Inject constructor(
|
||||||
|
private val storageManager: LocalStorageManager,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
||||||
|
private var loadingJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateList() {
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCustomDirectoryPicked(uri: Uri) {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
loadingJob?.cancelAndJoin()
|
||||||
|
storageManager.takePermissions(uri)
|
||||||
|
val dir = storageManager.resolveUri(uri) ?: throw FileNotFoundException()
|
||||||
|
if (!dir.canWrite()) {
|
||||||
|
throw AccessDeniedException(dir)
|
||||||
|
}
|
||||||
|
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||||
|
settings.userSpecifiedMangaDirectories += dir
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRemoveClick(directory: File) {
|
||||||
|
settings.userSpecifiedMangaDirectories -= directory
|
||||||
|
if (settings.mangaStorageDir == directory) {
|
||||||
|
settings.mangaStorageDir = null
|
||||||
|
}
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadList() {
|
||||||
|
val prevJob = loadingJob
|
||||||
|
loadingJob = launchJob(Dispatchers.Default) {
|
||||||
|
prevJob?.cancelAndJoin()
|
||||||
|
val applicationDirs = storageManager.getApplicationStorageDirs()
|
||||||
|
val customDirs = settings.userSpecifiedMangaDirectories
|
||||||
|
items.value = buildList(applicationDirs.size + customDirs.size) {
|
||||||
|
applicationDirs.mapTo(this) { dir ->
|
||||||
|
DirectoryModel(
|
||||||
|
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||||
|
titleRes = 0,
|
||||||
|
file = dir,
|
||||||
|
isChecked = false,
|
||||||
|
isAvailable = dir.canRead() && dir.canWrite(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
customDirs.mapTo(this) { dir ->
|
||||||
|
DirectoryModel(
|
||||||
|
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||||
|
titleRes = 0,
|
||||||
|
file = dir,
|
||||||
|
isChecked = true,
|
||||||
|
isAvailable = dir.canRead() && dir.canWrite(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,6 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
android:contentDescription="@string/add_new_category"
|
android:contentDescription="@string/add_new_category"
|
||||||
android:src="@drawable/ic_add"
|
|
||||||
android:text="@string/create_category"
|
android:text="@string/create_category"
|
||||||
app:fabSize="normal"
|
app:fabSize="normal"
|
||||||
app:icon="@drawable/ic_add"
|
app:icon="@drawable/ic_add"
|
||||||
|
|||||||
57
app/src/main/res/layout/activity_manga_directories.xml
Normal file
57
app/src/main/res/layout/activity_manga_directories.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/appbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
app:layout_scrollFlags="noScroll">
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.MaterialToolbar>
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_anchor="@id/appbar"
|
||||||
|
app:layout_anchorGravity="bottom" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
|
android:id="@+id/fab_add"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:contentDescription="@string/pick_custom_directory"
|
||||||
|
android:text="@string/add"
|
||||||
|
app:fabSize="normal"
|
||||||
|
app:icon="@drawable/ic_add"
|
||||||
|
app:layout_anchor="@id/recyclerView"
|
||||||
|
app:layout_anchorGravity="bottom|end"
|
||||||
|
app:layout_behavior="org.koitharu.kotatsu.core.ui.util.ShrinkOnScrollBehavior"
|
||||||
|
app:layout_dodgeInsetEdges="bottom" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
53
app/src/main/res/layout/item_storage_config.xml
Normal file
53
app/src/main/res/layout/item_storage_config.xml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="?listPreferredItemHeight"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:paddingStart="?listPreferredItemPaddingStart">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:drawablePadding="6dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
|
app:drawableTint="@color/warning"
|
||||||
|
tools:drawableStart="@drawable/ic_alert_outline"
|
||||||
|
tools:text="@tools:sample/lorem[3]" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_subtitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
tools:text="@tools:sample/lorem[20]" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imageView_remove"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/remove"
|
||||||
|
android:padding="?listPreferredItemPaddingEnd"
|
||||||
|
app:srcCompat="@drawable/ic_delete" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -9,4 +9,10 @@
|
|||||||
android:title="@string/_import"
|
android:title="@string/_import"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
</menu>
|
<item
|
||||||
|
android:id="@+id/action_settings"
|
||||||
|
android:orderInCategory="100"
|
||||||
|
android:title="@string/settings"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
|
|||||||
@@ -448,4 +448,5 @@
|
|||||||
<string name="custom_directory">Custom directory</string>
|
<string name="custom_directory">Custom directory</string>
|
||||||
<string name="pick_custom_directory">Pick custom directory</string>
|
<string name="pick_custom_directory">Pick custom directory</string>
|
||||||
<string name="no_access_to_file">You have no access to this file or directory</string>
|
<string name="no_access_to_file">You have no access to this file or directory</string>
|
||||||
|
<string name="local_manga_directories">Local manga directories</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<PreferenceScreen
|
<PreferenceScreen
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:key="local_manga_dirs"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="@string/local_manga_directories" />
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:key="local_storage"
|
android:key="local_storage"
|
||||||
@@ -11,7 +17,8 @@
|
|||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
android:key="downloads_wifi"
|
android:key="downloads_wifi"
|
||||||
android:summary="@string/downloads_wifi_only_summary"
|
android:summary="@string/downloads_wifi_only_summary"
|
||||||
android:title="@string/downloads_wifi_only" />
|
android:title="@string/downloads_wifi_only"
|
||||||
|
app:allowDividerAbove="true" />
|
||||||
|
|
||||||
<SwitchPreferenceCompat
|
<SwitchPreferenceCompat
|
||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
|
|||||||
Reference in New Issue
Block a user