diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f62ce9cbc..a48438b57 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,7 +164,6 @@ dependencies { implementation(compose.ui.tooling.preview) implementation(compose.ui.util) implementation(compose.accompanist.webview) - implementation(compose.accompanist.permissions) implementation(compose.accompanist.systemuicontroller) lintChecks(compose.lintchecks) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6cc6ed32..fe6f4a6c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,9 +7,6 @@ - - - @@ -39,7 +36,6 @@ android:largeHeap="true" android:localeConfig="@xml/locales_config" android:networkSecurityConfig="@xml/network_security_config" - android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Tachiyomi"> diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index 631a68949..04d1137a0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -36,12 +35,12 @@ import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding -import eu.kanade.presentation.permissions.PermissionRequestHelper import eu.kanade.presentation.util.relativeTimeSpanString import eu.kanade.tachiyomi.data.backup.BackupCreateJob import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupRestoreJob import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.sync.SyncManager import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService @@ -77,8 +76,6 @@ object SettingsDataScreen : SearchableSettings { val backupPreferences = Injekt.get() val storagePreferences = Injekt.get() - PermissionRequestHelper.requestStoragePermission() - val syncPreferences = remember { Injekt.get() } val syncService by syncPreferences.syncService().collectAsState() @@ -107,8 +104,10 @@ object SettingsDataScreen : SearchableSettings { context.contentResolver.takePersistableUriPermission(uri, flags) - val file = UniFile.fromUri(context, uri) - storageDirPref.set(file.uri.toString()) + UniFile.fromUri(context, uri)?.let { + storageDirPref.set(it.uri.toString()) + } + Injekt.get().invalidateCache() } } diff --git a/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt b/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt index f35336d50..64f59280c 100644 --- a/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt +++ b/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt @@ -1,8 +1,10 @@ package eu.kanade.presentation.more.stats import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -12,6 +14,7 @@ import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import eu.kanade.presentation.more.stats.components.StatsItem import eu.kanade.presentation.more.stats.components.StatsOverviewItem @@ -63,22 +66,24 @@ private fun OverviewSection( .toDurationString(context, fallback = none) } StatsSection(MR.strings.label_overview_section) { - Row { + Row( + modifier = Modifier.height(IntrinsicSize.Min), + ) { StatsOverviewItem( title = data.libraryMangaCount.toString(), subtitle = stringResource(MR.strings.in_library), icon = Icons.Outlined.CollectionsBookmark, ) - StatsOverviewItem( - title = data.completedMangaCount.toString(), - subtitle = stringResource(MR.strings.label_completed_titles), - icon = Icons.Outlined.LocalLibrary, - ) StatsOverviewItem( title = readDurationString, subtitle = stringResource(MR.strings.label_read_duration), icon = Icons.Outlined.Schedule, ) + StatsOverviewItem( + title = data.completedMangaCount.toString(), + subtitle = stringResource(MR.strings.label_completed_titles), + icon = Icons.Outlined.LocalLibrary, + ) } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt b/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt index 32f4fcd28..8002b3d04 100644 --- a/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt +++ b/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt @@ -3,6 +3,8 @@ package eu.kanade.presentation.more.stats.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -53,7 +55,9 @@ private fun RowScope.BaseStatsItem( icon: ImageVector? = null, ) { Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -74,6 +78,7 @@ private fun RowScope.BaseStatsItem( textAlign = TextAlign.Center, ) if (icon != null) { + Spacer(modifier = Modifier.weight(1f)) Icon( imageVector = icon, contentDescription = null, diff --git a/app/src/main/java/eu/kanade/presentation/permissions/PermissionRequestHelper.kt b/app/src/main/java/eu/kanade/presentation/permissions/PermissionRequestHelper.kt deleted file mode 100644 index 7ce28f9da..000000000 --- a/app/src/main/java/eu/kanade/presentation/permissions/PermissionRequestHelper.kt +++ /dev/null @@ -1,20 +0,0 @@ -package eu.kanade.presentation.permissions - -import android.Manifest -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import com.google.accompanist.permissions.rememberPermissionState - -/** - * Launches request for [Manifest.permission.WRITE_EXTERNAL_STORAGE] permission - */ -object PermissionRequestHelper { - - @Composable - fun requestStoragePermission() { - val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) - LaunchedEffect(Unit) { - permissionState.launchPermissionRequest() - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 67ed7146e..27235ba64 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -87,8 +87,8 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { if (packageName != process) WebView.setDataDirectorySuffix(process) } - Injekt.importModule(AppModule(this)) Injekt.importModule(PreferenceModule(this)) + Injekt.importModule(AppModule(this)) Injekt.importModule(DomainModule()) setupAcra() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt index c28d12f26..bc37b8c75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt @@ -21,7 +21,7 @@ import eu.kanade.tachiyomi.util.system.workManager import logcat.LogPriority import tachiyomi.core.util.system.logcat import tachiyomi.domain.backup.service.BackupPreferences -import tachiyomi.domain.storage.service.StoragePreferences +import tachiyomi.domain.storage.service.StorageManager import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Date @@ -43,6 +43,8 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() ?: getAutomaticBackupLocation() + ?: return Result.failure() + val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupCreateFlags.AutomaticDefaults) try { @@ -56,7 +58,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete if (isAutoBackup) { backupPreferences.lastAutoBackupTimestamp().set(Date().time) } else { - notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())) + notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!) } Result.success() } catch (e: Exception) { @@ -75,13 +77,9 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete ) } - private fun getAutomaticBackupLocation(): Uri { - val storagePreferences = Injekt.get() - return storagePreferences.baseStorageDirectory().get().let { - val dir = UniFile.fromUri(context, it.toUri()) - .createDirectory(StoragePreferences.BACKUP_DIR) - dir.uri - } + private fun getAutomaticBackupLocation(): Uri? { + val storageManager = Injekt.get() + return storageManager.getAutomaticBackupsDirectory()?.uri } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt index 415900488..1c335ae6e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt @@ -93,17 +93,16 @@ class BackupCreator( if (isAutoBackup) { // Get dir of file and create val dir = UniFile.fromUri(context, uri) - .createDirectory("automatic") // Delete older backups - dir.listFiles { _, filename -> Backup.filenameRegex.matches(filename) } + dir?.listFiles { _, filename -> Backup.filenameRegex.matches(filename) } .orEmpty() .sortedByDescending { it.name } .drop(MAX_AUTO_BACKUPS - 1) .forEach { it.delete() } // Create new file to place backup - dir.createFile(Backup.getFilename()) + dir?.createFile(Backup.getFilename()) } else { UniFile.fromUri(context, uri) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index 407d2d505..9fe7dbfee 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.download import android.app.Application import android.content.Context import android.net.Uri -import androidx.core.net.toUri import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.Source @@ -19,6 +18,7 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -40,6 +40,8 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.protobuf.ProtoBuf import logcat.LogPriority +import tachiyomi.core.storage.extension +import tachiyomi.core.storage.nameWithoutExtension import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchNonCancellable import tachiyomi.core.util.system.logcat @@ -64,7 +66,7 @@ class DownloadCache( private val provider: DownloadProvider = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(), - private val storagePreferences: StoragePreferences = Injekt.get(), + storagePreferences: StoragePreferences = Injekt.get(), ) { private val scope = CoroutineScope(Dispatchers.IO) @@ -95,16 +97,9 @@ class DownloadCache( get() = File(context.cacheDir, "dl_index_cache") private val rootDownloadsDirLock = Mutex() - private var rootDownloadsDir = RootDirectory(getDirectoryFromPreference()) + private var rootDownloadsDir = RootDirectory(provider.downloadsDir) init { - storagePreferences.baseStorageDirectory().changes() - .onEach { - rootDownloadsDir = RootDirectory(getDirectoryFromPreference()) - invalidateCache() - } - .launchIn(scope) - // Attempt to read cache file scope.launch { rootDownloadsDirLock.withLock { @@ -119,6 +114,14 @@ class DownloadCache( } } } + + storagePreferences.baseStorageDirectory().changes() + .drop(1) + .onEach { + rootDownloadsDir = RootDirectory(provider.downloadsDir) + invalidateCache() + } + .launchIn(scope) } /** @@ -293,14 +296,6 @@ class DownloadCache( renewalJob?.cancel() } - /** - * Returns the downloads directory from the user's preferences. - */ - private fun getDirectoryFromPreference(): UniFile { - return UniFile.fromUri(context, storagePreferences.baseStorageDirectory().get().toUri()) - .createDirectory(StoragePreferences.DOWNLOADS_DIR) - } - /** * Renews the downloads cache. */ @@ -332,7 +327,7 @@ class DownloadCache( val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } rootDownloadsDirLock.withLock { - val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty() + val sourceDirs = rootDownloadsDir.dir?.listFiles().orEmpty() .filter { it.isDirectory && !it.name.isNullOrBlank() } .mapNotNull { dir -> val sourceId = sourceMap[dir.name!!.lowercase()] @@ -345,12 +340,12 @@ class DownloadCache( sourceDirs.values .map { sourceDir -> async { - sourceDir.mangaDirs = sourceDir.dir.listFiles().orEmpty() + sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty() .filter { it.isDirectory && !it.name.isNullOrBlank() } .associate { it.name!! to MangaDirectory(it) } sourceDir.mangaDirs.values.forEach { mangaDir -> - val chapterDirs = mangaDir.dir.listFiles().orEmpty() + val chapterDirs = mangaDir.dir?.listFiles().orEmpty() .mapNotNull { when { // Ignore incomplete downloads @@ -358,8 +353,7 @@ class DownloadCache( // Folder of images it.isDirectory -> it.name // CBZ files - it.isFile && it.name?.endsWith(".cbz") == true -> - it.name!!.substringBeforeLast(".cbz") + it.isFile && it.extension == "cbz" -> it.nameWithoutExtension // Anything else is irrelevant else -> null } @@ -427,7 +421,7 @@ class DownloadCache( @Serializable private class RootDirectory( @Serializable(with = UniFileAsStringSerializer::class) - val dir: UniFile, + val dir: UniFile?, var sourceDirs: Map = mapOf(), ) @@ -437,7 +431,7 @@ private class RootDirectory( @Serializable private class SourceDirectory( @Serializable(with = UniFileAsStringSerializer::class) - val dir: UniFile, + val dir: UniFile?, var mangaDirs: Map = mapOf(), ) @@ -447,17 +441,26 @@ private class SourceDirectory( @Serializable private class MangaDirectory( @Serializable(with = UniFileAsStringSerializer::class) - val dir: UniFile, + val dir: UniFile?, var chapterDirs: MutableSet = mutableSetOf(), ) -private object UniFileAsStringSerializer : KSerializer { +private object UniFileAsStringSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UniFile", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: UniFile) { - return encoder.encodeString(value.uri.toString()) + override fun serialize(encoder: Encoder, value: UniFile?) { + return if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeString(value.uri.toString()) + } } - override fun deserialize(decoder: Decoder): UniFile { - return UniFile.fromUri(Injekt.get(), Uri.parse(decoder.decodeString())) + + override fun deserialize(decoder: Decoder): UniFile? { + return if (decoder.decodeNotNullMark()) { + UniFile.fromUri(Injekt.get(), Uri.parse(decoder.decodeString())) + } else { + decoder.decodeNull() + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 0b404165f..ba5c4d81a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.runBlocking import logcat.LogPriority import tachiyomi.core.i18n.stringResource +import tachiyomi.core.storage.extension import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.system.logcat import tachiyomi.domain.category.interactor.GetCategories @@ -340,7 +341,7 @@ class DownloadManager( .firstOrNull() ?: return var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator) - if (oldDownload.isFile && oldDownload.name?.endsWith(".cbz") == true) { + if (oldDownload.isFile && oldDownload.extension == "cbz") { newName += ".cbz" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index eb444398a..e29628878 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -1,19 +1,15 @@ package eu.kanade.tachiyomi.data.download import android.content.Context -import androidx.core.net.toUri import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.util.storage.DiskUtil -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import logcat.LogPriority import tachiyomi.core.i18n.stringResource import tachiyomi.core.util.system.logcat import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.storage.service.StoragePreferences +import tachiyomi.domain.storage.service.StorageManager import tachiyomi.i18n.MR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -26,30 +22,11 @@ import uy.kohesive.injekt.api.get */ class DownloadProvider( private val context: Context, - private val storagePreferences: StoragePreferences = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), ) { - private val scope = MainScope() - - /** - * The root directory for downloads. - */ - private var downloadsDir = setDownloadsLocation() - - init { - storagePreferences.baseStorageDirectory().changes() - .onEach { downloadsDir = setDownloadsLocation() } - .launchIn(scope) - } - - private fun setDownloadsLocation(): UniFile { - return storagePreferences.baseStorageDirectory().get().let { - val dir = UniFile.fromUri(context, it.toUri()) - .createDirectory(StoragePreferences.DOWNLOADS_DIR) - DiskUtil.createNoMediaFile(dir, context) - dir - } - } + val downloadsDir: UniFile? + get() = storageManager.getDownloadsDirectory() /** * Returns the download directory for a manga. For internal use only. @@ -59,12 +36,12 @@ class DownloadProvider( */ internal fun getMangaDir(mangaTitle: String, source: Source): UniFile { try { - return downloadsDir - .createDirectory(getSourceDirName(source)) - .createDirectory(getMangaDirName(mangaTitle)) + return downloadsDir!! + .createDirectory(getSourceDirName(source))!! + .createDirectory(getMangaDirName(mangaTitle))!! } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Invalid download directory" } - throw Exception(context.stringResource(MR.strings.invalid_location, downloadsDir)) + throw Exception(context.stringResource(MR.strings.invalid_location, downloadsDir ?: "")) } } @@ -74,7 +51,7 @@ class DownloadProvider( * @param source the source to query. */ fun findSourceDir(source: Source): UniFile? { - return downloadsDir.findFile(getSourceDirName(source), true) + return downloadsDir?.findFile(getSourceDirName(source), true) } /** @@ -114,7 +91,7 @@ class DownloadProvider( val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList() return mangaDir to chapters.mapNotNull { chapter -> getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence() - .mapNotNull { mangaDir.findFile(it) } + .mapNotNull { mangaDir.findFile(it, true) } .firstOrNull() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index f89146e3a..5e58d4f6a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -43,6 +43,7 @@ import okhttp3.Response import tachiyomi.core.i18n.stringResource import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.ComicInfo +import tachiyomi.core.storage.extension import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchNow import tachiyomi.core.util.lang.withIOContext @@ -334,7 +335,7 @@ class Downloader( } val chapterDirname = provider.getChapterDirName(download.chapter.name, download.chapter.scanlator) - val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) + val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!! try { // If the page list already exists, start from the file @@ -353,7 +354,7 @@ class Downloader( // Delete all temporary (unfinished) files tmpDir.listFiles() - ?.filter { it.name!!.endsWith(".tmp") } + ?.filter { it.extension == "tmp" } ?.forEach { it.delete() } download.status = Download.State.DOWNLOADING @@ -479,7 +480,7 @@ class Downloader( page.progress = 0 return flow { val response = source.getImage(page) - val file = tmpDir.createFile("$filename.tmp") + val file = tmpDir.createFile("$filename.tmp")!! try { response.body.source().saveTo(file.openOutputStream()) val extension = getImageExtension(response, file) @@ -511,7 +512,7 @@ class Downloader( * @param filename the filename of the image. */ private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile { - val tmpFile = tmpDir.createFile("$filename.tmp") + val tmpFile = tmpDir.createFile("$filename.tmp")!! cacheFile.inputStream().use { input -> tmpFile.openOutputStream().use { output -> input.copyTo(output) @@ -602,7 +603,7 @@ class Downloader( dirname: String, tmpDir: UniFile, ) { - val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") + val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")!! ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut -> zipOut.setMethod(ZipEntry.STORED) @@ -641,8 +642,8 @@ class Downloader( val categories = getCategories.await(manga.id).map { it.name.trim() }.takeUnless { it.isEmpty() } val comicInfo = getComicInfo(manga, chapter, chapterUrl, categories) // Remove the old file - dir.findFile(COMIC_INFO_FILE)?.delete() - dir.createFile(COMIC_INFO_FILE).openOutputStream().use { + dir.findFile(COMIC_INFO_FILE, true)?.delete() + dir.createFile(COMIC_INFO_FILE)!!.openOutputStream().use { val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo) it.write(comicInfoString.toByteArray()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt index 4a21b9bc0..d9e8dc54a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt @@ -33,12 +33,14 @@ import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.MR import uy.kohesive.injekt.injectLazy +import java.math.RoundingMode import java.text.NumberFormat class LibraryUpdateNotifier(private val context: Context) { private val preferences: SecurityPreferences by injectLazy() private val percentFormatter = NumberFormat.getPercentInstance().apply { + roundingMode = RoundingMode.DOWN maximumFractionDigits = 0 } @@ -78,20 +80,17 @@ class LibraryUpdateNotifier(private val context: Context) { * @param total the total progress. */ fun showProgressNotification(manga: List, current: Int, total: Int) { - if (preferences.hideNotificationContent().get()) { - progressNotificationBuilder - .setContentTitle(context.stringResource(MR.strings.notification_check_updates)) - .setContentText("($current/$total)") - } else { + progressNotificationBuilder + .setContentTitle( + context.stringResource( + MR.strings.notification_updating_progress, + percentFormatter.format(current.toFloat() / total), + ), + ) + + if (!preferences.hideNotificationContent().get()) { val updatingText = manga.joinToString("\n") { it.title.chop(40) } - progressNotificationBuilder - .setContentTitle( - context.stringResource( - MR.strings.notification_updating_progress, - percentFormatter.format(current.toFloat() / total), - ), - ) - .setStyle(NotificationCompat.BigTextStyle().bigText(updatingText)) + progressNotificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText)) } context.notify( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt index 46de5e735..e0103371e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt @@ -4,6 +4,7 @@ import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.util.lang.htmlDecode import kotlinx.serialization.Serializable import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat @@ -25,7 +26,7 @@ data class ALManga( title = title_user_pref total_chapters = this@ALManga.total_chapters cover_url = image_url_lge - summary = description ?: "" + summary = description?.htmlDecode() ?: "" tracking_url = AnilistApi.mangaUrl(media_id) publishing_status = this@ALManga.publishing_status publishing_type = format diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt index 903f13566..c95e73017 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.json.Json import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.serialization.XML +import tachiyomi.core.storage.AndroidStorageFolderProvider import tachiyomi.data.AndroidDatabaseHandler import tachiyomi.data.Database import tachiyomi.data.DatabaseHandler @@ -35,6 +36,7 @@ import tachiyomi.data.Mangas import tachiyomi.data.StringListColumnAdapter import tachiyomi.data.UpdateStrategyColumnAdapter import tachiyomi.domain.source.service.SourceManager +import tachiyomi.domain.storage.service.StorageManager import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.io.LocalSourceFileSystem import uy.kohesive.injekt.api.InjektModule @@ -124,8 +126,10 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { ImageSaver(app) } - addSingletonFactory { LocalSourceFileSystem(app) } + addSingletonFactory { AndroidStorageFolderProvider(app) } + addSingletonFactory { LocalSourceFileSystem(get()) } addSingletonFactory { LocalCoverManager(app, get()) } + addSingletonFactory { StorageManager(app, get()) } // Asynchronously init expensive components for a faster cold start ContextCompat.getMainExecutor(app).execute { diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt index ce5d7eedd..0981ead0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.system.isDevFlavor import tachiyomi.core.preference.AndroidPreferenceStore import tachiyomi.core.preference.PreferenceStore -import tachiyomi.core.provider.AndroidStorageFolderProvider +import tachiyomi.core.storage.AndroidStorageFolderProvider import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.library.service.LibraryPreferences @@ -55,9 +55,6 @@ class PreferenceModule(val app: Application) : InjektModule { addSingletonFactory { BackupPreferences(get()) } - addSingletonFactory { - AndroidStorageFolderProvider(app) - } addSingletonFactory { StoragePreferences( folderProvider = get(), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/SecureActivityDelegate.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/SecureActivityDelegate.kt index 02589cec9..dc017c328 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/SecureActivityDelegate.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/delegate/SecureActivityDelegate.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.ui.security.UnlockActivity import eu.kanade.tachiyomi.util.system.AuthenticatorUtil import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported +import eu.kanade.tachiyomi.util.system.overridePendingTransitionCompat import eu.kanade.tachiyomi.util.view.setSecureScreen import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn @@ -106,7 +107,7 @@ class SecureActivityDelegateImpl : SecureActivityDelegate, DefaultLifecycleObser if (activity.isAuthenticationSupported()) { if (!SecureActivityDelegate.requireUnlock) return activity.startActivity(Intent(activity, UnlockActivity::class.java)) - activity.overridePendingTransition(0, 0) + activity.overridePendingTransitionCompat(0, 0) } else { securityPreferences.useAuthenticator().set(false) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt index a05cba620..2ce4685bb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt @@ -13,7 +13,6 @@ import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions import eu.kanade.presentation.components.TabbedScreen -import eu.kanade.presentation.permissions.PermissionRequestHelper import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel @@ -66,9 +65,6 @@ data class BrowseTab( onChangeSearchQuery = extensionsScreenModel::search, ) - // For local source - PermissionRequestHelper.requestStoragePermission() - LaunchedEffect(Unit) { (context as? MainActivity)?.ready = true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index 236e80f83..13319c948 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -66,6 +66,7 @@ object HomeScreen : Screen() { private val showBottomNavEvent = Channel() private const val TabFadeDuration = 200 + private const val TabNavigatorKey = "HomeTabs" private val tabs = listOf( LibraryTab, @@ -80,6 +81,7 @@ object HomeScreen : Screen() { val navigator = LocalNavigator.currentOrThrow TabNavigator( tab = LibraryTab, + key = TabNavigatorKey, ) { tabNavigator -> // Provide usable navigator to content screen CompositionLocalProvider(LocalNavigator provides navigator) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index f5b647e85..7900b377c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -70,6 +70,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.system.hasDisplayCutout import eu.kanade.tachiyomi.util.system.isNightMode +import eu.kanade.tachiyomi.util.system.overridePendingTransitionCompat import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.setComposeContent @@ -138,7 +139,7 @@ class ReaderActivity : BaseActivity() { */ override fun onCreate(savedInstanceState: Bundle?) { registerSecureActivity(this) - overridePendingTransition(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit) + overridePendingTransitionCompat(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit) super.onCreate(savedInstanceState) @@ -269,7 +270,7 @@ class ReaderActivity : BaseActivity() { override fun finish() { viewModel.onActivityFinish() super.finish() - overridePendingTransition(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit) + overridePendingTransitionCompat(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit) } override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 4989b4704..6a31ed029 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import tachiyomi.core.i18n.stringResource +import tachiyomi.core.storage.toTempFile import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.logcat import tachiyomi.domain.manga.model.Manga @@ -88,13 +89,13 @@ class ChapterLoader( source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { is Format.Directory -> DirectoryPageLoader(format.file) - is Format.Zip -> ZipPageLoader(format.file) + is Format.Zip -> ZipPageLoader(format.file.toTempFile(context)) is Format.Rar -> try { - RarPageLoader(format.file) + RarPageLoader(format.file.toTempFile(context)) } catch (e: UnsupportedRarV5Exception) { error(context.stringResource(MR.strings.loader_rar5_error)) } - is Format.Epub -> EpubPageLoader(format.file) + is Format.Epub -> EpubPageLoader(format.file.toTempFile(context)) } } source is HttpSource -> HttpPageLoader(chapter, source) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt index 837986b28..2a11f74e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt @@ -1,25 +1,24 @@ package eu.kanade.tachiyomi.ui.reader.loader +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import tachiyomi.core.util.system.ImageUtil -import java.io.File -import java.io.FileInputStream /** * Loader used to load a chapter from a directory given on [file]. */ -internal class DirectoryPageLoader(val file: File) : PageLoader() { +internal class DirectoryPageLoader(val file: UniFile) : PageLoader() { override var isLocal: Boolean = true override suspend fun getPages(): List { return file.listFiles() - ?.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } - ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + ?.filter { !it.isDirectory && ImageUtil.isImage(it.name) { it.openInputStream() } } + ?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) } ?.mapIndexed { i, file -> - val streamFn = { FileInputStream(file) } + val streamFn = { file.openInputStream() } ReaderPage(i).apply { stream = streamFn status = Page.State.READY diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index 64b6a73f5..3d385551d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -10,9 +10,9 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import tachiyomi.core.storage.toTempFile import tachiyomi.domain.manga.model.Manga import uy.kohesive.injekt.injectLazy -import java.io.File /** * Loader used to load a chapter from the downloaded chapters. @@ -47,7 +47,7 @@ internal class DownloadPageLoader( } private suspend fun getPagesFromArchive(chapterPath: UniFile): List { - val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it } + val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it } return loader.getPages() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt index 61d09dc79..5afd8364c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt @@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.system.overridePendingTransitionCompat import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.setComposeContent @@ -35,7 +36,7 @@ class WebViewActivity : BaseActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - overridePendingTransition(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit) + overridePendingTransitionCompat(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit) super.onCreate(savedInstanceState) if (!WebViewUtil.supportsWebView(this)) { @@ -77,7 +78,7 @@ class WebViewActivity : BaseActivity() { override fun finish() { super.finish() - overridePendingTransition(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit) + overridePendingTransitionCompat(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit) } private fun shareWebpage(url: String) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ActivityExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ActivityExtensions.kt new file mode 100644 index 000000000..c145f0e28 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ActivityExtensions.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.util.system + +import android.app.Activity +import android.os.Build +import androidx.annotation.AnimRes + +fun Activity.overridePendingTransitionCompat(@AnimRes enterAnim: Int, @AnimRes exitAnim: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + overrideActivityTransition(Activity.OVERRIDE_TRANSITION_OPEN, enterAnim, exitAnim) + } else { + @Suppress("DEPRECATION") + overridePendingTransition(enterAnim, exitAnim) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 14f637366..5f91bfbac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -165,7 +165,7 @@ fun Context.createReaderThemeContext(): Context { * @return document size of [uri] or null if size can't be obtained */ fun Context.getUriSize(uri: Uri): Long? { - return UniFile.fromUri(this, uri).length().takeIf { it >= 0 } + return UniFile.fromUri(this, uri)?.length()?.takeIf { it >= 0 } } /** diff --git a/buildSrc/src/main/kotlin/AndroidConfig.kt b/buildSrc/src/main/kotlin/AndroidConfig.kt index f63c07729..2127d0657 100644 --- a/buildSrc/src/main/kotlin/AndroidConfig.kt +++ b/buildSrc/src/main/kotlin/AndroidConfig.kt @@ -1,6 +1,6 @@ object AndroidConfig { const val compileSdk = 34 const val minSdk = 23 - const val targetSdk = 29 + const val targetSdk = 30 const val ndk = "22.1.7171670" } diff --git a/core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index 5ea61b5a5..fc0e81e65 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -3,9 +3,7 @@ package eu.kanade.tachiyomi.util.storage import android.content.Context import android.media.MediaScannerConnection import android.net.Uri -import android.os.Environment import android.os.StatFs -import androidx.core.content.ContextCompat import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.util.lang.Hash import java.io.File @@ -64,23 +62,6 @@ object DiskUtil { } } - /** - * Returns the root folders of all the available external storages. - */ - fun getExternalStorages(context: Context): List { - return ContextCompat.getExternalFilesDirs(context, null) - .filterNotNull() - .mapNotNull { - val file = File(it.absolutePath.substringBefore("/Android/")) - val state = Environment.getExternalStorageState(file) - if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) { - file - } else { - null - } - } - } - /** * Don't display downloaded chapters in gallery apps creating `.nomedia`. */ diff --git a/core/src/main/java/tachiyomi/core/provider/AndroidStorageFolderProvider.kt b/core/src/main/java/tachiyomi/core/storage/AndroidStorageFolderProvider.kt similarity index 94% rename from core/src/main/java/tachiyomi/core/provider/AndroidStorageFolderProvider.kt rename to core/src/main/java/tachiyomi/core/storage/AndroidStorageFolderProvider.kt index fc2859868..a5d48a49d 100644 --- a/core/src/main/java/tachiyomi/core/provider/AndroidStorageFolderProvider.kt +++ b/core/src/main/java/tachiyomi/core/storage/AndroidStorageFolderProvider.kt @@ -1,4 +1,4 @@ -package tachiyomi.core.provider +package tachiyomi.core.storage import android.content.Context import android.os.Environment diff --git a/core/src/main/java/tachiyomi/core/provider/FolderProvider.kt b/core/src/main/java/tachiyomi/core/storage/FolderProvider.kt similarity index 76% rename from core/src/main/java/tachiyomi/core/provider/FolderProvider.kt rename to core/src/main/java/tachiyomi/core/storage/FolderProvider.kt index b4e124cee..decd1c378 100644 --- a/core/src/main/java/tachiyomi/core/provider/FolderProvider.kt +++ b/core/src/main/java/tachiyomi/core/storage/FolderProvider.kt @@ -1,4 +1,4 @@ -package tachiyomi.core.provider +package tachiyomi.core.storage import java.io.File diff --git a/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt b/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt new file mode 100644 index 000000000..c5c2bbbc8 --- /dev/null +++ b/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt @@ -0,0 +1,38 @@ +package tachiyomi.core.storage + +import android.content.Context +import android.os.Build +import android.os.FileUtils +import com.hippo.unifile.UniFile +import java.io.BufferedOutputStream +import java.io.File + +val UniFile.extension: String? + get() = name?.substringAfterLast('.') + +val UniFile.nameWithoutExtension: String? + get() = name?.substringBeforeLast('.') + +fun UniFile.toTempFile(context: Context): File { + val inputStream = context.contentResolver.openInputStream(uri)!! + val tempFile = File.createTempFile( + nameWithoutExtension.orEmpty(), + null, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + FileUtils.copy(inputStream, tempFile.outputStream()) + } else { + BufferedOutputStream(tempFile.outputStream()).use { tmpOut -> + inputStream.use { input -> + val buffer = ByteArray(8192) + var count: Int + while (input.read(buffer).also { count = it } > 0) { + tmpOut.write(buffer, 0, count) + } + } + } + } + + return tempFile +} diff --git a/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt b/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt index b6cbc45e6..0aa7f9f59 100644 --- a/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt +++ b/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt @@ -37,7 +37,9 @@ import kotlin.math.min object ImageUtil { - fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean { + fun isImage(name: String?, openStream: (() -> InputStream)? = null): Boolean { + if (name == null) return false + val contentType = try { URLConnection.guessContentTypeFromName(name) } catch (e: Exception) { @@ -243,7 +245,7 @@ object ImageUtil { // Remove pre-existing split if exists (this split shouldn't exist under normal circumstances) tmpDir.findFile(splitImageName)?.delete() - val splitFile = tmpDir.createFile(splitImageName) + val splitFile = tmpDir.createFile(splitImageName)!! val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset) diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 2ce1a4d55..4df15c79a 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { implementation(platform(kotlinx.coroutines.bom)) implementation(kotlinx.bundles.coroutines) + implementation(libs.unifile) + api(libs.sqldelight.android.paging) testImplementation(libs.bundles.test) diff --git a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt new file mode 100644 index 000000000..a1fff4269 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt @@ -0,0 +1,54 @@ +package tachiyomi.domain.storage.service + +import android.content.Context +import androidx.core.net.toUri +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.util.storage.DiskUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class StorageManager( + private val context: Context, + storagePreferences: StoragePreferences, +) { + + private val scope = CoroutineScope(Dispatchers.IO) + + private var baseDir: UniFile? = storagePreferences.baseStorageDirectory().get().let(::getBaseDir) + + init { + storagePreferences.baseStorageDirectory().changes() + .onEach { baseDir = getBaseDir(it) } + .launchIn(scope) + } + + private fun getBaseDir(path: String): UniFile? { + val file = UniFile.fromUri(context, path.toUri()) + + return file.takeIf { it?.exists() == true }?.also { parent -> + parent.createDirectory(AUTOMATIC_BACKUPS_PATH) + parent.createDirectory(LOCAL_SOURCE_PATH) + parent.createDirectory(DOWNLOADS_PATH).also { + DiskUtil.createNoMediaFile(it, context) + } + } + } + + fun getAutomaticBackupsDirectory(): UniFile? { + return baseDir?.createDirectory(AUTOMATIC_BACKUPS_PATH) + } + + fun getDownloadsDirectory(): UniFile? { + return baseDir?.createDirectory(DOWNLOADS_PATH) + } + + fun getLocalSourceDirectory(): UniFile? { + return baseDir?.createDirectory(LOCAL_SOURCE_PATH) + } +} + +private const val AUTOMATIC_BACKUPS_PATH = "autobackup" +private const val DOWNLOADS_PATH = "downloads" +private const val LOCAL_SOURCE_PATH = "local" diff --git a/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt b/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt index c336825b8..8f7c3fcc6 100644 --- a/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt @@ -1,7 +1,7 @@ package tachiyomi.domain.storage.service import tachiyomi.core.preference.PreferenceStore -import tachiyomi.core.provider.FolderProvider +import tachiyomi.core.storage.FolderProvider class StoragePreferences( private val folderProvider: FolderProvider, @@ -9,9 +9,4 @@ class StoragePreferences( ) { fun baseStorageDirectory() = preferenceStore.getString("storage_dir", folderProvider.path()) - - companion object { - const val BACKUP_DIR = "backup" - const val DOWNLOADS_DIR = "downloads" - } } diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index 8d586064e..7ef758874 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -1,5 +1,5 @@ [versions] -agp_version = "8.1.3" +agp_version = "8.1.4" lifecycle_version = "2.6.2" paging_version = "3.2.1" diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index bdafa0fe2..ce7b074bf 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -1,6 +1,6 @@ [versions] compiler = "1.5.4" -compose-bom = "2023.12.00-alpha01" +compose-bom = "2023.12.00-alpha02" accompanist = "0.33.2-alpha" [libraries] @@ -22,7 +22,6 @@ material-core = { module = "androidx.compose.material:material" } glance = "androidx.glance:glance-appwidget:1.0.0" accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" } -accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } lintchecks = { module = "com.slack.lint.compose:compose-lint-checks", version = "1.2.0" } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f016060f..c556a1674 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ quickjs-android = "app.cash.quickjs:quickjs-android:0.9.2" jsoup = "org.jsoup:jsoup:1.16.2" disklrucache = "com.jakewharton:disklrucache:2.0.2" -unifile = "com.github.tachiyomiorg:unifile:17bec43" +unifile = "com.github.tachiyomiorg:unifile:7c257e1c64" junrar = "com.github.junrar:junrar:7.5.5" sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 079136828..b9ec6edf0 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -839,7 +839,6 @@ Warning: large bulk downloads may lead to sources becoming slower and/or blocking Tachiyomi. Tap to learn more. - Checking for new chapters Updating library… (%s) Large updates harm sources and may lead to slower updates and also increased battery usage. Tap to learn more. New chapters found diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt index c68dd300f..b24baec43 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/material/PullRefresh.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.animate import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshContainer import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.runtime.Composable @@ -46,6 +47,7 @@ fun PullRefresh( content: @Composable () -> Unit, ) { val state = rememberPullToRefreshState( + initialRefreshing = refreshing, extraVerticalOffset = indicatorPadding.calculateTopPadding(), enabled = enabled, ) @@ -84,12 +86,15 @@ fun PullRefresh( modifier = Modifier .align(Alignment.TopCenter) .padding(contentPadding), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable private fun rememberPullToRefreshState( + initialRefreshing: Boolean, extraVerticalOffset: Dp, positionalThreshold: Dp = 64.dp, enabled: () -> Boolean = { true }, @@ -108,7 +113,7 @@ private fun rememberPullToRefreshState( ), ) { PullToRefreshStateImpl( - initialRefreshing = false, + initialRefreshing = initialRefreshing, extraVerticalOffset = extraVerticalOffsetPx, positionalThreshold = positionalThresholdPx, enabled = enabled, @@ -133,18 +138,21 @@ private class PullToRefreshStateImpl( ) : PullToRefreshState { override val progress get() = adjustedDistancePulled / positionalThreshold - override var verticalOffset by mutableFloatStateOf(0f) + override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f) override var isRefreshing by mutableStateOf(initialRefreshing) + private val refreshingVerticalOffset: Float + get() = positionalThreshold + extraVerticalOffset + override fun startRefresh() { isRefreshing = true - verticalOffset = positionalThreshold + extraVerticalOffset + verticalOffset = refreshingVerticalOffset } suspend fun startRefreshAnimated() { isRefreshing = true - animateTo(positionalThreshold + extraVerticalOffset) + animateTo(refreshingVerticalOffset) } override fun endRefresh() { @@ -196,7 +204,7 @@ private class PullToRefreshStateImpl( val newOffset = (distancePulled + available.y).coerceAtLeast(0f) val dragConsumed = newOffset - distancePulled distancePulled = newOffset - verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress) + verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f)) dragConsumed } return Offset(0f, y) diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt index c3be92d2c..1305001f5 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt @@ -11,6 +11,8 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.EpubFile +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import logcat.LogPriority @@ -22,6 +24,9 @@ import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.copyFromComicInfo import tachiyomi.core.metadata.comicinfo.getComicInfo import tachiyomi.core.metadata.tachiyomi.MangaDetails +import tachiyomi.core.storage.extension +import tachiyomi.core.storage.nameWithoutExtension +import tachiyomi.core.storage.toTempFile import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.logcat @@ -33,11 +38,9 @@ import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.io.Archive import tachiyomi.source.local.io.Format import tachiyomi.source.local.io.LocalSourceFileSystem -import tachiyomi.source.local.metadata.fillChapterMetadata -import tachiyomi.source.local.metadata.fillMangaMetadata +import tachiyomi.source.local.metadata.fillMetadata import uy.kohesive.injekt.injectLazy import java.io.File -import java.io.FileInputStream import java.io.InputStream import java.nio.charset.StandardCharsets import java.util.zip.ZipFile @@ -72,22 +75,22 @@ actual class LocalSource( override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LATEST_FILTERS) - override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage { - val baseDirsFiles = fileSystem.getFilesInBaseDirectories() - val lastModifiedLimit by lazy { - if (filters === LATEST_FILTERS) { - System.currentTimeMillis() - LATEST_THRESHOLD - } else { - 0L - } + override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = withIOContext { + val lastModifiedLimit = if (filters === LATEST_FILTERS) { + System.currentTimeMillis() - LATEST_THRESHOLD + } else { + 0L } - var mangaDirs = baseDirsFiles + + var mangaDirs = fileSystem.getFilesInBaseDirectory() // Filter out files that are hidden and is not a folder - .filter { it.isDirectory && !it.name.startsWith('.') } + .filter { it.isDirectory && !it.name.orEmpty().startsWith('.') } .distinctBy { it.name } - .filter { // Filter by query or last modified - if (lastModifiedLimit == 0L) { - it.name.contains(query, ignoreCase = true) + .filter { + if (lastModifiedLimit == 0L && query.isBlank()) { + true + } else if (lastModifiedLimit == 0L) { + it.name.orEmpty().contains(query, ignoreCase = true) } else { it.lastModified() >= lastModifiedLimit } @@ -97,71 +100,53 @@ actual class LocalSource( when (filter) { is OrderBy.Popular -> { mangaDirs = if (filter.state!!.ascending) { - mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() }) } else { - mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) + mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() }) } } is OrderBy.Latest -> { mangaDirs = if (filter.state!!.ascending) { - mangaDirs.sortedBy(File::lastModified) + mangaDirs.sortedBy(UniFile::lastModified) } else { - mangaDirs.sortedByDescending(File::lastModified) + mangaDirs.sortedByDescending(UniFile::lastModified) } } - else -> { /* Do nothing */ } } } - // Transform mangaDirs to list of SManga - val mangas = mangaDirs.map { mangaDir -> - SManga.create().apply { - title = mangaDir.name - url = mangaDir.name + val mangas = mangaDirs + .map { mangaDir -> + async { + SManga.create().apply { + title = mangaDir.name.orEmpty() + url = mangaDir.name.orEmpty() - // Try to find the cover - coverManager.find(mangaDir.name) - ?.takeIf(File::exists) - ?.let { thumbnail_url = it.absolutePath } - } - } - - // Fetch chapters of all the manga - mangas.forEach { manga -> - val chapters = getChapterList(manga) - if (chapters.isNotEmpty()) { - val chapter = chapters.last() - val format = getFormat(chapter) - - if (format is Format.Epub) { - EpubFile(format.file).use { epub -> - epub.fillMangaMetadata(manga) + // Try to find the cover + coverManager.find(mangaDir.name.orEmpty())?.let { + thumbnail_url = it.filePath + } } } - - // Copy the cover from the first chapter found if not available - if (manga.thumbnail_url == null) { - updateCover(chapter, manga) - } } - } + .awaitAll() - return MangasPage(mangas.toList(), false) + MangasPage(mangas, false) } // Manga details related override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext { coverManager.find(manga.url)?.let { - manga.thumbnail_url = it.absolutePath + manga.thumbnail_url = it.filePath } // Augment manga details based on metadata files try { - val mangaDir = fileSystem.getMangaDirectory(manga.url) - val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url).toList() + val mangaDir by lazy { fileSystem.getMangaDirectory(manga.url) } + val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url) val comicInfoFile = mangaDirFiles .firstOrNull { it.name == COMIC_INFO_FILE } @@ -174,13 +159,13 @@ actual class LocalSource( // Top level ComicInfo.xml comicInfoFile != null -> { noXmlFile?.delete() - setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga) + setMangaDetailsFromComicInfoFile(comicInfoFile.openInputStream(), manga) } // Old custom JSON format // TODO: remove support for this entirely after a while legacyJsonDetailsFile != null -> { - json.decodeFromStream(legacyJsonDetailsFile.inputStream()).run { + json.decodeFromStream(legacyJsonDetailsFile.openInputStream()).run { title?.let { manga.title = it } author?.let { manga.author = it } artist?.let { manga.artist = it } @@ -190,7 +175,7 @@ actual class LocalSource( } // Replace with ComicInfo.xml file val comicInfo = manga.getComicInfo() - UniFile.fromFile(mangaDir) + mangaDir ?.createFile(COMIC_INFO_FILE) ?.openOutputStream() ?.use { @@ -206,14 +191,14 @@ actual class LocalSource( .filter(Archive::isSupported) .toList() - val folderPath = mangaDir?.absolutePath + val folderPath = mangaDir?.filePath val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath) if (copiedFile != null) { setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga) } else { // Avoid re-scanning - File("$folderPath/.noxml").createNewFile() + mangaDir?.createFile(".noxml") } } } @@ -224,11 +209,11 @@ actual class LocalSource( return@withIOContext manga } - private fun copyComicInfoFileFromArchive(chapterArchives: List, folderPath: String?): File? { + private fun copyComicInfoFileFromArchive(chapterArchives: List, folderPath: String?): File? { for (chapter in chapterArchives) { when (Format.valueOf(chapter)) { is Format.Zip -> { - ZipFile(chapter).use { zip: ZipFile -> + ZipFile(chapter.toTempFile(context)).use { zip: ZipFile -> zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile -> zip.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) @@ -237,7 +222,7 @@ actual class LocalSource( } } is Format.Rar -> { - JunrarArchive(chapter).use { rar -> + JunrarArchive(chapter.toTempFile(context)).use { rar -> rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile -> rar.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) @@ -268,8 +253,8 @@ actual class LocalSource( } // Chapters - override suspend fun getChapterList(manga: SManga): List { - return fileSystem.getFilesInMangaDirectory(manga.url) + override suspend fun getChapterList(manga: SManga): List = withIOContext { + val chapters = fileSystem.getFilesInMangaDirectory(manga.url) // Only keep supported formats .filter { it.isDirectory || Archive.isSupported(it) } .map { chapterFile -> @@ -279,7 +264,7 @@ actual class LocalSource( chapterFile.name } else { chapterFile.nameWithoutExtension - } + }.orEmpty() date_upload = chapterFile.lastModified() chapter_number = ChapterRecognition .parseChapterNumber(manga.title, this.name, this.chapter_number.toDouble()) @@ -287,8 +272,8 @@ actual class LocalSource( val format = Format.valueOf(chapterFile) if (format is Format.Epub) { - EpubFile(format.file).use { epub -> - epub.fillChapterMetadata(this) + EpubFile(format.file.toTempFile(context)).use { epub -> + epub.fillMetadata(manga, this) } } } @@ -297,7 +282,15 @@ actual class LocalSource( val c = c2.chapter_number.compareTo(c1.chapter_number) if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c } - .toList() + + // Copy the cover from the first chapter found if not available + if (manga.thumbnail_url.isNullOrBlank()) { + chapters.lastOrNull()?.let { chapter -> + updateCover(chapter, manga) + } + } + + chapters } // Filters @@ -308,9 +301,10 @@ actual class LocalSource( fun getFormat(chapter: SChapter): Format { try { - return fileSystem.getBaseDirectories() - .map { dir -> File(dir, chapter.url) } - .find { it.exists() } + val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2) + return fileSystem.getBaseDirectory() + ?.findFile(mangaDirName, true) + ?.findFile(chapterName, true) ?.let(Format.Companion::valueOf) ?: throw Exception(context.stringResource(MR.strings.chapter_not_found)) } catch (e: Format.UnknownFormatException) { @@ -320,18 +314,24 @@ actual class LocalSource( } } - private fun updateCover(chapter: SChapter, manga: SManga): File? { + private fun updateCover(chapter: SChapter, manga: SManga): UniFile? { return try { when (val format = getFormat(chapter)) { is Format.Directory -> { val entry = format.file.listFiles() - ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + ?.sortedWith { f1, f2 -> + f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder( + f2.name.orEmpty(), + ) + } + ?.find { + !it.isDirectory && ImageUtil.isImage(it.name) { it.openInputStream() } + } - entry?.let { coverManager.update(manga, it.inputStream()) } + entry?.let { coverManager.update(manga, it.openInputStream()) } } is Format.Zip -> { - ZipFile(format.file).use { zip -> + ZipFile(format.file.toTempFile(context)).use { zip -> val entry = zip.entries().toList() .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } @@ -340,7 +340,7 @@ actual class LocalSource( } } is Format.Rar -> { - JunrarArchive(format.file).use { archive -> + JunrarArchive(format.file.toTempFile(context)).use { archive -> val entry = archive.fileHeaders .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } @@ -349,7 +349,7 @@ actual class LocalSource( } } is Format.Epub -> { - EpubFile(format.file).use { epub -> + EpubFile(format.file.toTempFile(context)).use { epub -> val entry = epub.getImagesFromPages() .firstOrNull() ?.let { epub.getEntry(it) } diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt index 7683756e3..0f5a4c343 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt @@ -4,9 +4,9 @@ import android.content.Context import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.storage.DiskUtil +import tachiyomi.core.storage.nameWithoutExtension import tachiyomi.core.util.system.ImageUtil import tachiyomi.source.local.io.LocalSourceFileSystem -import java.io.File import java.io.InputStream private const val DEFAULT_COVER_NAME = "cover.jpg" @@ -16,43 +16,35 @@ actual class LocalCoverManager( private val fileSystem: LocalSourceFileSystem, ) { - actual fun find(mangaUrl: String): File? { + actual fun find(mangaUrl: String): UniFile? { return fileSystem.getFilesInMangaDirectory(mangaUrl) // Get all file whose names start with "cover" .filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) } // Get the first actual image - .firstOrNull { - ImageUtil.isImage(it.name) { it.inputStream() } - } + .firstOrNull { ImageUtil.isImage(it.name) { it.openInputStream() } } } actual fun update( manga: SManga, inputStream: InputStream, - ): File? { + ): UniFile? { val directory = fileSystem.getMangaDirectory(manga.url) if (directory == null) { inputStream.close() return null } - var targetFile = find(manga.url) - if (targetFile == null) { - targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME) - targetFile.createNewFile() - } + val targetFile = find(manga.url) ?: directory.createFile(DEFAULT_COVER_NAME)!! - // It might not exist at this point - targetFile.parentFile?.mkdirs() inputStream.use { input -> - targetFile.outputStream().use { output -> + targetFile.openOutputStream().use { output -> input.copyTo(output) } } - DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context) + DiskUtil.createNoMediaFile(directory, context) - manga.thumbnail_url = targetFile.absolutePath + manga.thumbnail_url = targetFile.uri.toString() return targetFile } } diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt index f645f8b75..ad95b39ce 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt @@ -1,40 +1,30 @@ package tachiyomi.source.local.io -import android.content.Context -import eu.kanade.tachiyomi.util.storage.DiskUtil -import tachiyomi.core.i18n.stringResource -import tachiyomi.i18n.MR -import java.io.File +import com.hippo.unifile.UniFile +import tachiyomi.domain.storage.service.StorageManager actual class LocalSourceFileSystem( - private val context: Context, + private val storageManager: StorageManager, ) { - private val baseFolderLocation = "${context.stringResource(MR.strings.app_name)}${File.separator}local" - - actual fun getBaseDirectories(): Sequence { - return DiskUtil.getExternalStorages(context) - .map { File(it.absolutePath, baseFolderLocation) } - .asSequence() + actual fun getBaseDirectory(): UniFile? { + return storageManager.getLocalSourceDirectory() } - actual fun getFilesInBaseDirectories(): Sequence { - return getBaseDirectories() - // Get all the files inside all baseDir - .flatMap { it.listFiles().orEmpty().toList() } + actual fun getFilesInBaseDirectory(): List { + return getBaseDirectory()?.listFiles().orEmpty().toList() } - actual fun getMangaDirectory(name: String): File? { - return getFilesInBaseDirectories() - // Get the first mangaDir or null - .firstOrNull { it.isDirectory && it.name == name } + actual fun getMangaDirectory(name: String): UniFile? { + return getBaseDirectory() + ?.findFile(name, true) + ?.takeIf { it.isDirectory } } - actual fun getFilesInMangaDirectory(name: String): Sequence { - return getFilesInBaseDirectories() - // Filter out ones that are not related to the manga and is not a directory - .filter { it.isDirectory && it.name == name } - // Get all the files inside the filtered folders - .flatMap { it.listFiles().orEmpty().toList() } + actual fun getFilesInMangaDirectory(name: String): List { + return getBaseDirectory() + ?.findFile(name, true) + ?.takeIf { it.isDirectory } + ?.listFiles().orEmpty().toList() } } diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/metadata/EpubFile.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/metadata/EpubFile.kt index d9ce323d5..6bade530b 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/metadata/EpubFile.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/metadata/EpubFile.kt @@ -8,37 +8,25 @@ import java.text.SimpleDateFormat import java.util.Locale /** - * Fills manga metadata using this epub file's metadata. + * Fills manga and chapter metadata using this epub file's metadata. */ -fun EpubFile.fillMangaMetadata(manga: SManga) { - val ref = getPackageHref() - val doc = getPackageDocument(ref) - - val creator = doc.getElementsByTag("dc:creator").first() - val description = doc.getElementsByTag("dc:description").first() - - manga.author = creator?.text() - manga.description = description?.text() -} - -/** - * Fills chapter metadata using this epub file's metadata. - */ -fun EpubFile.fillChapterMetadata(chapter: SChapter) { +fun EpubFile.fillMetadata(manga: SManga, chapter: SChapter) { val ref = getPackageHref() val doc = getPackageDocument(ref) val title = doc.getElementsByTag("dc:title").first() val publisher = doc.getElementsByTag("dc:publisher").first() val creator = doc.getElementsByTag("dc:creator").first() + val description = doc.getElementsByTag("dc:description").first() var date = doc.getElementsByTag("dc:date").first() if (date == null) { date = doc.select("meta[property=dcterms:modified]").first() } - if (title != null) { - chapter.name = title.text() - } + creator?.text()?.let { manga.author = it } + description?.text()?.let { manga.description = it } + + title?.text()?.let { chapter.name = it } if (publisher != null) { chapter.scanlator = publisher.text() diff --git a/source-local/src/commonMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt b/source-local/src/commonMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt index fd31299c2..037d9f1dc 100644 --- a/source-local/src/commonMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt +++ b/source-local/src/commonMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt @@ -1,12 +1,12 @@ package tachiyomi.source.local.image +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.source.model.SManga -import java.io.File import java.io.InputStream expect class LocalCoverManager { - fun find(mangaUrl: String): File? + fun find(mangaUrl: String): UniFile? - fun update(manga: SManga, inputStream: InputStream): File? + fun update(manga: SManga, inputStream: InputStream): UniFile? } diff --git a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt index b28ee60b5..a8f5a0740 100644 --- a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt +++ b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt @@ -1,12 +1,13 @@ package tachiyomi.source.local.io -import java.io.File +import com.hippo.unifile.UniFile +import tachiyomi.core.storage.extension object Archive { private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub") - fun isSupported(file: File): Boolean = with(file) { - return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES + fun isSupported(file: UniFile): Boolean { + return file.extension in SUPPORTED_ARCHIVE_TYPES } } diff --git a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Format.kt b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Format.kt index 53406b5de..0f29ae8ab 100644 --- a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Format.kt +++ b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Format.kt @@ -1,18 +1,19 @@ package tachiyomi.source.local.io -import java.io.File +import com.hippo.unifile.UniFile +import tachiyomi.core.storage.extension sealed interface Format { - data class Directory(val file: File) : Format - data class Zip(val file: File) : Format - data class Rar(val file: File) : Format - data class Epub(val file: File) : Format + data class Directory(val file: UniFile) : Format + data class Zip(val file: UniFile) : Format + data class Rar(val file: UniFile) : Format + data class Epub(val file: UniFile) : Format class UnknownFormatException : Exception() companion object { - fun valueOf(file: File) = with(file) { + fun valueOf(file: UniFile) = with(file) { when { isDirectory -> Directory(this) extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this) diff --git a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt index 0440df26e..5aa74d851 100644 --- a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt +++ b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/LocalSourceFileSystem.kt @@ -1,14 +1,14 @@ package tachiyomi.source.local.io -import java.io.File +import com.hippo.unifile.UniFile expect class LocalSourceFileSystem { - fun getBaseDirectories(): Sequence + fun getBaseDirectory(): UniFile? - fun getFilesInBaseDirectories(): Sequence + fun getFilesInBaseDirectory(): List - fun getMangaDirectory(name: String): File? + fun getMangaDirectory(name: String): UniFile? - fun getFilesInMangaDirectory(name: String): Sequence + fun getFilesInMangaDirectory(name: String): List }