diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08fd78b53a..f2493be00e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,11 +7,6 @@ - - - - @@ -37,12 +32,10 @@ android:name=".App" android:allowBackup="true" android:fullBackupContent="@xml/backup_rules" - android:preserveLegacyExternalStorage="true" android:hardwareAccelerated="true" android:usesCleartextTraffic="true" android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher" - android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" diff --git a/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt b/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt index dc4a9d35e7..39d09bba92 100644 --- a/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt +++ b/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt @@ -40,6 +40,8 @@ class StorageManager( parent.createDirectory(DOWNLOADS_PATH).also { DiskUtil.createNoMediaFile(it, context) } + parent.createDirectory(COVERS_PATH) + parent.createDirectory(PAGES_PATH) } _changes.send(Unit) } @@ -66,9 +68,19 @@ class StorageManager( fun getLocalSourceDirectory(): UniFile? { return baseDir?.createDirectory(LOCAL_SOURCE_PATH) } + + fun getCoversDirectory(): UniFile? { + return baseDir?.createDirectory(COVERS_PATH) + } + + fun getPagesDirectory(): UniFile? { + return baseDir?.createDirectory(PAGES_PATH) + } } private const val BACKUPS_PATH = "autobackup" private const val AUTOMATIC_BACKUPS_PATH = "autobackup" private const val DOWNLOADS_PATH = "downloads" private const val LOCAL_SOURCE_PATH = "local" +private const val COVERS_PATH = "covers" +private const val PAGES_PATH = "pages" 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 eaa4a772d9..73ff7694c3 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 @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.backup import android.content.Context import android.net.Uri import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK @@ -54,6 +55,7 @@ import okio.sink import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.io.FileOutputStream class BackupCreator(val context: Context) { @@ -64,6 +66,7 @@ class BackupCreator(val context: Context) { private val sourceManager: SourceManager = Injekt.get() private val preferences: PreferencesHelper = Injekt.get() private val customMangaManager: CustomMangaManager = Injekt.get() + internal val storageManager: StorageManager by injectLazy() /** * Create backup Json file from database @@ -98,8 +101,7 @@ class BackupCreator(val context: Context) { file = ( if (isAutoBackup) { // Get dir of file and create - // TODO: Unified Storage - val dir = UniFile.fromUri(context, uri)!!.createDirectory("automatic")!! + val dir = storageManager.getAutomaticBackupsDirectory()!! // Delete older backups val numberOfBackups = preferences.numberOfBackups().get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index d74448657d..7fc4f133f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -13,6 +13,8 @@ import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager +import dev.yokai.domain.storage.StoragePreferences import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.util.system.localeContext @@ -20,16 +22,16 @@ import eu.kanade.tachiyomi.util.system.notificationManager import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.util.concurrent.TimeUnit class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { override fun doWork(): Result { - val preferences = Injekt.get() + val storageManager: StorageManager by injectLazy() val notifier = BackupNotifier(context.localeContext) - val uri = inputData.getString(LOCATION_URI_KEY)?.let { Uri.parse(it) } - ?: preferences.backupsDirectory().get().toUri() + val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() ?: storageManager.getAutomaticBackupsDirectory()?.uri!! val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL) val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true) 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 ae8cbe7a96..501243147c 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 @@ -1,18 +1,16 @@ package eu.kanade.tachiyomi.data.download import android.content.Context -import androidx.core.net.toUri import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.storage.DiskUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.Injekt @@ -35,7 +33,7 @@ class DownloadCache( private val context: Context, private val provider: DownloadProvider, private val sourceManager: SourceManager, - private val preferences: PreferencesHelper = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), ) { /** @@ -54,21 +52,11 @@ class DownloadCache( val scope = CoroutineScope(Job() + Dispatchers.IO) init { - preferences.downloadsDirectory().changes() - .drop(1) - .onEach { lastRenew = 0L } // invalidate cache + storageManager.changes + .onEach { forceRenewCache() } // invalidate cache .launchIn(scope) } - /** - * Returns the downloads directory from the user's preferences. - */ - private fun getDirectoryFromPreference(): UniFile { - // TODO: Unified Storage - val dir = preferences.downloadsDirectory().get() - return UniFile.fromUri(context, dir.toUri())!! - } - /** * Returns true if the chapter is downloaded. * @@ -138,7 +126,7 @@ class DownloadCache( private fun renew() { val onlineSources = sourceManager.getOnlineSources() - val sourceDirs = getDirectoryFromPreference().listFiles().orEmpty() + val sourceDirs = storageManager.getDownloadsDirectory()!!.listFiles().orEmpty() .associate { it.name to SourceDirectory(it) }.mapNotNullKeys { entry -> onlineSources.find { provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) }?.id } 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 4af95d0306..36d06880de 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 @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download import android.content.Context import androidx.core.net.toUri import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter @@ -31,6 +32,7 @@ class DownloadProvider(private val context: Context) { * Preferences helper. */ private val preferences: PreferencesHelper by injectLazy() + private val storageManager: StorageManager by injectLazy() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -38,15 +40,11 @@ class DownloadProvider(private val context: Context) { * The root directory for downloads. */ // TODO: Unified Storage - private var downloadsDir = preferences.downloadsDirectory().get().let { - val dir = UniFile.fromUri(context, it.toUri()) - DiskUtil.createNoMediaFile(dir, context) - dir!! - } + private var downloadsDir = storageManager.getDownloadsDirectory() init { - preferences.downloadsDirectory().changes().drop(1).onEach { - downloadsDir = UniFile.fromUri(context, it.toUri())!! + storageManager.changes.onEach { + downloadsDir = storageManager.getDownloadsDirectory() }.launchIn(scope) } @@ -58,7 +56,7 @@ class DownloadProvider(private val context: Context) { */ internal fun getMangaDir(manga: Manga, source: Source): UniFile { try { - return downloadsDir.createDirectory(getSourceDirName(source))!! + return downloadsDir!!.createDirectory(getSourceDirName(source))!! .createDirectory(getMangaDirName(manga))!! } catch (e: NullPointerException) { throw Exception(context.getString(R.string.invalid_download_location)) @@ -71,7 +69,7 @@ class DownloadProvider(private val context: Context) { * @param source the source to query. */ fun findSourceDir(source: Source): UniFile? { - return downloadsDir.findFile(getSourceDirName(source), true) + return downloadsDir!!.findFile(getSourceDirName(source), true) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 2b54e9ea75..02e6693495 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -1,20 +1,16 @@ package eu.kanade.tachiyomi.data.preference import android.content.Context -import android.net.Uri -import android.os.Environment import androidx.appcompat.app.AppCompatDelegate import androidx.preference.PreferenceManager import com.google.android.material.color.DynamicColors import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.core.preference.Preference import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.preference.getEnum import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder -import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet @@ -23,14 +19,12 @@ import eu.kanade.tachiyomi.ui.reader.settings.PageLayout import eu.kanade.tachiyomi.ui.reader.settings.ReaderBottomButton import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation -import eu.kanade.tachiyomi.ui.recents.RecentMangaAdapter import eu.kanade.tachiyomi.ui.recents.RecentsPresenter import eu.kanade.tachiyomi.util.system.Themes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat import java.util.* @@ -66,22 +60,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - private val defaultDownloadsDir = Uri.fromFile( - File( - Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.app_normalized_name), - "downloads", - ), - ) - - private val defaultBackupDir = Uri.fromFile( - File( - Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.app_normalized_name), - "backup", - ), - ) - fun getInt(key: String, default: Int) = preferenceStore.getInt(key, default) fun getStringPref(key: String, default: String = "") = preferenceStore.getString(key, default) fun getStringSet(key: String, default: Set) = preferenceStore.getStringSet(key, default) @@ -215,8 +193,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto fun anilistScoreType() = preferenceStore.getString("anilist_score_type", "POINT_10") - fun backupsDirectory() = preferenceStore.getString(Keys.backupDirectory, defaultBackupDir.toString()) - fun dateFormat(format: String = preferenceStore.getString(Keys.dateFormat, "").get()): DateFormat = when (format) { "" -> DateFormat.getDateInstance(DateFormat.SHORT) else -> SimpleDateFormat(format, Locale.getDefault()) @@ -224,8 +200,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto fun appLanguage() = preferenceStore.getString("app_language", "") - fun downloadsDirectory() = preferenceStore.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) - fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 4ab186787d..5b10772e75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -1,7 +1,10 @@ package eu.kanade.tachiyomi.source import android.content.Context +import androidx.core.net.toFile import com.github.junrar.Archive +import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList @@ -10,9 +13,11 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder -import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.extension +import eu.kanade.tachiyomi.util.system.nameWithoutExtension +import eu.kanade.tachiyomi.util.system.writeText import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -20,7 +25,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import timber.log.Timber import uy.kohesive.injekt.injectLazy -import java.io.File import java.io.FileInputStream import java.io.InputStream import java.util.concurrent.TimeUnit @@ -35,16 +39,16 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) private val langMap = hashMapOf() - fun getMangaLang(manga: SManga, context: Context): String { + fun getMangaLang(manga: SManga): String { return langMap.getOrPut(manga.url) { - val localDetails = getBaseDirectories(context) + val localDetails = getBaseDirectories() .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } .flatten() .firstOrNull { it.extension.equals("json", ignoreCase = true) } return if (localDetails != null) { - val obj = Json.decodeFromStream(localDetails.inputStream()) + val obj = Json.decodeFromStream(localDetails.openInputStream()) obj.lang ?: "other" } else { "other" @@ -52,49 +56,39 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } - fun updateCover(context: Context, manga: SManga, input: InputStream): File? { - val dir = getBaseDirectories(context).firstOrNull() + fun updateCover(manga: SManga, input: InputStream): UniFile? { + val dir = getBaseDirectories().firstOrNull() if (dir == null) { input.close() return null } - var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) + var cover = getCoverFile(dir.findFile(manga.url)) if (cover == null) { - cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) + cover = dir.findFile(manga.url)?.findFile(COVER_NAME)!! } // It might not exist if using the external SD card - cover.parentFile?.mkdirs() + cover.parentFile?.parentFile?.createDirectory(cover.parentFile?.name) input.use { - cover.outputStream().use { + cover.openOutputStream().use { input.copyTo(it) } } - manga.thumbnail_url = cover.absolutePath + manga.thumbnail_url = cover.filePath return cover } /** * Returns valid cover file inside [parent] directory. */ - private fun getCoverFile(parent: File): File? { - return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { - it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } + private fun getCoverFile(parent: UniFile?): UniFile? { + return parent?.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { + it.isFile && ImageUtil.isImage(it.name.orEmpty()) { it.openInputStream() } } } - private fun getBaseDirectories(context: Context): List { - val library = context.getString(R.string.app_short_name) + File.separator + "local" - val normalized = context.getString(R.string.app_normalized_name) + File.separator + "local" - val j2k = "TachiyomiJ2K" + File.separator + "local" - val tachi = "Tachiyomi" + File.separator + "local" - return DiskUtil.getExternalStorages(context).map { - listOf( - File(it.absolutePath, library), - File(it.absolutePath, normalized), - File(it.absolutePath, j2k), - File(it.absolutePath, tachi), - ) - }.flatten() + private fun getBaseDirectories(): List { + val storageManager: StorageManager by injectLazy() + return listOf(storageManager.getLocalSourceDirectory()!!) } } @@ -114,7 +108,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour query: String, filters: FilterList, ): MangasPage { - val baseDirs = getBaseDirectories(context) + val baseDirs = getBaseDirectories() val time = if (filters === latestFilters) System.currentTimeMillis() - LATEST_THRESHOLD else 0L @@ -123,38 +117,38 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour .mapNotNull { it.listFiles()?.toList() } .flatten() .filter { it.isDirectory } - .filterNot { it.name.startsWith('.') } - .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .filterNot { it.name.orEmpty().startsWith('.') } + .filter { if (time == 0L) it.name.orEmpty().contains(query, ignoreCase = true) else it.lastModified() >= time } .distinctBy { it.name } val state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state when (state?.index) { 0 -> { mangaDirs = if (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()}) } } 1 -> { mangaDirs = if (state.ascending) { - mangaDirs.sortedBy(File::lastModified) + mangaDirs.sortedBy(UniFile::lastModified) } else { - mangaDirs.sortedByDescending(File::lastModified) + mangaDirs.sortedByDescending(UniFile::lastModified) } } } val mangas = mangaDirs.map { mangaDir -> SManga.create().apply { - title = mangaDir.name - url = mangaDir.name + title = mangaDir.name.orEmpty() + url = mangaDir.name.orEmpty() // Try to find the cover for (dir in baseDirs) { - val cover = getCoverFile(File("${dir.absolutePath}/$url")) + val cover = getCoverFile(mangaDir.findFile(url)) if (cover != null && cover.exists()) { - thumbnail_url = cover.absolutePath + thumbnail_url = cover.filePath break } } @@ -166,7 +160,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour val chapter = chapters.last() val format = getFormat(chapter) if (format is Format.Epub) { - EpubFile(format.file).use { epub -> + EpubFile(format.file.uri.toFile()).use { epub -> epub.fillMangaMetadata(manga) } } @@ -175,7 +169,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour if (thumbnail_url == null) { try { val dest = updateCover(chapter, manga) - thumbnail_url = dest?.absolutePath + thumbnail_url = dest?.filePath } catch (e: Exception) { Timber.e(e) } @@ -191,14 +185,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters) override suspend fun getMangaDetails(manga: SManga): SManga { - val localDetails = getBaseDirectories(context) + val localDetails = getBaseDirectories() .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } .flatten() .firstOrNull { it.extension.equals("json", ignoreCase = true) } return if (localDetails != null) { - val obj = json.decodeFromStream(localDetails.inputStream()) + val obj = json.decodeFromStream(localDetails.openInputStream()) obj.lang?.let { langMap[manga.url] = it } SManga.create().apply { @@ -215,13 +209,13 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } fun updateMangaInfo(manga: SManga, lang: String?) { - val directory = getBaseDirectories(context).map { File(it, manga.url) }.find { - it.exists() + val directory = getBaseDirectories().map { it.findFile(manga.url) }.find { + it?.exists() == true } ?: return lang?.let { langMap[manga.url] = it } val json = Json { prettyPrint = true } - val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name - val file = File(directory, existingFileName ?: "info.json") + val existingFileName = directory.listFiles()?.find { it.name.orEmpty().endsWith("json", true) }?.name + val file = directory.findFile(existingFileName ?: "info.json")!! file.writeText(json.encodeToString(manga.toJson(lang))) } @@ -256,24 +250,24 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } override suspend fun getChapterList(manga: SManga): List { - val chapters = getBaseDirectories(context) + val chapters = getBaseDirectories() .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } .flatten() - .filter { it.isDirectory || isSupportedFile(it.extension) } + .filter { it.isDirectory || isSupportedFile(it.extension.orEmpty()) } .map { chapterFile -> SChapter.create().apply { url = "${manga.url}/${chapterFile.name}" name = if (chapterFile.isDirectory) { - chapterFile.name + chapterFile.name.orEmpty() } else { - chapterFile.nameWithoutExtension + chapterFile.nameWithoutExtension.orEmpty() } date_upload = chapterFile.lastModified() val format = getFormat(chapterFile) if (format is Format.Epub) { - EpubFile(format.file).use { epub -> + EpubFile(format.file.uri.toFile()).use { epub -> epub.fillChapterMetadata(this) } } @@ -297,18 +291,18 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } fun getFormat(chapter: SChapter): Format { - val baseDirs = getBaseDirectories(context) + val baseDirs = getBaseDirectories() for (dir in baseDirs) { - val chapFile = File(dir, chapter.url) - if (!chapFile.exists()) continue + val chapFile = dir.findFile(chapter.url) + if (chapFile == null || !chapFile.exists()) continue return getFormat(chapFile) } throw Exception(context.getString(R.string.chapter_not_found)) } - private fun getFormat(file: File) = with(file) { + private fun getFormat(file: UniFile) = with(file) { when { isDirectory -> Format.Directory(this) extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this) @@ -318,41 +312,41 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } - 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.orEmpty()) { FileInputStream(it.uri.toFile()) } } - entry?.let { updateCover(context, manga, it.inputStream()) } + entry?.let { updateCover(manga, it.openInputStream()) } } is Format.Zip -> { - ZipFile(format.file).use { zip -> + ZipFile(format.file.uri.toFile()).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) } } - entry?.let { updateCover(context, manga, zip.getInputStream(it)) } + entry?.let { updateCover(manga, zip.getInputStream(it)) } } } is Format.Rar -> { - Archive(format.file).use { archive -> + Archive(format.file.uri.toFile()).use { archive -> val entry = archive.fileHeaders .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } - entry?.let { updateCover(context, manga, archive.getInputStream(it)) } + entry?.let { updateCover(manga, archive.getInputStream(it)) } } } is Format.Epub -> { - EpubFile(format.file).use { epub -> + EpubFile(format.file.uri.toFile()).use { epub -> val entry = epub.getImagesFromPages() .firstOrNull() ?.let { epub.getEntry(it) } - entry?.let { updateCover(context, manga, epub.getInputStream(it)) } + entry?.let { updateCover(manga, epub.getInputStream(it)) } } } } @@ -374,10 +368,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour ) sealed class 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() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index e7a331d7f8..d60988de82 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -578,7 +578,7 @@ class LibraryPresenter( private fun getLanguage(manga: Manga): String? { return if (manga.isLocal()) { - LocalSource.getMangaLang(manga, context) + LocalSource.getMangaLang(manga) } else { sourceManager.get(manga.source)?.lang } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt index 7c70e0cf93..497c5438fe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt @@ -124,7 +124,7 @@ class EditMangaDialog : DialogController { }, ) binding.mangaLang.setSelection( - languages.indexOf(LocalSource.getMangaLang(manga, binding.root.context)) + languages.indexOf(LocalSource.getMangaLang(manga)) .takeIf { it > -1 } ?: 0, ) } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 2968e09510..ec79622821 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -32,6 +32,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.ColorUtils +import androidx.core.net.toFile import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isVisible @@ -118,7 +119,6 @@ import eu.kanade.tachiyomi.util.view.findChild import eu.kanade.tachiyomi.util.view.getText import eu.kanade.tachiyomi.util.view.isControllerVisible import eu.kanade.tachiyomi.util.view.previousController -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setStyle @@ -249,7 +249,6 @@ class MangaDetailsController : if (presenter.preferences.themeMangaDetails()) { setItemColors() } - requestFilePermissionsSafe(301, presenter.preferences, presenter.manga.isLocal()) } private fun setAccentColorValue(colorToUse: Int? = null) { @@ -1193,7 +1192,7 @@ class MangaDetailsController : fun shareCover() { val cover = presenter.shareCover() if (cover != null) { - val stream = cover.getUriCompat(activity!!) + val stream = cover.toFile().getUriCompat(activity!!) val intent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, stream) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index dcaa7293e2..b4789ada10 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -3,12 +3,14 @@ package eu.kanade.tachiyomi.ui.manga import android.app.Application import android.graphics.Bitmap import android.net.Uri -import android.os.Environment +import androidx.core.net.toFile import coil3.imageLoader import coil3.memory.MemoryCache import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.SuccessResult +import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -81,6 +83,7 @@ class MangaDetailsPresenter( val db: DatabaseHelper = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), chapterFilter: ChapterFilter = Injekt.get(), + internal val storageManager: StorageManager = Injekt.get(), ) : BaseCoroutinePresenter(), DownloadQueue.DownloadListener { private val customMangaManager: CustomMangaManager by injectLazy() @@ -719,14 +722,13 @@ class MangaDetailsPresenter( fun shareManga() { val context = Injekt.get() - val destDir = File(context.cacheDir, "shared_image") + val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!! presenterScope.launchIO { - destDir.deleteRecursively() try { - val file = saveCover(destDir) + val uri = saveCover(destDir) withUIContext { - view?.shareManga(file) + view?.shareManga(uri.toFile()) } } catch (_: java.lang.Exception) { } @@ -831,7 +833,7 @@ class MangaDetailsPresenter( val inputStream = downloadManager.context.contentResolver.openInputStream(uri) ?: return false if (manga.isLocal()) { - LocalSource.updateCover(downloadManager.context, manga, inputStream) + LocalSource.updateCover(manga, inputStream) view?.setPaletteColor() return true } @@ -844,9 +846,9 @@ class MangaDetailsPresenter( return false } - fun shareCover(): File? { + fun shareCover(): Uri? { return try { - val destDir = File(coverCache.context.cacheDir, "shared_image") + val destDir = UniFile.fromFile(coverCache.context.cacheDir)!!.createDirectory("shared_image")!! val file = saveCover(destDir) file } catch (e: Exception) { @@ -857,43 +859,33 @@ class MangaDetailsPresenter( fun saveCover(): Boolean { return try { val directory = if (preferences.folderPerManga().get()) { - val baseDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + preferences.context.getString(R.string.app_normalized_name) - - File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title)) + storageManager.getCoversDirectory()!!.createDirectory(DiskUtil.buildValidFilename(manga.title))!! } else { - File( - Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + preferences.context.getString(R.string.app_normalized_name), - ) + storageManager.getCoversDirectory()!! } - val file = saveCover(directory) - DiskUtil.scanMedia(preferences.context, file) + val uri = saveCover(directory) + DiskUtil.scanMedia(preferences.context, uri.toFile()) true } catch (e: Exception) { false } } - private fun saveCover(directory: File): File { + private fun saveCover(directory: UniFile): Uri { val cover = coverCache.getCustomCoverFile(manga).takeIf { it.exists() } ?: coverCache.getCoverFile(manga) val type = ImageUtil.findImageType(cover.inputStream()) ?: throw Exception("Not an image") - directory.mkdirs() - // Build destination file. val filename = DiskUtil.buildValidFilename("${manga.title}.${type.extension}") - val destFile = File(directory, filename) + val destFile = directory.createFile(filename)!! cover.inputStream().use { input -> - destFile.outputStream().use { output -> + destFile.openOutputStream().use { output -> input.copyTo(output) } } - return destFile + return destFile.uri } fun isTracked(): Boolean = diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt index 890ca3a51f..170f7798cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt @@ -483,7 +483,7 @@ class StatsDetailsPresenter( */ private fun LibraryManga.getLanguage(): String { val code = if (isLocal()) { - LocalSource.getMangaLang(this, context) + LocalSource.getMangaLang(this) } else { sourceManager.get(source)?.lang } ?: return context.getString(R.string.unknown) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 4f75464b7e..2de437585e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -5,9 +5,12 @@ import android.graphics.BitmapFactory import android.net.Uri import android.os.Environment import androidx.annotation.ColorInt +import androidx.core.net.toFile import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -86,6 +89,7 @@ class ReaderViewModel( private val coverCache: CoverCache = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), ) : ViewModel() { private val mutableState = MutableStateFlow(State()) @@ -743,13 +747,11 @@ class ReaderViewModel( /** * Saves the image of this [page] in the given [directory] and returns the file location. */ - private fun saveImage(page: ReaderPage, directory: File, manga: Manga): File { + private fun saveImage(page: ReaderPage, directory: UniFile, manga: Manga): Uri { val stream = page.stream!! val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image") val context = Injekt.get() - directory.mkdirs() - val chapter = page.chapter.chapter // Build destination file. @@ -757,13 +759,13 @@ class ReaderViewModel( "${manga.title} - ${chapter.preferredChapterName(context, manga, preferences)}".take(225), ) + " - ${page.number}.${type.extension}" - val destFile = File(directory, filename) + val destFile = directory.createFile(filename)!! stream().use { input -> - destFile.outputStream().use { output -> + destFile.openOutputStream().use { output -> input.copyTo(output) } } - return destFile + return destFile.uri } /** @@ -814,22 +816,20 @@ class ReaderViewModel( notifier.onClear() // Pictures directory. - val baseDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + context.getString(R.string.app_normalized_name) + val baseDir = storageManager.getPagesDirectory()!! val destDir = if (preferences.folderPerManga().get()) { - File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title)) + baseDir.createDirectory(DiskUtil.buildValidFilename(manga.title))!! } else { - File(baseDir) + baseDir } // Copy file in background. viewModelScope.launchNonCancellable { try { - val file = saveImage(page, destDir, manga) - DiskUtil.scanMedia(context, file) - notifier.onComplete(file) - eventChannel.send(Event.SavedImage(SaveImageResult.Success(file))) + val uri = saveImage(page, destDir, manga) + DiskUtil.scanMedia(context, uri.toFile()) + notifier.onComplete(uri.toFile()) + eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri.toFile()))) } catch (e: Exception) { notifier.onError(e.message) eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) @@ -880,12 +880,11 @@ class ReaderViewModel( val manga = manga ?: return val context = Injekt.get() - val destDir = File(context.cacheDir, "shared_image") + val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!! viewModelScope.launchNonCancellable { - destDir.deleteRecursively() // Keep only the last shared file - val file = saveImage(page, destDir, manga) - eventChannel.send(Event.ShareImage(file, page)) + val uri = saveImage(page, destDir, manga) + eventChannel.send(Event.ShareImage(uri.toFile(), page)) } } @@ -919,7 +918,7 @@ class ReaderViewModel( if (manga.isLocal()) { val context = Injekt.get() coverCache.deleteFromCache(manga) - LocalSource.updateCover(context, manga, stream()) + LocalSource.updateCover(manga, stream()) R.string.cover_updated SetAsCoverResult.Success } else { 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 c7c06bb780..692356a069 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,6 +10,7 @@ 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 eu.kanade.tachiyomi.util.system.toTempFile import uy.kohesive.injekt.injectLazy import java.io.File @@ -48,7 +49,7 @@ 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/recents/RecentsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt index 0f2e4e352b..841531ca59 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt @@ -80,7 +80,6 @@ import eu.kanade.tachiyomi.util.view.isExpanded import eu.kanade.tachiyomi.util.view.isHidden import eu.kanade.tachiyomi.util.view.moveRecyclerViewUp import eu.kanade.tachiyomi.util.view.onAnimationsFinished -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setStyle @@ -421,7 +420,6 @@ class RecentsController(bundle: Bundle? = null) : binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand() } setPadding(binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == true) - requestFilePermissionsSafe(301, presenter.preferences) binding.downloadBottomSheet.root.sheetBehavior?.isGestureInsetBottomIgnored = true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt index e48774d0d0..bdb2c73251 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt @@ -4,16 +4,16 @@ import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri -import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.preference.PreferenceScreen import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager +import dev.yokai.domain.storage.StoragePreferences import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreatorJob @@ -26,23 +26,43 @@ import eu.kanade.tachiyomi.util.system.disableItems import eu.kanade.tachiyomi.util.system.materialAlertDialog import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.injectLazy -class SettingsBackupController : SettingsController() { +class SettingsDataController : SettingsController() { /** * Flags containing information of what to backup. */ private var backupFlags = 0 - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - requestFilePermissionsSafe(500, preferences) - } + internal val storagePreferences: StoragePreferences by injectLazy() + internal val storageManager: StorageManager by injectLazy() override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { - titleRes = R.string.backup_and_restore + titleRes = R.string.data_and_storage + + preference { + bindTo(storagePreferences.baseStorageDirectory()) + titleRes = R.string.storage_location + + onClick { + try { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + startActivityForResult(intent, CODE_DATA_DIR) + } catch (e: ActivityNotFoundException) { + activity?.toast(R.string.file_picker_error) + } + } + + storagePreferences.baseStorageDirectory().changes() + .onEach { path -> + summary = UniFile.fromUri(context, path.toUri())!!.let { dir -> + dir.filePath ?: context.getString(R.string.invalid_location, dir.uri) + } + } + .launchIn(viewScope) + } preference { key = "pref_create_backup" @@ -75,7 +95,7 @@ class SettingsBackupController : SettingsController() { (activity as? MainActivity)?.getExtensionUpdates(true) val intent = Intent(Intent.ACTION_GET_CONTENT) intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "*/*" + intent.setDataAndType(storageManager.getBackupsDirectory()!!.uri, "*/*") val title = resources?.getString(R.string.select_backup_file) val chooser = Intent.createChooser(intent, title) startActivityForResult(chooser, CODE_BACKUP_RESTORE) @@ -107,29 +127,6 @@ class SettingsBackupController : SettingsController() { true } } - preference { - bindTo(preferences.backupsDirectory()) - titleRes = R.string.backup_location - - onClick { - try { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - startActivityForResult(intent, CODE_BACKUP_DIR) - } catch (e: ActivityNotFoundException) { - activity?.toast(R.string.file_picker_error) - } - } - - visibleIf(preferences.backupInterval()) { it > 0 } - - preferences.backupsDirectory().changes() - .onEach { path -> - val dir = UniFile.fromUri(context, path.toUri())!! - val filePath = dir.filePath - summary = if (filePath != null) "$filePath/automatic" else "Invalid directory: ${dir.uri}" - } - .launchIn(viewScope) - } intListPreference(activity) { bindTo(preferences.numberOfBackups()) titleRes = R.string.max_auto_backups @@ -165,22 +162,18 @@ class SettingsBackupController : SettingsController() { } when (requestCode) { - CODE_BACKUP_DIR -> { + CODE_DATA_DIR -> { // Get UriPermission so it's possible to write files val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION activity.contentResolver.takePersistableUriPermission(uri, flags) - preferences.backupsDirectory().set(uri.toString()) + val file = UniFile.fromUri(activity, uri)!! + storagePreferences.baseStorageDirectory().set(file.uri.toString()) } CODE_BACKUP_CREATE -> { - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - activity.contentResolver.takePersistableUriPermission(uri, flags) - activity.toast(R.string.creating_backup) - BackupCreatorJob.startNow(activity, uri, backupFlags) + doBackup(backupFlags, uri) } CODE_BACKUP_RESTORE -> { @@ -191,8 +184,25 @@ class SettingsBackupController : SettingsController() { } } - fun createBackup(flags: Int) { + private fun doBackup(flags: Int, uri: Uri) { + val activity = activity ?: return + + val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + activity.contentResolver.takePersistableUriPermission(uri, intentFlags) + activity.toast(R.string.creating_backup) + BackupCreatorJob.startNow(activity, uri, flags) + } + + fun createBackup(flags: Int, picker: Boolean = false) { backupFlags = flags + + if (!picker) { + doBackup(backupFlags, storageManager.getBackupsDirectory()!!.uri) + return + } + try { // Use Android's built-in file creator val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) @@ -299,7 +309,7 @@ class SettingsBackupController : SettingsController() { } } -private const val CODE_BACKUP_DIR = 503 +private const val CODE_DATA_DIR = 104 private const val CODE_BACKUP_CREATE = 504 private const val CODE_BACKUP_RESTORE = 505 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt index 6d2cb4ca08..f2e61f63f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt @@ -1,24 +1,14 @@ package eu.kanade.tachiyomi.ui.setting -import android.app.Activity import android.content.Intent -import android.net.Uri -import android.os.Environment -import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.preference.PreferenceScreen -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.hippo.unifile.UniFile +import com.bluelinelabs.conductor.Controller import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.changesIn -import eu.kanade.tachiyomi.util.system.withOriginalWidth -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get +import eu.kanade.tachiyomi.util.view.withFadeTransaction import uy.kohesive.injekt.injectLazy -import java.io.File import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys class SettingsDownloadController : SettingsController() { @@ -31,14 +21,9 @@ class SettingsDownloadController : SettingsController() { preference { key = Keys.downloadsDirectory titleRes = R.string.download_location - onClick { - DownloadDirectoriesDialog(this@SettingsDownloadController).show() - } + onClick { navigateTo(SettingsDataController()) } - preferences.downloadsDirectory().changesIn(viewScope) { path -> - val dir = UniFile.fromUri(context, path.toUri())!! - summary = dir.filePath ?: path - } + summary = "Moved to Data and Storage!" } switchPreference { key = Keys.downloadOnlyOverWifi @@ -150,70 +135,9 @@ class SettingsDownloadController : SettingsController() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - DOWNLOAD_DIR -> if (data != null && resultCode == Activity.RESULT_OK) { - val context = applicationContext ?: return - val uri = data.data - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - if (uri != null) { - @Suppress("NewApi") - context.contentResolver.takePersistableUriPermission(uri, flags) - } - - val file = UniFile.fromUri(context, uri)!! - preferences.downloadsDirectory().set(file.uri.toString()) - } - } } - fun predefinedDirectorySelected(selectedDir: String) { - val path = Uri.fromFile(File(selectedDir)) - preferences.downloadsDirectory().set(path.toString()) - } - - fun customDirectorySelected() { - startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), DOWNLOAD_DIR) - } - - class DownloadDirectoriesDialog(val controller: SettingsDownloadController) : - MaterialAlertDialogBuilder(controller.activity!!.withOriginalWidth()) { - - private val preferences: PreferencesHelper = Injekt.get() - - val activity = controller.activity!! - - init { - val currentDir = preferences.downloadsDirectory().get() - val externalDirs = - getExternalDirs() + File(activity.getString(R.string.custom_location)) - val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir } - val items = externalDirs.map { it.path } - - setTitle(R.string.download_location) - setSingleChoiceItems(items.toTypedArray(), selectedIndex) { dialog, position -> - if (position == externalDirs.lastIndex) { - controller.customDirectorySelected() - } else { - controller.predefinedDirectorySelected(items[position]) - } - dialog.dismiss() - } - setNegativeButton(android.R.string.cancel, null) - } - - private fun getExternalDirs(): List { - val defaultDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + activity.resources?.getString(R.string.app_normalized_name) + - File.separator + "downloads" - - return mutableListOf(File(defaultDir)) + - ContextCompat.getExternalFilesDirs(activity, "").filterNotNull() - } - } - - private companion object { - const val DOWNLOAD_DIR = 104 + private fun navigateTo(controller: Controller) { + router.pushController(controller.withFadeTransaction()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index d5a8c6b29c..705e5b0b65 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -75,8 +75,8 @@ class SettingsMainController : SettingsController(), FloatingSearchInterface { preference { iconRes = R.drawable.ic_backup_restore_24dp iconTint = tintColor - titleRes = R.string.backup_and_restore - onClick { navigateTo(SettingsBackupController()) } + titleRes = R.string.data_and_storage + onClick { navigateTo(SettingsDataController()) } } preference { iconRes = R.drawable.ic_security_24dp diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt index 5e368a60d5..b7a52630fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt @@ -9,7 +9,7 @@ import androidx.preference.PreferenceGroup import androidx.preference.PreferenceManager import eu.kanade.tachiyomi.ui.setting.SettingsAdvancedController import eu.kanade.tachiyomi.ui.setting.SettingsAppearanceController -import eu.kanade.tachiyomi.ui.setting.SettingsBackupController +import eu.kanade.tachiyomi.ui.setting.SettingsDataController import eu.kanade.tachiyomi.ui.setting.SettingsBrowseController import eu.kanade.tachiyomi.ui.setting.SettingsController import eu.kanade.tachiyomi.ui.setting.SettingsDownloadController @@ -31,7 +31,7 @@ object SettingsSearchHelper { */ private val settingControllersList: List> = listOf( SettingsAdvancedController::class, - SettingsBackupController::class, + SettingsDataController::class, SettingsBrowseController::class, SettingsDownloadController::class, SettingsGeneralController::class, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt index a018b53f0e..7b3d28ee05 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt @@ -60,7 +60,6 @@ import eu.kanade.tachiyomi.util.view.isCollapsed import eu.kanade.tachiyomi.util.view.isCompose import eu.kanade.tachiyomi.util.view.isControllerVisible import eu.kanade.tachiyomi.util.view.onAnimationsFinished -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.snack @@ -182,7 +181,6 @@ class BrowseController : updateTitleAndMenu() } - requestFilePermissionsSafe(301, preferences) binding.bottomSheet.root.onCreate(this) basePreferences.extensionInstaller().changes() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt index 5c376147ed..f242ab704a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt @@ -48,7 +48,6 @@ import eu.kanade.tachiyomi.util.view.applyBottomAnimatedInsets import eu.kanade.tachiyomi.util.view.fullAppBarHeight import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.util.view.isControllerVisible -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.snack @@ -182,7 +181,6 @@ open class BrowseSourceController(bundle: Bundle) : } else { binding.progress.isVisible = true } - requestFilePermissionsSafe(301, preferences, presenter.source is LocalSource) } override fun onDestroyView(view: View) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt new file mode 100644 index 0000000000..6eba4738ea --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.util.system + +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.nameWithoutExtension: String? + get() = name?.substringBeforeLast('.') + +val UniFile.extension: String? + get() = name?.replace(nameWithoutExtension.orEmpty(), "") + +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 +} + +fun UniFile.writeText(string: String) { + this.openOutputStream().use { + it.write(string.toByteArray()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt index e1b1415753..31dac56831 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.util.view -import android.Manifest import android.animation.Animator import android.animation.ValueAnimator import android.app.ActivityManager @@ -8,12 +7,9 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.content.res.ColorStateList import android.graphics.Color import android.os.Build -import android.os.Environment -import android.provider.Settings import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -26,7 +22,6 @@ import androidx.annotation.CallSuper import androidx.annotation.MainThread import androidx.appcompat.widget.SearchView import androidx.cardview.widget.CardView -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.graphics.ColorUtils import androidx.core.math.MathUtils @@ -53,7 +48,6 @@ import com.bluelinelabs.conductor.Router import com.bluelinelabs.conductor.RouterTransaction import com.google.android.material.snackbar.Snackbar import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface @@ -73,7 +67,6 @@ import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.ignoredSystemInsets -import eu.kanade.tachiyomi.util.system.materialAlertDialog import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat import eu.kanade.tachiyomi.util.system.toInt import eu.kanade.tachiyomi.util.system.toast @@ -780,54 +773,6 @@ fun Controller.setAppBarBG(value: Float, includeTabView: Boolean = false) { } } -fun Controller.requestFilePermissionsSafe( - requestCode: Int, - preferences: PreferencesHelper, - showA11PermissionAnyway: Boolean = false, -) { - val activity = activity ?: return - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - val permissions = mutableListOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - permissions.forEach { permission -> - if (ContextCompat.checkSelfPermission( - activity, - permission, - ) != PackageManager.PERMISSION_GRANTED - ) { - requestPermissions(arrayOf(permission), requestCode) - } - } - } - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - !Environment.isExternalStorageManager() && - (!preferences.hasDeniedA11FilePermission().get() || showA11PermissionAnyway) - ) { - preferences.hasDeniedA11FilePermission().set(true) - activity.materialAlertDialog() - .setTitle(R.string.all_files_permission_required) - .setMessage(R.string.external_storage_permission_notice) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> - val intent = Intent( - Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - "package:${activity.packageName}".toUri(), - ) - try { - activity.startActivity(intent) - } catch (_: Exception) { - val intent2 = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - activity.startActivity(intent2) - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } else if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()) && !preferences.backupInterval().isSet()) { - preferences.backupInterval().set(24) - BackupCreatorJob.setupTask(activity, 24) - } -} - fun Controller.withFadeTransaction(): RouterTransaction { return RouterTransaction.with(this) .pushChangeHandler(fadeTransactionHandler()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f8d618482..d6269888b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -793,6 +793,10 @@ Series shortcuts opens new chapters When there\'s no new chapters, the series\' details will open instead + + Data and storage + Storage location + Backup Backup and restore