From 6a0ad7f79b198b7c82b0edc3448a118c06db5ec3 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 15 Mar 2025 10:38:41 +0200 Subject: [PATCH] Improve FileNotFoundException handling (#1332) --- .../koitharu/kotatsu/core/util/ext/File.kt | 12 +++++- .../kotatsu/core/util/ext/Throwable.kt | 37 ++++++++++++++++++- .../local/data/LocalMangaRepository.kt | 4 +- .../kotatsu/local/data/LocalStorageManager.kt | 15 +++----- .../storage/MangaDirectorySelectViewModel.kt | 3 +- .../directories/MangaDirectoriesViewModel.kt | 6 ++- app/src/main/res/values/strings.xml | 1 + 7 files changed, 61 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt index 3a89f87f8..c6ed7a6ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt @@ -28,9 +28,9 @@ fun File.subdir(name: String) = File(this, name).also { if (!it.exists()) it.mkdirs() } -fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } +fun File.takeIfReadable() = takeIf { it.isReadable() } -fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } +fun File.takeIfWriteable() = takeIf { it.isWriteable() } fun File.isNotEmpty() = length() != 0L @@ -110,3 +110,11 @@ fun File.walkCompat(includeDirectories: Boolean): Sequence = if (Build.VER val File.normalizedExtension: String? get() = MimeTypes.getNormalizedExtension(name) + +fun File.isReadable() = runCatching { + canRead() +}.getOrDefault(false) + +fun File.isWriteable() = runCatching { + canWrite() +}.getOrDefault(false) 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 1ea3b999f..ef927b1a8 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 @@ -41,6 +41,7 @@ import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException +import java.io.File import java.net.ConnectException import java.net.NoRouteToHostException import java.net.SocketException @@ -52,6 +53,8 @@ private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_CONNECTION_RESET = "Connection reset" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" +private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$") + fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources) ?: resources.getString(R.string.error_occurred) @@ -86,7 +89,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) - is FileNotFoundException -> resources.getString(R.string.file_not_found) + is FileNotFoundException -> parseMessage(resources) ?: message is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) @@ -225,3 +228,35 @@ fun Throwable.isWebViewUnavailable(): Boolean { @Suppress("FunctionName") fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) + +fun FileNotFoundException.getFile(): File? { + val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null + return groups.getOrNull(1)?.let { File(it) } +} + +fun FileNotFoundException.parseMessage(resources: Resources): String? { + /* + Examples: + /storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system) + /storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory) + /storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error) + */ + val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null + val path = groups.getOrNull(1) + val error = groups.getOrNull(2) + val baseMessageIs = when (error) { + "EROFS" -> R.string.no_write_permission_to_file + "ENOENT" -> R.string.file_not_found + else -> return null + } + return if (path.isNullOrEmpty()) { + resources.getString(baseMessageIs) + } else { + resources.getString( + R.string.inline_preference_pattern, + resources.getString(baseMessageIs), + path, + ) + } +} + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 5f05be1d9..c381bfcd1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -17,7 +17,9 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.ext.deleteAwait +import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.core.util.ext.withChildren import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.input.LocalMangaParser @@ -203,7 +205,7 @@ class LocalMangaRepository @Inject constructor( override suspend fun getRelated(seed: Manga): List = emptyList() suspend fun getOutputDir(manga: Manga, fallback: File?): File? { - val defaultDir = fallback ?: storageManager.getDefaultWriteableDir() + val defaultDir = fallback?.takeIfWriteable() ?: storageManager.getDefaultWriteableDir() if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) { return defaultDir } 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 cb14266e6..626772f43 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 @@ -22,7 +22,10 @@ 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.isFileUri +import org.koitharu.kotatsu.core.util.ext.isReadable +import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.core.util.ext.resolveFile +import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.parsers.util.mapToSet import java.io.File import javax.inject.Inject @@ -81,8 +84,8 @@ class LocalStorageManager @Inject constructor( } suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) { - val preferredDir = settings.mangaStorageDir?.takeIf { it.isWriteable() } - preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() } + val preferredDir = settings.mangaStorageDir?.takeIfWriteable() + preferredDir ?: getFallbackStorageDir()?.takeIfWriteable() } suspend fun getApplicationStorageDirs(): Set = runInterruptible(Dispatchers.IO) { @@ -184,12 +187,4 @@ class LocalStorageManager @Inject constructor( CACHE_SIZE_MIN } } - - private fun File.isReadable() = runCatching { - canRead() - }.getOrDefault(false) - - private fun File.isWriteable() = runCatching { - canWrite() - }.getOrDefault(false) } 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 index 3df94e208..053c222d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt @@ -9,6 +9,7 @@ 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.core.util.ext.isWriteable import org.koitharu.kotatsu.local.data.LocalStorageManager import javax.inject.Inject @@ -41,7 +42,7 @@ class MangaDirectorySelectViewModel @Inject constructor( val dir = requireNotNull(storageManager.resolveUri(uri)) { "Cannot resolve file name of \"$uri\"" } - if (!dir.canWrite()) { + if (!dir.isWriteable()) { throw AccessDeniedException(dir) } if (dir !in storageManager.getApplicationStorageDirs()) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt index d9d8834cb..a3d8bf8a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.isReadable +import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.settings.storage.DirectoryModel import java.io.File @@ -69,7 +71,7 @@ class MangaDirectoriesViewModel @Inject constructor( titleRes = 0, file = dir, isChecked = dir == downloadDir, - isAvailable = dir.canRead() && dir.canWrite(), + isAvailable = dir.isReadable() && dir.isWriteable(), isRemovable = false, ) } @@ -79,7 +81,7 @@ class MangaDirectoriesViewModel @Inject constructor( titleRes = 0, file = dir, isChecked = dir == downloadDir, - isAvailable = dir.canRead() && dir.canWrite(), + isAvailable = dir.isReadable() && dir.isWriteable(), isRemovable = true, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0b254ffe..b37edda5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -818,4 +818,5 @@ Link to manga in Kotatsu Clear browser data Clear browser data such as cache and cookies. Warning: Authorization in manga sources may become invalid + Does not have permission to write a file