Support for custom directories for manga
This commit is contained in:
@@ -11,12 +11,11 @@ import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.StorageSelectDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -62,12 +61,7 @@ class DownloadsSettingsFragment :
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
val ctx = context ?: return false
|
||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
||||
.setTitle(preference.title ?: "")
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.create()
|
||||
.show()
|
||||
MangaDirectorySelectDialog.show(childFragmentManager)
|
||||
true
|
||||
}
|
||||
|
||||
@@ -82,7 +76,11 @@ class DownloadsSettingsFragment :
|
||||
private fun Preference.bindStorageName() {
|
||||
viewLifecycleScope.launch {
|
||||
val storage = storageManager.getDefaultWriteableDir()
|
||||
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
||||
summary = if (storage != null) {
|
||||
storageManager.getDirectoryDisplayName(storage, isFullPath = true)
|
||||
} else {
|
||||
getString(R.string.not_available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
|
||||
fun directoryAD(
|
||||
clickListener: OnListItemClickListener<DirectoryModel>,
|
||||
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageBinding>(
|
||||
{ layoutInflater, parent -> ItemStorageBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
||||
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
||||
binding.imageViewIndicator.isChecked = item.isChecked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil.ItemCallback
|
||||
|
||||
class DirectoryDiffCallback : ItemCallback<DirectoryModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
|
||||
return oldItem.file == newItem.file
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: DirectoryModel, newItem: DirectoryModel): Any? {
|
||||
return if (oldItem.isChecked != newItem.isChecked) {
|
||||
Unit
|
||||
} else {
|
||||
super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.io.File
|
||||
|
||||
class DirectoryModel(
|
||||
val title: String?,
|
||||
@StringRes val titleRes: Int,
|
||||
val file: File?,
|
||||
val isChecked: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DirectoryModel
|
||||
|
||||
if (title != other.title) return false
|
||||
if (titleRes != other.titleRes) return false
|
||||
if (file != other.file) return false
|
||||
return isChecked == other.isChecked
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = title?.hashCode() ?: 0
|
||||
result = 31 * result + titleRes
|
||||
result = 31 * result + (file?.hashCode() ?: 0)
|
||||
result = 31 * result + isChecked.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ToastErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
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.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.databinding.DialogDirectorySelectBinding
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaDirectorySelectDialog : AlertDialogFragment<DialogDirectorySelectBinding>(),
|
||||
OnListItemClickListener<DirectoryModel> {
|
||||
|
||||
private val viewModel: MangaDirectorySelectViewModel 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()
|
||||
},
|
||||
) {
|
||||
pickFileTreeLauncher.launch(null)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding {
|
||||
return DialogDirectorySelectBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogDirectorySelectBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryAD(this))
|
||||
binding.root.adapter = adapter
|
||||
viewModel.items.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
viewModel.onDismissDialog.observeEvent(viewLifecycleOwner) { dismiss() }
|
||||
viewModel.onPickDirectory.observeEvent(viewLifecycleOwner) { pickCustomDirectory() }
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, ToastErrorObserver(binding.root, this))
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.manga_save_location)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DirectoryModel, view: View) {
|
||||
viewModel.onItemClick(item)
|
||||
}
|
||||
|
||||
private fun pickCustomDirectory() {
|
||||
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "MangaDirectorySelectDialog"
|
||||
|
||||
fun show(fm: FragmentManager) = MangaDirectorySelectDialog()
|
||||
.showDistinct(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okio.FileNotFoundException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaDirectorySelectViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
||||
val onDismissDialog = MutableEventFlow<Unit>()
|
||||
val onPickDirectory = MutableEventFlow<Unit>()
|
||||
|
||||
init {
|
||||
launchJob {
|
||||
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) {
|
||||
if (item.file != null) {
|
||||
settings.mangaStorageDir = item.file
|
||||
onDismissDialog.call(Unit)
|
||||
} else {
|
||||
onPickDirectory.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = storageManager.resolveUri(uri) ?: throw FileNotFoundException()
|
||||
if (!dir.canWrite()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
settings.userSpecifiedMangaDirectories += dir
|
||||
settings.mangaStorageDir = dir
|
||||
onDismissDialog.call(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class RequestStorageManagerPermissionContract : ActivityResultContract<String, Boolean>() {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.addCategory("android.intent.category.DEFAULT")
|
||||
intent.data = "package:${context.packageName}".toUri()
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return Environment.isExternalStorageManager()
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(context: Context, input: String): SynchronousResult<Boolean>? {
|
||||
return if (Environment.isExternalStorageManager()) {
|
||||
SynchronousResult(true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user