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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <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 --> <!-- For background jobs -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -37,12 +32,10 @@
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:preserveLegacyExternalStorage="true"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true" android:supportsRtl="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"

View file

@ -40,6 +40,8 @@ class StorageManager(
parent.createDirectory(DOWNLOADS_PATH).also { parent.createDirectory(DOWNLOADS_PATH).also {
DiskUtil.createNoMediaFile(it, context) DiskUtil.createNoMediaFile(it, context)
} }
parent.createDirectory(COVERS_PATH)
parent.createDirectory(PAGES_PATH)
} }
_changes.send(Unit) _changes.send(Unit)
} }
@ -66,9 +68,19 @@ class StorageManager(
fun getLocalSourceDirectory(): UniFile? { fun getLocalSourceDirectory(): UniFile? {
return baseDir?.createDirectory(LOCAL_SOURCE_PATH) 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 BACKUPS_PATH = "autobackup"
private const val AUTOMATIC_BACKUPS_PATH = "autobackup" private const val AUTOMATIC_BACKUPS_PATH = "autobackup"
private const val DOWNLOADS_PATH = "downloads" private const val DOWNLOADS_PATH = "downloads"
private const val LOCAL_SOURCE_PATH = "local" 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.content.Context
import android.net.Uri import android.net.Uri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StorageManager
import eu.kanade.tachiyomi.R 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
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK
@ -54,6 +55,7 @@ import okio.sink
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.FileOutputStream import java.io.FileOutputStream
class BackupCreator(val context: Context) { class BackupCreator(val context: Context) {
@ -64,6 +66,7 @@ class BackupCreator(val context: Context) {
private val sourceManager: SourceManager = Injekt.get() private val sourceManager: SourceManager = Injekt.get()
private val preferences: PreferencesHelper = Injekt.get() private val preferences: PreferencesHelper = Injekt.get()
private val customMangaManager: CustomMangaManager = Injekt.get() private val customMangaManager: CustomMangaManager = Injekt.get()
internal val storageManager: StorageManager by injectLazy()
/** /**
* Create backup Json file from database * Create backup Json file from database
@ -98,8 +101,7 @@ class BackupCreator(val context: Context) {
file = ( file = (
if (isAutoBackup) { if (isAutoBackup) {
// Get dir of file and create // Get dir of file and create
// TODO: Unified Storage val dir = storageManager.getAutomaticBackupsDirectory()!!
val dir = UniFile.fromUri(context, uri)!!.createDirectory("automatic")!!
// Delete older backups // Delete older backups
val numberOfBackups = preferences.numberOfBackups().get() val numberOfBackups = preferences.numberOfBackups().get()

View file

@ -13,6 +13,8 @@ import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import com.hippo.unifile.UniFile 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.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.system.localeContext import eu.kanade.tachiyomi.util.system.localeContext
@ -20,16 +22,16 @@ import eu.kanade.tachiyomi.util.system.notificationManager
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) : class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) { Worker(context, workerParams) {
override fun doWork(): Result { override fun doWork(): Result {
val preferences = Injekt.get<PreferencesHelper>() val storageManager: StorageManager by injectLazy()
val notifier = BackupNotifier(context.localeContext) val notifier = BackupNotifier(context.localeContext)
val uri = inputData.getString(LOCATION_URI_KEY)?.let { Uri.parse(it) } val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() ?: storageManager.getAutomaticBackupsDirectory()?.uri!!
?: preferences.backupsDirectory().get().toUri()
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL) val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL)
val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true) val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true)

View file

@ -1,18 +1,16 @@
package eu.kanade.tachiyomi.data.download package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import androidx.core.net.toUri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StorageManager
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga 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.source.SourceManager
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -35,7 +33,7 @@ class DownloadCache(
private val context: Context, private val context: Context,
private val provider: DownloadProvider, private val provider: DownloadProvider,
private val sourceManager: SourceManager, 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) val scope = CoroutineScope(Job() + Dispatchers.IO)
init { init {
preferences.downloadsDirectory().changes() storageManager.changes
.drop(1) .onEach { forceRenewCache() } // invalidate cache
.onEach { lastRenew = 0L } // invalidate cache
.launchIn(scope) .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. * Returns true if the chapter is downloaded.
* *
@ -138,7 +126,7 @@ class DownloadCache(
private fun renew() { private fun renew() {
val onlineSources = sourceManager.getOnlineSources() val onlineSources = sourceManager.getOnlineSources()
val sourceDirs = getDirectoryFromPreference().listFiles().orEmpty() val sourceDirs = storageManager.getDownloadsDirectory()!!.listFiles().orEmpty()
.associate { it.name to SourceDirectory(it) }.mapNotNullKeys { entry -> .associate { it.name to SourceDirectory(it) }.mapNotNullKeys { entry ->
onlineSources.find { provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) }?.id 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 android.content.Context
import androidx.core.net.toUri import androidx.core.net.toUri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StorageManager
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
@ -31,6 +32,7 @@ class DownloadProvider(private val context: Context) {
* Preferences helper. * Preferences helper.
*/ */
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
private val storageManager: StorageManager by injectLazy()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -38,15 +40,11 @@ class DownloadProvider(private val context: Context) {
* The root directory for downloads. * The root directory for downloads.
*/ */
// TODO: Unified Storage // TODO: Unified Storage
private var downloadsDir = preferences.downloadsDirectory().get().let { private var downloadsDir = storageManager.getDownloadsDirectory()
val dir = UniFile.fromUri(context, it.toUri())
DiskUtil.createNoMediaFile(dir, context)
dir!!
}
init { init {
preferences.downloadsDirectory().changes().drop(1).onEach { storageManager.changes.onEach {
downloadsDir = UniFile.fromUri(context, it.toUri())!! downloadsDir = storageManager.getDownloadsDirectory()
}.launchIn(scope) }.launchIn(scope)
} }
@ -58,7 +56,7 @@ class DownloadProvider(private val context: Context) {
*/ */
internal fun getMangaDir(manga: Manga, source: Source): UniFile { internal fun getMangaDir(manga: Manga, source: Source): UniFile {
try { try {
return downloadsDir.createDirectory(getSourceDirName(source))!! return downloadsDir!!.createDirectory(getSourceDirName(source))!!
.createDirectory(getMangaDirName(manga))!! .createDirectory(getMangaDirName(manga))!!
} catch (e: NullPointerException) { } catch (e: NullPointerException) {
throw Exception(context.getString(R.string.invalid_download_location)) 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. * @param source the source to query.
*/ */
fun findSourceDir(source: Source): UniFile? { 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 package eu.kanade.tachiyomi.data.preference
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.preference.Preference import eu.kanade.tachiyomi.core.preference.Preference
import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.preference.PreferenceStore
import eu.kanade.tachiyomi.core.preference.getEnum import eu.kanade.tachiyomi.core.preference.getEnum
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob
import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet 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.ReaderBottomButton
import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation 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.ui.recents.RecentsPresenter
import eu.kanade.tachiyomi.util.system.Themes import eu.kanade.tachiyomi.util.system.Themes
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import java.io.File
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -66,22 +60,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) 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 getInt(key: String, default: Int) = preferenceStore.getInt(key, default)
fun getStringPref(key: String, default: String = "") = preferenceStore.getString(key, default) fun getStringPref(key: String, default: String = "") = preferenceStore.getString(key, default)
fun getStringSet(key: String, default: Set<String>) = preferenceStore.getStringSet(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 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) { fun dateFormat(format: String = preferenceStore.getString(Keys.dateFormat, "").get()): DateFormat = when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT) "" -> DateFormat.getDateInstance(DateFormat.SHORT)
else -> SimpleDateFormat(format, Locale.getDefault()) else -> SimpleDateFormat(format, Locale.getDefault())
@ -224,8 +200,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto
fun appLanguage() = preferenceStore.getString("app_language", "") fun appLanguage() = preferenceStore.getString("app_language", "")
fun downloadsDirectory() = preferenceStore.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false) fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false)

View file

@ -1,7 +1,10 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import androidx.core.net.toFile
import com.github.junrar.Archive 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.R
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList 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.source.model.SManga
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder 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.storage.EpubFile
import eu.kanade.tachiyomi.util.system.ImageUtil 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.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -20,7 +25,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.concurrent.TimeUnit 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 LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
private val langMap = hashMapOf<String, String>() private val langMap = hashMapOf<String, String>()
fun getMangaLang(manga: SManga, context: Context): String { fun getMangaLang(manga: SManga): String {
return langMap.getOrPut(manga.url) { return langMap.getOrPut(manga.url) {
val localDetails = getBaseDirectories(context) val localDetails = getBaseDirectories()
.asSequence() .asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() }
.flatten() .flatten()
.firstOrNull { it.extension.equals("json", ignoreCase = true) } .firstOrNull { it.extension.equals("json", ignoreCase = true) }
return if (localDetails != null) { return if (localDetails != null) {
val obj = Json.decodeFromStream<MangaJson>(localDetails.inputStream()) val obj = Json.decodeFromStream<MangaJson>(localDetails.openInputStream())
obj.lang ?: "other" obj.lang ?: "other"
} else { } else {
"other" "other"
@ -52,49 +56,39 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
} }
} }
fun updateCover(context: Context, manga: SManga, input: InputStream): File? { fun updateCover(manga: SManga, input: InputStream): UniFile? {
val dir = getBaseDirectories(context).firstOrNull() val dir = getBaseDirectories().firstOrNull()
if (dir == null) { if (dir == null) {
input.close() input.close()
return null return null
} }
var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) var cover = getCoverFile(dir.findFile(manga.url))
if (cover == null) { 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 // It might not exist if using the external SD card
cover.parentFile?.mkdirs() cover.parentFile?.parentFile?.createDirectory(cover.parentFile?.name)
input.use { input.use {
cover.outputStream().use { cover.openOutputStream().use {
input.copyTo(it) input.copyTo(it)
} }
} }
manga.thumbnail_url = cover.absolutePath manga.thumbnail_url = cover.filePath
return cover return cover
} }
/** /**
* Returns valid cover file inside [parent] directory. * Returns valid cover file inside [parent] directory.
*/ */
private fun getCoverFile(parent: File): File? { private fun getCoverFile(parent: UniFile?): UniFile? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { return parent?.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } it.isFile && ImageUtil.isImage(it.name.orEmpty()) { it.openInputStream() }
} }
} }
private fun getBaseDirectories(context: Context): List<File> { private fun getBaseDirectories(): List<UniFile> {
val library = context.getString(R.string.app_short_name) + File.separator + "local" val storageManager: StorageManager by injectLazy()
val normalized = context.getString(R.string.app_normalized_name) + File.separator + "local" return listOf(storageManager.getLocalSourceDirectory()!!)
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()
} }
} }
@ -114,7 +108,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
query: String, query: String,
filters: FilterList, filters: FilterList,
): MangasPage { ): MangasPage {
val baseDirs = getBaseDirectories(context) val baseDirs = getBaseDirectories()
val time = val time =
if (filters === latestFilters) System.currentTimeMillis() - LATEST_THRESHOLD else 0L 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() } .mapNotNull { it.listFiles()?.toList() }
.flatten() .flatten()
.filter { it.isDirectory } .filter { it.isDirectory }
.filterNot { it.name.startsWith('.') } .filterNot { it.name.orEmpty().startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } .filter { if (time == 0L) it.name.orEmpty().contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name } .distinctBy { it.name }
val state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state val state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state
when (state?.index) { when (state?.index) {
0 -> { 0 -> {
mangaDirs = if (state.ascending) { mangaDirs = if (state.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() })
} else { } else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty()})
} }
} }
1 -> { 1 -> {
mangaDirs = if (state.ascending) { mangaDirs = if (state.ascending) {
mangaDirs.sortedBy(File::lastModified) mangaDirs.sortedBy(UniFile::lastModified)
} else { } else {
mangaDirs.sortedByDescending(File::lastModified) mangaDirs.sortedByDescending(UniFile::lastModified)
} }
} }
} }
val mangas = mangaDirs.map { mangaDir -> val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply { SManga.create().apply {
title = mangaDir.name title = mangaDir.name.orEmpty()
url = mangaDir.name url = mangaDir.name.orEmpty()
// Try to find the cover // Try to find the cover
for (dir in baseDirs) { for (dir in baseDirs) {
val cover = getCoverFile(File("${dir.absolutePath}/$url")) val cover = getCoverFile(mangaDir.findFile(url))
if (cover != null && cover.exists()) { if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath thumbnail_url = cover.filePath
break break
} }
} }
@ -166,7 +160,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
val chapter = chapters.last() val chapter = chapters.last()
val format = getFormat(chapter) val format = getFormat(chapter)
if (format is Format.Epub) { if (format is Format.Epub) {
EpubFile(format.file).use { epub -> EpubFile(format.file.uri.toFile()).use { epub ->
epub.fillMangaMetadata(manga) epub.fillMangaMetadata(manga)
} }
} }
@ -175,7 +169,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
if (thumbnail_url == null) { if (thumbnail_url == null) {
try { try {
val dest = updateCover(chapter, manga) val dest = updateCover(chapter, manga)
thumbnail_url = dest?.absolutePath thumbnail_url = dest?.filePath
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) 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 getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters)
override suspend fun getMangaDetails(manga: SManga): SManga { override suspend fun getMangaDetails(manga: SManga): SManga {
val localDetails = getBaseDirectories(context) val localDetails = getBaseDirectories()
.asSequence() .asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() }
.flatten() .flatten()
.firstOrNull { it.extension.equals("json", ignoreCase = true) } .firstOrNull { it.extension.equals("json", ignoreCase = true) }
return if (localDetails != null) { 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 } obj.lang?.let { langMap[manga.url] = it }
SManga.create().apply { SManga.create().apply {
@ -215,13 +209,13 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
} }
fun updateMangaInfo(manga: SManga, lang: String?) { fun updateMangaInfo(manga: SManga, lang: String?) {
val directory = getBaseDirectories(context).map { File(it, manga.url) }.find { val directory = getBaseDirectories().map { it.findFile(manga.url) }.find {
it.exists() it?.exists() == true
} ?: return } ?: return
lang?.let { langMap[manga.url] = it } lang?.let { langMap[manga.url] = it }
val json = Json { prettyPrint = true } val json = Json { prettyPrint = true }
val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name val existingFileName = directory.listFiles()?.find { it.name.orEmpty().endsWith("json", true) }?.name
val file = File(directory, existingFileName ?: "info.json") val file = directory.findFile(existingFileName ?: "info.json")!!
file.writeText(json.encodeToString(manga.toJson(lang))) 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> { override suspend fun getChapterList(manga: SManga): List<SChapter> {
val chapters = getBaseDirectories(context) val chapters = getBaseDirectories()
.asSequence() .asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() }
.flatten() .flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) } .filter { it.isDirectory || isSupportedFile(it.extension.orEmpty()) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}" url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) { name = if (chapterFile.isDirectory) {
chapterFile.name chapterFile.name.orEmpty()
} else { } else {
chapterFile.nameWithoutExtension chapterFile.nameWithoutExtension.orEmpty()
} }
date_upload = chapterFile.lastModified() date_upload = chapterFile.lastModified()
val format = getFormat(chapterFile) val format = getFormat(chapterFile)
if (format is Format.Epub) { if (format is Format.Epub) {
EpubFile(format.file).use { epub -> EpubFile(format.file.uri.toFile()).use { epub ->
epub.fillChapterMetadata(this) epub.fillChapterMetadata(this)
} }
} }
@ -297,18 +291,18 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
} }
fun getFormat(chapter: SChapter): Format { fun getFormat(chapter: SChapter): Format {
val baseDirs = getBaseDirectories(context) val baseDirs = getBaseDirectories()
for (dir in baseDirs) { for (dir in baseDirs) {
val chapFile = File(dir, chapter.url) val chapFile = dir.findFile(chapter.url)
if (!chapFile.exists()) continue if (chapFile == null || !chapFile.exists()) continue
return getFormat(chapFile) return getFormat(chapFile)
} }
throw Exception(context.getString(R.string.chapter_not_found)) throw Exception(context.getString(R.string.chapter_not_found))
} }
private fun getFormat(file: File) = with(file) { private fun getFormat(file: UniFile) = with(file) {
when { when {
isDirectory -> Format.Directory(this) isDirectory -> Format.Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(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 { return try {
when (val format = getFormat(chapter)) { when (val format = getFormat(chapter)) {
is Format.Directory -> { is Format.Directory -> {
val entry = format.file.listFiles() val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } ?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } ?.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 -> { is Format.Zip -> {
ZipFile(format.file).use { zip -> ZipFile(format.file.uri.toFile()).use { zip ->
val entry = zip.entries().toList() val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } .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 -> { is Format.Rar -> {
Archive(format.file).use { archive -> Archive(format.file.uri.toFile()).use { archive ->
val entry = archive.fileHeaders val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } .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 -> { is Format.Epub -> {
EpubFile(format.file).use { epub -> EpubFile(format.file.uri.toFile()).use { epub ->
val entry = epub.getImagesFromPages() val entry = epub.getImagesFromPages()
.firstOrNull() .firstOrNull()
?.let { epub.getEntry(it) } ?.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 { sealed class Format {
data class Directory(val file: File) : Format() data class Directory(val file: UniFile) : Format()
data class Zip(val file: File) : Format() data class Zip(val file: UniFile) : Format()
data class Rar(val file: File) : Format() data class Rar(val file: UniFile) : Format()
data class Epub(val file: File) : Format() data class Epub(val file: UniFile) : Format()
} }
} }

View file

@ -578,7 +578,7 @@ class LibraryPresenter(
private fun getLanguage(manga: Manga): String? { private fun getLanguage(manga: Manga): String? {
return if (manga.isLocal()) { return if (manga.isLocal()) {
LocalSource.getMangaLang(manga, context) LocalSource.getMangaLang(manga)
} else { } else {
sourceManager.get(manga.source)?.lang sourceManager.get(manga.source)?.lang
} }

View file

@ -124,7 +124,7 @@ class EditMangaDialog : DialogController {
}, },
) )
binding.mangaLang.setSelection( binding.mangaLang.setSelection(
languages.indexOf(LocalSource.getMangaLang(manga, binding.root.context)) languages.indexOf(LocalSource.getMangaLang(manga))
.takeIf { it > -1 } ?: 0, .takeIf { it > -1 } ?: 0,
) )
} else { } else {

View file

@ -32,6 +32,7 @@ import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.net.toFile
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible 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.getText
import eu.kanade.tachiyomi.util.view.isControllerVisible import eu.kanade.tachiyomi.util.view.isControllerVisible
import eu.kanade.tachiyomi.util.view.previousController 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.scrollViewWith
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.util.view.setStyle import eu.kanade.tachiyomi.util.view.setStyle
@ -249,7 +249,6 @@ class MangaDetailsController :
if (presenter.preferences.themeMangaDetails()) { if (presenter.preferences.themeMangaDetails()) {
setItemColors() setItemColors()
} }
requestFilePermissionsSafe(301, presenter.preferences, presenter.manga.isLocal())
} }
private fun setAccentColorValue(colorToUse: Int? = null) { private fun setAccentColorValue(colorToUse: Int? = null) {
@ -1193,7 +1192,7 @@ class MangaDetailsController :
fun shareCover() { fun shareCover() {
val cover = presenter.shareCover() val cover = presenter.shareCover()
if (cover != null) { if (cover != null) {
val stream = cover.getUriCompat(activity!!) val stream = cover.toFile().getUriCompat(activity!!)
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, stream) putExtra(Intent.EXTRA_STREAM, stream)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION 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.app.Application
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Environment import androidx.core.net.toFile
import coil3.imageLoader import coil3.imageLoader
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.SuccessResult import coil3.request.SuccessResult
import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StorageManager
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
@ -81,6 +83,7 @@ class MangaDetailsPresenter(
val db: DatabaseHelper = Injekt.get(), val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
chapterFilter: ChapterFilter = Injekt.get(), chapterFilter: ChapterFilter = Injekt.get(),
internal val storageManager: StorageManager = Injekt.get(),
) : BaseCoroutinePresenter<MangaDetailsController>(), DownloadQueue.DownloadListener { ) : BaseCoroutinePresenter<MangaDetailsController>(), DownloadQueue.DownloadListener {
private val customMangaManager: CustomMangaManager by injectLazy() private val customMangaManager: CustomMangaManager by injectLazy()
@ -719,14 +722,13 @@ class MangaDetailsPresenter(
fun shareManga() { fun shareManga() {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image") val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!!
presenterScope.launchIO { presenterScope.launchIO {
destDir.deleteRecursively()
try { try {
val file = saveCover(destDir) val uri = saveCover(destDir)
withUIContext { withUIContext {
view?.shareManga(file) view?.shareManga(uri.toFile())
} }
} catch (_: java.lang.Exception) { } catch (_: java.lang.Exception) {
} }
@ -831,7 +833,7 @@ class MangaDetailsPresenter(
val inputStream = val inputStream =
downloadManager.context.contentResolver.openInputStream(uri) ?: return false downloadManager.context.contentResolver.openInputStream(uri) ?: return false
if (manga.isLocal()) { if (manga.isLocal()) {
LocalSource.updateCover(downloadManager.context, manga, inputStream) LocalSource.updateCover(manga, inputStream)
view?.setPaletteColor() view?.setPaletteColor()
return true return true
} }
@ -844,9 +846,9 @@ class MangaDetailsPresenter(
return false return false
} }
fun shareCover(): File? { fun shareCover(): Uri? {
return try { return try {
val destDir = File(coverCache.context.cacheDir, "shared_image") val destDir = UniFile.fromFile(coverCache.context.cacheDir)!!.createDirectory("shared_image")!!
val file = saveCover(destDir) val file = saveCover(destDir)
file file
} catch (e: Exception) { } catch (e: Exception) {
@ -857,43 +859,33 @@ class MangaDetailsPresenter(
fun saveCover(): Boolean { fun saveCover(): Boolean {
return try { return try {
val directory = if (preferences.folderPerManga().get()) { val directory = if (preferences.folderPerManga().get()) {
val baseDir = Environment.getExternalStorageDirectory().absolutePath + storageManager.getCoversDirectory()!!.createDirectory(DiskUtil.buildValidFilename(manga.title))!!
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + preferences.context.getString(R.string.app_normalized_name)
File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title))
} else { } else {
File( storageManager.getCoversDirectory()!!
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + preferences.context.getString(R.string.app_normalized_name),
)
} }
val file = saveCover(directory) val uri = saveCover(directory)
DiskUtil.scanMedia(preferences.context, file) DiskUtil.scanMedia(preferences.context, uri.toFile())
true true
} catch (e: Exception) { } catch (e: Exception) {
false 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 cover = coverCache.getCustomCoverFile(manga).takeIf { it.exists() } ?: coverCache.getCoverFile(manga)
val type = ImageUtil.findImageType(cover.inputStream()) val type = ImageUtil.findImageType(cover.inputStream())
?: throw Exception("Not an image") ?: throw Exception("Not an image")
directory.mkdirs()
// Build destination file. // Build destination file.
val filename = DiskUtil.buildValidFilename("${manga.title}.${type.extension}") val filename = DiskUtil.buildValidFilename("${manga.title}.${type.extension}")
val destFile = File(directory, filename) val destFile = directory.createFile(filename)!!
cover.inputStream().use { input -> cover.inputStream().use { input ->
destFile.outputStream().use { output -> destFile.openOutputStream().use { output ->
input.copyTo(output) input.copyTo(output)
} }
} }
return destFile return destFile.uri
} }
fun isTracked(): Boolean = fun isTracked(): Boolean =

View file

@ -483,7 +483,7 @@ class StatsDetailsPresenter(
*/ */
private fun LibraryManga.getLanguage(): String { private fun LibraryManga.getLanguage(): String {
val code = if (isLocal()) { val code = if (isLocal()) {
LocalSource.getMangaLang(this, context) LocalSource.getMangaLang(this)
} else { } else {
sourceManager.get(source)?.lang sourceManager.get(source)?.lang
} ?: return context.getString(R.string.unknown) } ?: return context.getString(R.string.unknown)

View file

@ -5,9 +5,12 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.net.toFile
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StorageManager
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
@ -86,6 +89,7 @@ class ReaderViewModel(
private val coverCache: CoverCache = Injekt.get(), private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(),
private val storageManager: StorageManager = Injekt.get(),
) : ViewModel() { ) : ViewModel() {
private val mutableState = MutableStateFlow(State()) 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. * 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 stream = page.stream!!
val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image") val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image")
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
directory.mkdirs()
val chapter = page.chapter.chapter val chapter = page.chapter.chapter
// Build destination file. // Build destination file.
@ -757,13 +759,13 @@ class ReaderViewModel(
"${manga.title} - ${chapter.preferredChapterName(context, manga, preferences)}".take(225), "${manga.title} - ${chapter.preferredChapterName(context, manga, preferences)}".take(225),
) + " - ${page.number}.${type.extension}" ) + " - ${page.number}.${type.extension}"
val destFile = File(directory, filename) val destFile = directory.createFile(filename)!!
stream().use { input -> stream().use { input ->
destFile.outputStream().use { output -> destFile.openOutputStream().use { output ->
input.copyTo(output) input.copyTo(output)
} }
} }
return destFile return destFile.uri
} }
/** /**
@ -814,22 +816,20 @@ class ReaderViewModel(
notifier.onClear() notifier.onClear()
// Pictures directory. // Pictures directory.
val baseDir = Environment.getExternalStorageDirectory().absolutePath + val baseDir = storageManager.getPagesDirectory()!!
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + context.getString(R.string.app_normalized_name)
val destDir = if (preferences.folderPerManga().get()) { val destDir = if (preferences.folderPerManga().get()) {
File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title)) baseDir.createDirectory(DiskUtil.buildValidFilename(manga.title))!!
} else { } else {
File(baseDir) baseDir
} }
// Copy file in background. // Copy file in background.
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
try { try {
val file = saveImage(page, destDir, manga) val uri = saveImage(page, destDir, manga)
DiskUtil.scanMedia(context, file) DiskUtil.scanMedia(context, uri.toFile())
notifier.onComplete(file) notifier.onComplete(uri.toFile())
eventChannel.send(Event.SavedImage(SaveImageResult.Success(file))) eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri.toFile())))
} catch (e: Exception) { } catch (e: Exception) {
notifier.onError(e.message) notifier.onError(e.message)
eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) eventChannel.send(Event.SavedImage(SaveImageResult.Error(e)))
@ -880,12 +880,11 @@ class ReaderViewModel(
val manga = manga ?: return val manga = manga ?: return
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image") val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!!
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
destDir.deleteRecursively() // Keep only the last shared file val uri = saveImage(page, destDir, manga)
val file = saveImage(page, destDir, manga) eventChannel.send(Event.ShareImage(uri.toFile(), page))
eventChannel.send(Event.ShareImage(file, page))
} }
} }
@ -919,7 +918,7 @@ class ReaderViewModel(
if (manga.isLocal()) { if (manga.isLocal()) {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
coverCache.deleteFromCache(manga) coverCache.deleteFromCache(manga)
LocalSource.updateCover(context, manga, stream()) LocalSource.updateCover(manga, stream())
R.string.cover_updated R.string.cover_updated
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } else {

View file

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.toTempFile
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
@ -48,7 +49,7 @@ class DownloadPageLoader(
} }
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> { 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() 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.isHidden
import eu.kanade.tachiyomi.util.view.moveRecyclerViewUp import eu.kanade.tachiyomi.util.view.moveRecyclerViewUp
import eu.kanade.tachiyomi.util.view.onAnimationsFinished 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.scrollViewWith
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.util.view.setStyle import eu.kanade.tachiyomi.util.view.setStyle
@ -421,7 +420,6 @@ class RecentsController(bundle: Bundle? = null) :
binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand() binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand()
} }
setPadding(binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == true) setPadding(binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == true)
requestFilePermissionsSafe(301, presenter.preferences)
binding.downloadBottomSheet.root.sheetBehavior?.isGestureInsetBottomIgnored = true binding.downloadBottomSheet.root.sheetBehavior?.isGestureInsetBottomIgnored = true
} }

View file

@ -4,16 +4,16 @@ import android.app.Activity
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import com.hippo.unifile.UniFile 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.R
import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupConst
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob 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.materialAlertDialog
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import uy.kohesive.injekt.injectLazy
class SettingsBackupController : SettingsController() { class SettingsDataController : SettingsController() {
/** /**
* Flags containing information of what to backup. * Flags containing information of what to backup.
*/ */
private var backupFlags = 0 private var backupFlags = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { internal val storagePreferences: StoragePreferences by injectLazy()
super.onViewCreated(view, savedInstanceState) internal val storageManager: StorageManager by injectLazy()
requestFilePermissionsSafe(500, preferences)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { 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 { preference {
key = "pref_create_backup" key = "pref_create_backup"
@ -75,7 +95,7 @@ class SettingsBackupController : SettingsController() {
(activity as? MainActivity)?.getExtensionUpdates(true) (activity as? MainActivity)?.getExtensionUpdates(true)
val intent = Intent(Intent.ACTION_GET_CONTENT) val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*" intent.setDataAndType(storageManager.getBackupsDirectory()!!.uri, "*/*")
val title = resources?.getString(R.string.select_backup_file) val title = resources?.getString(R.string.select_backup_file)
val chooser = Intent.createChooser(intent, title) val chooser = Intent.createChooser(intent, title)
startActivityForResult(chooser, CODE_BACKUP_RESTORE) startActivityForResult(chooser, CODE_BACKUP_RESTORE)
@ -107,29 +127,6 @@ class SettingsBackupController : SettingsController() {
true 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) { intListPreference(activity) {
bindTo(preferences.numberOfBackups()) bindTo(preferences.numberOfBackups())
titleRes = R.string.max_auto_backups titleRes = R.string.max_auto_backups
@ -165,22 +162,18 @@ class SettingsBackupController : SettingsController() {
} }
when (requestCode) { when (requestCode) {
CODE_BACKUP_DIR -> { CODE_DATA_DIR -> {
// Get UriPermission so it's possible to write files // Get UriPermission so it's possible to write files
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION Intent.FLAG_GRANT_WRITE_URI_PERMISSION
activity.contentResolver.takePersistableUriPermission(uri, flags) 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 -> { CODE_BACKUP_CREATE -> {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or doBackup(backupFlags, uri)
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
activity.contentResolver.takePersistableUriPermission(uri, flags)
activity.toast(R.string.creating_backup)
BackupCreatorJob.startNow(activity, uri, backupFlags)
} }
CODE_BACKUP_RESTORE -> { 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 backupFlags = flags
if (!picker) {
doBackup(backupFlags, storageManager.getBackupsDirectory()!!.uri)
return
}
try { try {
// Use Android's built-in file creator // Use Android's built-in file creator
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) 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_CREATE = 504
private const val CODE_BACKUP_RESTORE = 505 private const val CODE_BACKUP_RESTORE = 505

View file

@ -1,24 +1,14 @@
package eu.kanade.tachiyomi.ui.setting package eu.kanade.tachiyomi.ui.setting
import android.app.Activity
import android.content.Intent 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 androidx.preference.PreferenceScreen
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.bluelinelabs.conductor.Controller
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category 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.data.preference.changesIn
import eu.kanade.tachiyomi.util.system.withOriginalWidth import eu.kanade.tachiyomi.util.view.withFadeTransaction
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
class SettingsDownloadController : SettingsController() { class SettingsDownloadController : SettingsController() {
@ -31,14 +21,9 @@ class SettingsDownloadController : SettingsController() {
preference { preference {
key = Keys.downloadsDirectory key = Keys.downloadsDirectory
titleRes = R.string.download_location titleRes = R.string.download_location
onClick { onClick { navigateTo(SettingsDataController()) }
DownloadDirectoriesDialog(this@SettingsDownloadController).show()
}
preferences.downloadsDirectory().changesIn(viewScope) { path -> summary = "Moved to Data and Storage!"
val dir = UniFile.fromUri(context, path.toUri())!!
summary = dir.filePath ?: path
}
} }
switchPreference { switchPreference {
key = Keys.downloadOnlyOverWifi key = Keys.downloadOnlyOverWifi
@ -150,70 +135,9 @@ class SettingsDownloadController : SettingsController() {
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 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)!! private fun navigateTo(controller: Controller) {
preferences.downloadsDirectory().set(file.uri.toString()) router.pushController(controller.withFadeTransaction())
}
}
}
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
} }
} }

View file

@ -75,8 +75,8 @@ class SettingsMainController : SettingsController(), FloatingSearchInterface {
preference { preference {
iconRes = R.drawable.ic_backup_restore_24dp iconRes = R.drawable.ic_backup_restore_24dp
iconTint = tintColor iconTint = tintColor
titleRes = R.string.backup_and_restore titleRes = R.string.data_and_storage
onClick { navigateTo(SettingsBackupController()) } onClick { navigateTo(SettingsDataController()) }
} }
preference { preference {
iconRes = R.drawable.ic_security_24dp iconRes = R.drawable.ic_security_24dp

View file

@ -9,7 +9,7 @@ import androidx.preference.PreferenceGroup
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import eu.kanade.tachiyomi.ui.setting.SettingsAdvancedController import eu.kanade.tachiyomi.ui.setting.SettingsAdvancedController
import eu.kanade.tachiyomi.ui.setting.SettingsAppearanceController 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.SettingsBrowseController
import eu.kanade.tachiyomi.ui.setting.SettingsController import eu.kanade.tachiyomi.ui.setting.SettingsController
import eu.kanade.tachiyomi.ui.setting.SettingsDownloadController import eu.kanade.tachiyomi.ui.setting.SettingsDownloadController
@ -31,7 +31,7 @@ object SettingsSearchHelper {
*/ */
private val settingControllersList: List<KClass<out SettingsController>> = listOf( private val settingControllersList: List<KClass<out SettingsController>> = listOf(
SettingsAdvancedController::class, SettingsAdvancedController::class,
SettingsBackupController::class, SettingsDataController::class,
SettingsBrowseController::class, SettingsBrowseController::class,
SettingsDownloadController::class, SettingsDownloadController::class,
SettingsGeneralController::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.isCompose
import eu.kanade.tachiyomi.util.view.isControllerVisible import eu.kanade.tachiyomi.util.view.isControllerVisible
import eu.kanade.tachiyomi.util.view.onAnimationsFinished 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.scrollViewWith
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
@ -182,7 +181,6 @@ class BrowseController :
updateTitleAndMenu() updateTitleAndMenu()
} }
requestFilePermissionsSafe(301, preferences)
binding.bottomSheet.root.onCreate(this) binding.bottomSheet.root.onCreate(this)
basePreferences.extensionInstaller().changes() 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.fullAppBarHeight
import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.isControllerVisible 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.scrollViewWith
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
@ -182,7 +181,6 @@ open class BrowseSourceController(bundle: Bundle) :
} else { } else {
binding.progress.isVisible = true binding.progress.isVisible = true
} }
requestFilePermissionsSafe(301, preferences, presenter.source is LocalSource)
} }
override fun onDestroyView(view: View) { 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 package eu.kanade.tachiyomi.util.view
import android.Manifest
import android.animation.Animator import android.animation.Animator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.app.ActivityManager import android.app.ActivityManager
@ -8,12 +7,9 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -26,7 +22,6 @@ import androidx.annotation.CallSuper
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.math.MathUtils import androidx.core.math.MathUtils
@ -53,7 +48,6 @@ import com.bluelinelabs.conductor.Router
import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.RouterTransaction
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.databinding.MainActivityBinding
import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface 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.dpToPx
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.ignoredSystemInsets 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.rootWindowInsetsCompat
import eu.kanade.tachiyomi.util.system.toInt import eu.kanade.tachiyomi.util.system.toInt
import eu.kanade.tachiyomi.util.system.toast 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 { fun Controller.withFadeTransaction(): RouterTransaction {
return RouterTransaction.with(this) return RouterTransaction.with(this)
.pushChangeHandler(fadeTransactionHandler()) .pushChangeHandler(fadeTransactionHandler())

View file

@ -793,6 +793,10 @@
<string name="series_opens_new_chapters">Series shortcuts opens new chapters</string> <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> <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 --> <!-- Backup -->
<string name="backup">Backup</string> <string name="backup">Backup</string>
<string name="backup_and_restore">Backup and restore</string> <string name="backup_and_restore">Backup and restore</string>