feat: The actual unified storage

This commit is contained in:
Ahmad Ansori Palembani 2024-05-27 09:28:31 +07:00
parent 6887d779ef
commit 64d6879893
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
25 changed files with 256 additions and 380 deletions

View file

@ -7,11 +7,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Storage -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- For background jobs -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -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"

View file

@ -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"

View file

@ -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()

View file

@ -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<PreferencesHelper>()
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)

View file

@ -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
}

View file

@ -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)
}
/**

View file

@ -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<String>) = 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)

View file

@ -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<String, String>()
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<MangaJson>(localDetails.inputStream())
val obj = Json.decodeFromStream<MangaJson>(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<File> {
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<UniFile> {
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<MangaJson>(localDetails.inputStream())
val obj = json.decodeFromStream<MangaJson>(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<SChapter> {
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()
}
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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

View file

@ -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<MangaDetailsController>(), DownloadQueue.DownloadListener {
private val customMangaManager: CustomMangaManager by injectLazy()
@ -719,14 +722,13 @@ class MangaDetailsPresenter(
fun shareManga() {
val context = Injekt.get<Application>()
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 =

View file

@ -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)

View file

@ -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<Application>()
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<Application>()
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<Application>()
coverCache.deleteFromCache(manga)
LocalSource.updateCover(context, manga, stream())
LocalSource.updateCover(manga, stream())
R.string.cover_updated
SetAsCoverResult.Success
} else {

View file

@ -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<ReaderPage> {
val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it }
val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it }
return loader.getPages()
}

View file

@ -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
}

View file

@ -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

View file

@ -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<File> {
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())
}
}

View file

@ -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

View file

@ -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<KClass<out SettingsController>> = listOf(
SettingsAdvancedController::class,
SettingsBackupController::class,
SettingsDataController::class,
SettingsBrowseController::class,
SettingsDownloadController::class,
SettingsGeneralController::class,

View file

@ -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()

View file

@ -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) {

View file

@ -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())
}
}

View file

@ -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())

View file

@ -793,6 +793,10 @@
<string name="series_opens_new_chapters">Series shortcuts opens new chapters</string>
<string name="no_new_chapters_open_details">When there\'s no new chapters, the series\' details will open instead</string>
<!-- Storage -->
<string name="data_and_storage">Data and storage</string>
<string name="storage_location">Storage location</string>
<!-- Backup -->
<string name="backup">Backup</string>
<string name="backup_and_restore">Backup and restore</string>