diff --git a/app/build.gradle b/app/build.gradle
index e357478a8..5e0481475 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -113,10 +113,10 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
- implementation 'androidx.room:room-runtime:2.5.1'
- implementation 'androidx.room:room-ktx:2.5.1'
+ implementation 'androidx.room:room-runtime:2.5.2'
+ implementation 'androidx.room:room-ktx:2.5.2'
//noinspection KaptUsageInsteadOfKsp
- kapt 'androidx.room:room-compiler:2.5.1'
+ kapt 'androidx.room:room-compiler:2.5.2'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a34d57362..26ab7650a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -18,6 +18,12 @@
+
+
+ get() {
+ val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty()
+ return set.mapNotNullToSet { File(it).takeIfReadable() }
+ }
+ set(value) {
+ val set = value.mapToSet { it.absolutePath }
+ prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) }
+ }
+
var mangaStorageDir: File?
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
@@ -461,6 +473,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
+ const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
// About
const val KEY_APP_UPDATE = "app_update"
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/StorageSelectDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/StorageSelectDialog.kt
deleted file mode 100644
index efc47ffde..000000000
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/StorageSelectDialog.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package org.koitharu.kotatsu.core.ui.dialog
-
-import android.content.Context
-import android.content.DialogInterface
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-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.data.LocalStorageManager
-import java.io.File
-
-class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
- DialogInterface by delegate {
-
- fun show() = delegate.show()
-
- class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
-
- 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
- }
- delegate.setAdapter(adapter) { d, i ->
- listener.onStorageSelected(adapter.getItem(i).first)
- d.dismiss()
- }
- }
- }
-
- fun setTitle(@StringRes titleResId: Int): Builder {
- delegate.setTitle(titleResId)
- return this
- }
-
- fun setTitle(title: CharSequence): Builder {
- delegate.setTitle(title)
- return this
- }
-
- fun setNegativeButton(@StringRes textId: Int): Builder {
- delegate.setNegativeButton(textId, null)
- return this
- }
-
- fun create() = StorageSelectDialog(delegate.create())
- }
-
- private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
-
- var selectedItemPosition: Int = -1
- val volumes = getAvailableVolumes(storageManager)
-
- override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
- val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
- val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
- view.tag = it
- }
- val item = volumes[position]
- binding.imageViewIndicator.isChecked = selectedItemPosition == position
- binding.textViewTitle.text = item.second
- binding.textViewSubtitle.text = item.first.path
- return view
- }
-
- override fun getItem(position: Int): Pair = volumes[position]
-
- override fun getItemId(position: Int) = position.toLong()
-
- override fun getCount() = volumes.size
-
- override fun hasStableIds() = true
-
- private fun getAvailableVolumes(storageManager: LocalStorageManager): List> {
- return runBlocking {
- storageManager.getWriteableDirs().map {
- it to storageManager.getStorageDisplayName(it)
- }
- }
- }
- }
-
- fun interface OnStorageSelectListener {
-
- fun onStorageSelected(file: File)
- }
-}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java
new file mode 100644
index 000000000..9bda23a7e
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java
@@ -0,0 +1,117 @@
+package org.koitharu.kotatsu.core.util;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.os.storage.StorageManager;
+import android.os.storage.StorageVolume;
+import android.provider.DocumentsContract;
+
+import androidx.annotation.Nullable;
+
+import java.io.File;
+import java.lang.reflect.Array;
+import java.lang.reflect.Method;
+import java.util.List;
+
+public final class FileUtil {
+
+ private static final String PRIMARY_VOLUME_NAME = "primary";
+
+ @Nullable
+ public static String getFullPathFromTreeUri(@Nullable final Uri treeUri, Context con) {
+ if (treeUri == null) return null;
+ String volumePath = getVolumePath(getVolumeIdFromTreeUri(treeUri), con);
+ if (volumePath == null) return File.separator;
+ if (volumePath.endsWith(File.separator))
+ volumePath = volumePath.substring(0, volumePath.length() - 1);
+
+ String documentPath = getDocumentPathFromTreeUri(treeUri);
+ if (documentPath.endsWith(File.separator))
+ documentPath = documentPath.substring(0, documentPath.length() - 1);
+
+ if (documentPath.length() > 0) {
+ if (documentPath.startsWith(File.separator))
+ return volumePath + documentPath;
+ else
+ return volumePath + File.separator + documentPath;
+ } else return volumePath;
+ }
+
+
+ private static String getVolumePath(final String volumeId, Context context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ return getVolumePathForAndroid11AndAbove(volumeId, context);
+ } else
+ return getVolumePathBeforeAndroid11(volumeId, context);
+ }
+
+
+ private static String getVolumePathBeforeAndroid11(final String volumeId, Context context) {
+ try {
+ StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
+ Class> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");
+ Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList");
+ Method getUuid = storageVolumeClazz.getMethod("getUuid");
+ Method getPath = storageVolumeClazz.getMethod("getPath");
+ Method isPrimary = storageVolumeClazz.getMethod("isPrimary");
+ Object result = getVolumeList.invoke(mStorageManager);
+
+ final int length = Array.getLength(result);
+ for (int i = 0; i < length; i++) {
+ Object storageVolumeElement = Array.get(result, i);
+ String uuid = (String) getUuid.invoke(storageVolumeElement);
+ Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement);
+
+ if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) // primary volume?
+ return (String) getPath.invoke(storageVolumeElement);
+
+ if (uuid != null && uuid.equals(volumeId)) // other volumes?
+ return (String) getPath.invoke(storageVolumeElement);
+ }
+ // not found.
+ return null;
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.R)
+ private static String getVolumePathForAndroid11AndAbove(final String volumeId, Context context) {
+ try {
+ StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
+ List storageVolumes = mStorageManager.getStorageVolumes();
+ for (StorageVolume storageVolume : storageVolumes) {
+ // primary volume?
+ if (storageVolume.isPrimary() && PRIMARY_VOLUME_NAME.equals(volumeId))
+ return storageVolume.getDirectory().getPath();
+
+ // other volumes?
+ String uuid = storageVolume.getUuid();
+ if (uuid != null && uuid.equals(volumeId))
+ return storageVolume.getDirectory().getPath();
+
+ }
+ // not found.
+ return null;
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+
+ private static String getVolumeIdFromTreeUri(final Uri treeUri) {
+ final String docId = DocumentsContract.getTreeDocumentId(treeUri);
+ final String[] split = docId.split(":");
+ if (split.length > 0) return split[0];
+ else return null;
+ }
+
+
+ private static String getDocumentPathFromTreeUri(final Uri treeUri) {
+ final String docId = DocumentsContract.getTreeDocumentId(treeUri);
+ final String[] split = docId.split(":");
+ if ((split.length >= 2) && (split[1] != null)) return split[1];
+ else return File.separator;
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt
new file mode 100644
index 000000000..4a0085b17
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt
@@ -0,0 +1,90 @@
+package org.koitharu.kotatsu.core.util.ext
+
+import android.annotation.TargetApi
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.storage.StorageManager
+import android.provider.DocumentsContract
+import org.koitharu.kotatsu.parsers.util.removeSuffix
+import java.io.File
+import java.lang.reflect.Array as ArrayReflect
+
+private const val PRIMARY_VOLUME_NAME = "primary"
+
+fun Uri.resolveFile(context: Context): File? {
+ val volumeId = getVolumeIdFromTreeUri(this) ?: return null
+ val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null
+ val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null
+
+ return File(
+ if (documentPath.isNotEmpty()) {
+ if (documentPath.startsWith(File.separator)) {
+ volumePath + documentPath
+ } else {
+ volumePath + File.separator + documentPath
+ }
+ } else {
+ volumePath
+ },
+ )
+}
+
+private fun getVolumePath(volumeId: String, context: Context): String? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ getVolumePathForAndroid11AndAbove(volumeId, context)
+ } else {
+ getVolumePathBeforeAndroid11(volumeId, context)
+ }
+}
+
+
+private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching {
+ val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
+ val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
+ val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
+ val getUuid = storageVolumeClazz.getMethod("getUuid")
+ val getPath = storageVolumeClazz.getMethod("getPath")
+ val isPrimary = storageVolumeClazz.getMethod("isPrimary")
+ val result = getVolumeList.invoke(mStorageManager)
+ val length = ArrayReflect.getLength(checkNotNull(result))
+ (0 until length).firstNotNullOfOrNull { i ->
+ val storageVolumeElement = ArrayReflect.get(result, i)
+ val uuid = getUuid.invoke(storageVolumeElement) as String
+ val primary = isPrimary.invoke(storageVolumeElement) as Boolean
+ when {
+ primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String
+ uuid == volumeId -> getPath.invoke(storageVolumeElement) as String
+ else -> null
+ }
+ }
+}.onFailure {
+ it.printStackTraceDebug()
+}.getOrNull()
+
+@TargetApi(Build.VERSION_CODES.R)
+private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
+ val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
+ storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
+ if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) {
+ volume.directory?.path
+ } else {
+ val uuid = volume.uuid
+ if (uuid != null && uuid == volumeId) volume.directory?.path else null
+ }
+ }
+}.onFailure {
+ it.printStackTraceDebug()
+}.getOrNull()
+
+private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
+ val docId = DocumentsContract.getTreeDocumentId(treeUri)
+ val split = docId.split(":".toRegex())
+ return split.firstOrNull()?.takeUnless { it.isEmpty() }
+}
+
+private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {
+ val docId = DocumentsContract.getTreeDocumentId(treeUri)
+ val split: Array = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ return if (split.size >= 2 && split[1] != null) split[1] else File.separator
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt
index 29defe131..abafc3db7 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt
@@ -34,6 +34,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
+ is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is SyncApiException,
is ContentUnavailableException,
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index 021f4d83a..e5670948a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
@@ -78,6 +78,9 @@ abstract class MangaListFragment :
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
spanSizeLookup.invalidateCache()
+ viewBinding?.let {
+ paginationListener?.onScrolled(it.recyclerView, 0, 0)
+ }
}
open val isSwipeRefreshEnabled = true
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
index 9684dd63c..b972fbe68 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.local.data
import android.content.ContentResolver
import android.content.Context
+import android.content.Intent
+import android.net.Uri
import android.os.StatFs
import androidx.annotation.WorkerThread
import dagger.Reusable
@@ -13,6 +15,7 @@ import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName
+import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File
import javax.inject.Inject
@@ -74,11 +77,33 @@ class LocalStorageManager @Inject constructor(
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
}
+ suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
+ uri.resolveFile(context)
+ }
+
+ fun takePermissions(uri: Uri) {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ contentResolver.takePersistableUriPermission(uri, flags)
+ }
+
+ @Deprecated("")
fun getStorageDisplayName(file: File) = file.getStorageName(context)
+ suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) {
+ val packageName = context.packageName
+ if (dir.absolutePath.contains(packageName)) {
+ dir.getStorageName(context)
+ } else if (isFullPath) {
+ dir.path
+ } else {
+ dir.name
+ }
+ }
+
@WorkerThread
private fun getConfiguredStorageDirs(): MutableSet {
val set = getAvailableStorageDirs()
+ set.addAll(settings.userSpecifiedMangaDirectories)
settings.mangaStorageDir?.let {
set.add(it)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt
index 0bcd68106..85eb56d6d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt
@@ -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)
+ }
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt
new file mode 100644
index 000000000..aca367204
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt
@@ -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,
+) = adapterDelegateViewBinding(
+ { 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
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt
new file mode 100644
index 000000000..ce30fdc25
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt
@@ -0,0 +1,22 @@
+package org.koitharu.kotatsu.settings.storage
+
+import androidx.recyclerview.widget.DiffUtil.ItemCallback
+
+class DirectoryDiffCallback : ItemCallback() {
+
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt
new file mode 100644
index 000000000..5113bb2ea
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt
@@ -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
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt
new file mode 100644
index 000000000..f276ef639
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt
@@ -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(),
+ OnListItemClickListener {
+
+ 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)
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt
new file mode 100644
index 000000000..6a4b1bb57
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt
@@ -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())
+ val onDismissDialog = MutableEventFlow()
+ val onPickDirectory = MutableEventFlow()
+
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt
new file mode 100644
index 000000000..85fbab3d1
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt
@@ -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() {
+
+ 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? {
+ return if (Environment.isExternalStorageManager()) {
+ SynchronousResult(true)
+ } else {
+ null
+ }
+ }
+}
diff --git a/app/src/main/res/layout/dialog_directory_select.xml b/app/src/main/res/layout/dialog_directory_select.xml
new file mode 100644
index 000000000..65cabac5c
--- /dev/null
+++ b/app/src/main/res/layout/dialog_directory_select.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/app/src/main/res/layout/item_storage.xml b/app/src/main/res/layout/item_storage.xml
index 72e9f513b..c56a83356 100644
--- a/app/src/main/res/layout/item_storage.xml
+++ b/app/src/main/res/layout/item_storage.xml
@@ -1,48 +1,46 @@
-
+ android:paddingEnd="?listPreferredItemPaddingEnd">
-
+ android:orientation="vertical">
-
+
-
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3c350fc05..0c95c0b16 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -445,4 +445,7 @@
All unread chapters
All unread chapters (%s)
Select chapters manually
+ Custom directory
+ Pick custom directory
+ You have no access to this file or directory