Support for custom directories for manga
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
@@ -32,6 +38,7 @@
|
||||
android:largeHeap="true"
|
||||
android:localeConfig="@xml/locales"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kotatsu"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
|
||||
class ToastErrorObserver(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
) : ErrorObserver(host, fragment, null, null) {
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
|
||||
toast.show()
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,11 @@ import org.koitharu.kotatsu.core.util.ext.filterToSet
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
|
||||
import java.io.File
|
||||
@@ -234,6 +236,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
||||
|
||||
var userSpecifiedMangaDirectories: Set<File>
|
||||
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"
|
||||
|
||||
@@ -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<File, String> = volumes[position]
|
||||
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getCount() = volumes.size
|
||||
|
||||
override fun hasStableIds() = true
|
||||
|
||||
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
|
||||
return runBlocking {
|
||||
storageManager.getWriteableDirs().map {
|
||||
it to storageManager.getStorageDisplayName(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnStorageSelectListener {
|
||||
|
||||
fun onStorageSelected(file: File)
|
||||
}
|
||||
}
|
||||
117
app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java
Normal file
117
app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileUtil.java
Normal file
@@ -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<StorageVolume> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
return if (split.size >= 2 && split[1] != null) split[1] else File.separator
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<File> {
|
||||
val set = getAvailableStorageDirs()
|
||||
set.addAll(settings.userSpecifiedMangaDirectories)
|
||||
settings.mangaStorageDir?.let {
|
||||
set.add(it)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/res/layout/dialog_directory_select.xml
Normal file
13
app/src/main/res/layout/dialog_directory_select.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
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:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:scrollIndicators="top|bottom"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
@@ -1,48 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:minHeight="?listPreferredItemHeightLarge"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?listPreferredItemHeight"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="12dp"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
android:paddingBottom="16dp">
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
|
||||
android:id="@+id/imageView_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:src="?android:listChoiceIndicatorSingle"
|
||||
tools:ignore="TouchTargetSizeCheck" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginStart="?listPreferredItemPaddingStart"
|
||||
android:layout_toEndOf="@id/imageView_indicator"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
tools:text="@tools:sample/lorem[3]" />
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/textView_title"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginStart="?listPreferredItemPaddingStart"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_toEndOf="@id/imageView_indicator"
|
||||
android:ellipsize="end"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
tools:text="@tools:sample/lorem[20]" />
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
tools:text="@tools:sample/lorem[3]" />
|
||||
|
||||
</RelativeLayout>
|
||||
<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>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -445,4 +445,7 @@
|
||||
<string name="download_option_all_unread">All unread chapters</string>
|
||||
<string name="download_option_all_unread_b">All unread chapters (%s)</string>
|
||||
<string name="download_option_manual_selection">Select chapters manually</string>
|
||||
<string name="custom_directory">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>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user