From feca7ba3fc2f00697b2a47da10fbcdcba36fbca4 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 22 Jun 2023 10:11:11 +0300 Subject: [PATCH] Support for custom directories for manga --- app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 7 ++ .../exceptions/resolve/ToastErrorObserver.kt | 17 +++ .../kotatsu/core/prefs/AppSettings.kt | 13 ++ .../core/ui/dialog/StorageSelectDialog.kt | 101 --------------- .../koitharu/kotatsu/core/util/FileUtil.java | 117 ++++++++++++++++++ .../kotatsu/core/util/ext/ContentResolver.kt | 90 ++++++++++++++ .../kotatsu/core/util/ext/Throwable.kt | 1 + .../kotatsu/list/ui/MangaListFragment.kt | 3 + .../kotatsu/local/data/LocalStorageManager.kt | 25 ++++ .../settings/DownloadsSettingsFragment.kt | 18 ++- .../kotatsu/settings/storage/DirectoryAD.kt | 21 ++++ .../settings/storage/DirectoryDiffCallback.kt | 22 ++++ .../settings/storage/DirectoryModel.kt | 33 +++++ .../storage/MangaDirectorySelectDialog.kt | 78 ++++++++++++ .../storage/MangaDirectorySelectViewModel.kt | 70 +++++++++++ ...RequestStorageManagerPermissionContract.kt | 34 +++++ .../res/layout/dialog_directory_select.xml | 13 ++ app/src/main/res/layout/item_storage.xml | 56 ++++----- app/src/main/res/values/strings.xml | 3 + 20 files changed, 585 insertions(+), 143 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ToastErrorObserver.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/StorageSelectDialog.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt create mode 100644 app/src/main/res/layout/dialog_directory_select.xml 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