Support for custom directories for manga
This commit is contained in:
@@ -113,10 +113,10 @@ dependencies {
|
|||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.5.1'
|
implementation 'androidx.room:room-runtime:2.5.2'
|
||||||
implementation 'androidx.room:room-ktx:2.5.1'
|
implementation 'androidx.room:room-ktx:2.5.2'
|
||||||
//noinspection KaptUsageInsteadOfKsp
|
//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:4.11.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps: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.READ_SYNC_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.WRITE_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.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
|
<application
|
||||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||||
@@ -32,6 +38,7 @@
|
|||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
android:localeConfig="@xml/locales"
|
android:localeConfig="@xml/locales"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Kotatsu"
|
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.getEnumValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
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.core.util.ext.toUriOrNull
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
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.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
|
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -234,6 +236,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
}.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?
|
var mangaStorageDir: File?
|
||||||
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||||
File(it)
|
File(it)
|
||||||
@@ -461,6 +473,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_PROXY_LOGIN = "proxy_login"
|
const val KEY_PROXY_LOGIN = "proxy_login"
|
||||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||||
|
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
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 UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
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 EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||||
is SyncApiException,
|
is SyncApiException,
|
||||||
is ContentUnavailableException,
|
is ContentUnavailableException,
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ abstract class MangaListFragment :
|
|||||||
private val spanSizeLookup = SpanSizeLookup()
|
private val spanSizeLookup = SpanSizeLookup()
|
||||||
private val listCommitCallback = Runnable {
|
private val listCommitCallback = Runnable {
|
||||||
spanSizeLookup.invalidateCache()
|
spanSizeLookup.invalidateCache()
|
||||||
|
viewBinding?.let {
|
||||||
|
paginationListener?.onScrolled(it.recyclerView, 0, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
open val isSwipeRefreshEnabled = true
|
open val isSwipeRefreshEnabled = true
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.local.data
|
|||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import dagger.Reusable
|
import dagger.Reusable
|
||||||
@@ -13,6 +15,7 @@ import okhttp3.Cache
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -74,11 +77,33 @@ class LocalStorageManager @Inject constructor(
|
|||||||
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
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)
|
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
|
@WorkerThread
|
||||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||||
val set = getAvailableStorageDirs()
|
val set = getAvailableStorageDirs()
|
||||||
|
set.addAll(settings.userSpecifiedMangaDirectories)
|
||||||
settings.mangaStorageDir?.let {
|
settings.mangaStorageDir?.let {
|
||||||
set.add(it)
|
set.add(it)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ import kotlinx.coroutines.withContext
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.StorageSelectDialog
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -62,12 +61,7 @@ class DownloadsSettingsFragment :
|
|||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
return when (preference.key) {
|
return when (preference.key) {
|
||||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||||
val ctx = context ?: return false
|
MangaDirectorySelectDialog.show(childFragmentManager)
|
||||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
|
||||||
.setTitle(preference.title ?: "")
|
|
||||||
.setNegativeButton(android.R.string.cancel)
|
|
||||||
.create()
|
|
||||||
.show()
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +76,11 @@ class DownloadsSettingsFragment :
|
|||||||
private fun Preference.bindStorageName() {
|
private fun Preference.bindStorageName() {
|
||||||
viewLifecycleScope.launch {
|
viewLifecycleScope.launch {
|
||||||
val storage = storageManager.getDefaultWriteableDir()
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout
|
<LinearLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?selectableItemBackground"
|
android:background="?selectableItemBackground"
|
||||||
android:minHeight="?listPreferredItemHeightLarge"
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="?listPreferredItemHeight"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
android:paddingStart="?listPreferredItemPaddingStart"
|
android:paddingStart="?listPreferredItemPaddingStart"
|
||||||
android:paddingTop="16dp"
|
android:paddingEnd="?listPreferredItemPaddingEnd">
|
||||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
|
||||||
android:paddingBottom="16dp">
|
|
||||||
|
|
||||||
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
|
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
|
||||||
android:id="@+id/imageView_indicator"
|
android:id="@+id/imageView_indicator"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentStart="true"
|
|
||||||
android:layout_centerVertical="true"
|
|
||||||
android:src="?android:listChoiceIndicatorSingle"
|
android:src="?android:listChoiceIndicatorSingle"
|
||||||
tools:ignore="TouchTargetSizeCheck" />
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/textView_title"
|
android:layout_width="match_parent"
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentEnd="true"
|
|
||||||
android:layout_marginStart="?listPreferredItemPaddingStart"
|
android:layout_marginStart="?listPreferredItemPaddingStart"
|
||||||
android:layout_toEndOf="@id/imageView_indicator"
|
android:orientation="vertical">
|
||||||
android:ellipsize="end"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
|
||||||
tools:text="@tools:sample/lorem[3]" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/textView_subtitle"
|
android:id="@+id/textView_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@id/textView_title"
|
android:ellipsize="end"
|
||||||
android:layout_alignParentEnd="true"
|
android:singleLine="true"
|
||||||
android:layout_marginStart="?listPreferredItemPaddingStart"
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
android:layout_marginTop="6dp"
|
tools:text="@tools:sample/lorem[3]" />
|
||||||
android:layout_toEndOf="@id/imageView_indicator"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
|
||||||
tools:text="@tools:sample/lorem[20]" />
|
|
||||||
|
|
||||||
</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">All unread chapters</string>
|
||||||
<string name="download_option_all_unread_b">All unread chapters (%s)</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="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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user