chore: merge upstream changes.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
KaiserBh 2023-11-30 04:36:45 +11:00
commit 73130bc3dd
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
49 changed files with 387 additions and 358 deletions

View File

@ -164,7 +164,6 @@ dependencies {
implementation(compose.ui.tooling.preview) implementation(compose.ui.tooling.preview)
implementation(compose.ui.util) implementation(compose.ui.util)
implementation(compose.accompanist.webview) implementation(compose.accompanist.webview)
implementation(compose.accompanist.permissions)
implementation(compose.accompanist.systemuicontroller) implementation(compose.accompanist.systemuicontroller)
lintChecks(compose.lintchecks) lintChecks(compose.lintchecks)

View File

@ -7,9 +7,6 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<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" />
<!-- For background jobs --> <!-- For background jobs -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@ -39,7 +36,6 @@
android:largeHeap="true" android:largeHeap="true"
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Tachiyomi"> android:theme="@style/Theme.Tachiyomi">

View File

@ -27,7 +27,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri import androidx.core.net.toUri
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
@ -36,12 +35,12 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen
import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget
import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding
import eu.kanade.presentation.permissions.PermissionRequestHelper
import eu.kanade.presentation.util.relativeTimeSpanString import eu.kanade.presentation.util.relativeTimeSpanString
import eu.kanade.tachiyomi.data.backup.BackupCreateJob import eu.kanade.tachiyomi.data.backup.BackupCreateJob
import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.BackupRestoreJob import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.data.sync.SyncManager import eu.kanade.tachiyomi.data.sync.SyncManager
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
@ -77,8 +76,6 @@ object SettingsDataScreen : SearchableSettings {
val backupPreferences = Injekt.get<BackupPreferences>() val backupPreferences = Injekt.get<BackupPreferences>()
val storagePreferences = Injekt.get<StoragePreferences>() val storagePreferences = Injekt.get<StoragePreferences>()
PermissionRequestHelper.requestStoragePermission()
val syncPreferences = remember { Injekt.get<SyncPreferences>() } val syncPreferences = remember { Injekt.get<SyncPreferences>() }
val syncService by syncPreferences.syncService().collectAsState() val syncService by syncPreferences.syncService().collectAsState()
@ -107,8 +104,10 @@ object SettingsDataScreen : SearchableSettings {
context.contentResolver.takePersistableUriPermission(uri, flags) context.contentResolver.takePersistableUriPermission(uri, flags)
val file = UniFile.fromUri(context, uri) UniFile.fromUri(context, uri)?.let {
storageDirPref.set(file.uri.toString()) storageDirPref.set(it.uri.toString())
}
Injekt.get<DownloadCache>().invalidateCache()
} }
} }

View File

@ -1,8 +1,10 @@
package eu.kanade.presentation.more.stats package eu.kanade.presentation.more.stats
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -12,6 +14,7 @@ import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.more.stats.components.StatsItem import eu.kanade.presentation.more.stats.components.StatsItem
import eu.kanade.presentation.more.stats.components.StatsOverviewItem import eu.kanade.presentation.more.stats.components.StatsOverviewItem
@ -63,22 +66,24 @@ private fun OverviewSection(
.toDurationString(context, fallback = none) .toDurationString(context, fallback = none)
} }
StatsSection(MR.strings.label_overview_section) { StatsSection(MR.strings.label_overview_section) {
Row { Row(
modifier = Modifier.height(IntrinsicSize.Min),
) {
StatsOverviewItem( StatsOverviewItem(
title = data.libraryMangaCount.toString(), title = data.libraryMangaCount.toString(),
subtitle = stringResource(MR.strings.in_library), subtitle = stringResource(MR.strings.in_library),
icon = Icons.Outlined.CollectionsBookmark, icon = Icons.Outlined.CollectionsBookmark,
) )
StatsOverviewItem(
title = data.completedMangaCount.toString(),
subtitle = stringResource(MR.strings.label_completed_titles),
icon = Icons.Outlined.LocalLibrary,
)
StatsOverviewItem( StatsOverviewItem(
title = readDurationString, title = readDurationString,
subtitle = stringResource(MR.strings.label_read_duration), subtitle = stringResource(MR.strings.label_read_duration),
icon = Icons.Outlined.Schedule, icon = Icons.Outlined.Schedule,
) )
StatsOverviewItem(
title = data.completedMangaCount.toString(),
subtitle = stringResource(MR.strings.label_completed_titles),
icon = Icons.Outlined.LocalLibrary,
)
} }
} }
} }

View File

@ -3,6 +3,8 @@ package eu.kanade.presentation.more.stats.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -53,7 +55,9 @@ private fun RowScope.BaseStatsItem(
icon: ImageVector? = null, icon: ImageVector? = null,
) { ) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -74,6 +78,7 @@ private fun RowScope.BaseStatsItem(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (icon != null) { if (icon != null) {
Spacer(modifier = Modifier.weight(1f))
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,

View File

@ -1,20 +0,0 @@
package eu.kanade.presentation.permissions
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.google.accompanist.permissions.rememberPermissionState
/**
* Launches request for [Manifest.permission.WRITE_EXTERNAL_STORAGE] permission
*/
object PermissionRequestHelper {
@Composable
fun requestStoragePermission() {
val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
LaunchedEffect(Unit) {
permissionState.launchPermissionRequest()
}
}
}

View File

@ -87,8 +87,8 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
if (packageName != process) WebView.setDataDirectorySuffix(process) if (packageName != process) WebView.setDataDirectorySuffix(process)
} }
Injekt.importModule(AppModule(this))
Injekt.importModule(PreferenceModule(this)) Injekt.importModule(PreferenceModule(this))
Injekt.importModule(AppModule(this))
Injekt.importModule(DomainModule()) Injekt.importModule(DomainModule())
setupAcra() setupAcra()

View File

@ -21,7 +21,7 @@ import eu.kanade.tachiyomi.util.system.workManager
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.storage.service.StorageManager
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.Date import java.util.Date
@ -43,6 +43,8 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: getAutomaticBackupLocation() ?: getAutomaticBackupLocation()
?: return Result.failure()
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupCreateFlags.AutomaticDefaults) val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupCreateFlags.AutomaticDefaults)
try { try {
@ -56,7 +58,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
if (isAutoBackup) { if (isAutoBackup) {
backupPreferences.lastAutoBackupTimestamp().set(Date().time) backupPreferences.lastAutoBackupTimestamp().set(Date().time)
} else { } else {
notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!)
} }
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
@ -75,13 +77,9 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
) )
} }
private fun getAutomaticBackupLocation(): Uri { private fun getAutomaticBackupLocation(): Uri? {
val storagePreferences = Injekt.get<StoragePreferences>() val storageManager = Injekt.get<StorageManager>()
return storagePreferences.baseStorageDirectory().get().let { return storageManager.getAutomaticBackupsDirectory()?.uri
val dir = UniFile.fromUri(context, it.toUri())
.createDirectory(StoragePreferences.BACKUP_DIR)
dir.uri
}
} }
companion object { companion object {

View File

@ -93,17 +93,16 @@ class BackupCreator(
if (isAutoBackup) { if (isAutoBackup) {
// Get dir of file and create // Get dir of file and create
val dir = UniFile.fromUri(context, uri) val dir = UniFile.fromUri(context, uri)
.createDirectory("automatic")
// Delete older backups // Delete older backups
dir.listFiles { _, filename -> Backup.filenameRegex.matches(filename) } dir?.listFiles { _, filename -> Backup.filenameRegex.matches(filename) }
.orEmpty() .orEmpty()
.sortedByDescending { it.name } .sortedByDescending { it.name }
.drop(MAX_AUTO_BACKUPS - 1) .drop(MAX_AUTO_BACKUPS - 1)
.forEach { it.delete() } .forEach { it.delete() }
// Create new file to place backup // Create new file to place backup
dir.createFile(Backup.getFilename()) dir?.createFile(Backup.getFilename())
} else { } else {
UniFile.fromUri(context, uri) UniFile.fromUri(context, uri)
} }

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.download
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
@ -19,6 +18,7 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
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 kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
@ -40,6 +40,8 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.storage.extension
import tachiyomi.core.storage.nameWithoutExtension
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNonCancellable import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
@ -64,7 +66,7 @@ class DownloadCache(
private val provider: DownloadProvider = Injekt.get(), private val provider: DownloadProvider = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val storagePreferences: StoragePreferences = Injekt.get(), storagePreferences: StoragePreferences = Injekt.get(),
) { ) {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
@ -95,16 +97,9 @@ class DownloadCache(
get() = File(context.cacheDir, "dl_index_cache") get() = File(context.cacheDir, "dl_index_cache")
private val rootDownloadsDirLock = Mutex() private val rootDownloadsDirLock = Mutex()
private var rootDownloadsDir = RootDirectory(getDirectoryFromPreference()) private var rootDownloadsDir = RootDirectory(provider.downloadsDir)
init { init {
storagePreferences.baseStorageDirectory().changes()
.onEach {
rootDownloadsDir = RootDirectory(getDirectoryFromPreference())
invalidateCache()
}
.launchIn(scope)
// Attempt to read cache file // Attempt to read cache file
scope.launch { scope.launch {
rootDownloadsDirLock.withLock { rootDownloadsDirLock.withLock {
@ -119,6 +114,14 @@ class DownloadCache(
} }
} }
} }
storagePreferences.baseStorageDirectory().changes()
.drop(1)
.onEach {
rootDownloadsDir = RootDirectory(provider.downloadsDir)
invalidateCache()
}
.launchIn(scope)
} }
/** /**
@ -293,14 +296,6 @@ class DownloadCache(
renewalJob?.cancel() renewalJob?.cancel()
} }
/**
* Returns the downloads directory from the user's preferences.
*/
private fun getDirectoryFromPreference(): UniFile {
return UniFile.fromUri(context, storagePreferences.baseStorageDirectory().get().toUri())
.createDirectory(StoragePreferences.DOWNLOADS_DIR)
}
/** /**
* Renews the downloads cache. * Renews the downloads cache.
*/ */
@ -332,7 +327,7 @@ class DownloadCache(
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
rootDownloadsDirLock.withLock { rootDownloadsDirLock.withLock {
val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty() val sourceDirs = rootDownloadsDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() } .filter { it.isDirectory && !it.name.isNullOrBlank() }
.mapNotNull { dir -> .mapNotNull { dir ->
val sourceId = sourceMap[dir.name!!.lowercase()] val sourceId = sourceMap[dir.name!!.lowercase()]
@ -345,12 +340,12 @@ class DownloadCache(
sourceDirs.values sourceDirs.values
.map { sourceDir -> .map { sourceDir ->
async { async {
sourceDir.mangaDirs = sourceDir.dir.listFiles().orEmpty() sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() } .filter { it.isDirectory && !it.name.isNullOrBlank() }
.associate { it.name!! to MangaDirectory(it) } .associate { it.name!! to MangaDirectory(it) }
sourceDir.mangaDirs.values.forEach { mangaDir -> sourceDir.mangaDirs.values.forEach { mangaDir ->
val chapterDirs = mangaDir.dir.listFiles().orEmpty() val chapterDirs = mangaDir.dir?.listFiles().orEmpty()
.mapNotNull { .mapNotNull {
when { when {
// Ignore incomplete downloads // Ignore incomplete downloads
@ -358,8 +353,7 @@ class DownloadCache(
// Folder of images // Folder of images
it.isDirectory -> it.name it.isDirectory -> it.name
// CBZ files // CBZ files
it.isFile && it.name?.endsWith(".cbz") == true -> it.isFile && it.extension == "cbz" -> it.nameWithoutExtension
it.name!!.substringBeforeLast(".cbz")
// Anything else is irrelevant // Anything else is irrelevant
else -> null else -> null
} }
@ -427,7 +421,7 @@ class DownloadCache(
@Serializable @Serializable
private class RootDirectory( private class RootDirectory(
@Serializable(with = UniFileAsStringSerializer::class) @Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile, val dir: UniFile?,
var sourceDirs: Map<Long, SourceDirectory> = mapOf(), var sourceDirs: Map<Long, SourceDirectory> = mapOf(),
) )
@ -437,7 +431,7 @@ private class RootDirectory(
@Serializable @Serializable
private class SourceDirectory( private class SourceDirectory(
@Serializable(with = UniFileAsStringSerializer::class) @Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile, val dir: UniFile?,
var mangaDirs: Map<String, MangaDirectory> = mapOf(), var mangaDirs: Map<String, MangaDirectory> = mapOf(),
) )
@ -447,17 +441,26 @@ private class SourceDirectory(
@Serializable @Serializable
private class MangaDirectory( private class MangaDirectory(
@Serializable(with = UniFileAsStringSerializer::class) @Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile, val dir: UniFile?,
var chapterDirs: MutableSet<String> = mutableSetOf(), var chapterDirs: MutableSet<String> = mutableSetOf(),
) )
private object UniFileAsStringSerializer : KSerializer<UniFile> { private object UniFileAsStringSerializer : KSerializer<UniFile?> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UniFile", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UniFile", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UniFile) { override fun serialize(encoder: Encoder, value: UniFile?) {
return encoder.encodeString(value.uri.toString()) return if (value == null) {
} encoder.encodeNull()
override fun deserialize(decoder: Decoder): UniFile { } else {
return UniFile.fromUri(Injekt.get<Application>(), Uri.parse(decoder.decodeString())) encoder.encodeString(value.uri.toString())
}
}
override fun deserialize(decoder: Decoder): UniFile? {
return if (decoder.decodeNotNullMark()) {
UniFile.fromUri(Injekt.get<Application>(), Uri.parse(decoder.decodeString()))
} else {
decoder.decodeNull()
}
} }
} }

View File

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.core.storage.extension
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
@ -340,7 +341,7 @@ class DownloadManager(
.firstOrNull() ?: return .firstOrNull() ?: return
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator) var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator)
if (oldDownload.isFile && oldDownload.name?.endsWith(".cbz") == true) { if (oldDownload.isFile && oldDownload.extension == "cbz") {
newName += ".cbz" newName += ".cbz"
} }

View File

@ -1,19 +1,15 @@
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 eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -26,30 +22,11 @@ import uy.kohesive.injekt.api.get
*/ */
class DownloadProvider( class DownloadProvider(
private val context: Context, private val context: Context,
private val storagePreferences: StoragePreferences = Injekt.get(), private val storageManager: StorageManager = Injekt.get(),
) { ) {
private val scope = MainScope() val downloadsDir: UniFile?
get() = storageManager.getDownloadsDirectory()
/**
* The root directory for downloads.
*/
private var downloadsDir = setDownloadsLocation()
init {
storagePreferences.baseStorageDirectory().changes()
.onEach { downloadsDir = setDownloadsLocation() }
.launchIn(scope)
}
private fun setDownloadsLocation(): UniFile {
return storagePreferences.baseStorageDirectory().get().let {
val dir = UniFile.fromUri(context, it.toUri())
.createDirectory(StoragePreferences.DOWNLOADS_DIR)
DiskUtil.createNoMediaFile(dir, context)
dir
}
}
/** /**
* Returns the download directory for a manga. For internal use only. * Returns the download directory for a manga. For internal use only.
@ -59,12 +36,12 @@ class DownloadProvider(
*/ */
internal fun getMangaDir(mangaTitle: String, source: Source): UniFile { internal fun getMangaDir(mangaTitle: String, source: Source): UniFile {
try { try {
return downloadsDir return downloadsDir!!
.createDirectory(getSourceDirName(source)) .createDirectory(getSourceDirName(source))!!
.createDirectory(getMangaDirName(mangaTitle)) .createDirectory(getMangaDirName(mangaTitle))!!
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Invalid download directory" } logcat(LogPriority.ERROR, e) { "Invalid download directory" }
throw Exception(context.stringResource(MR.strings.invalid_location, downloadsDir)) throw Exception(context.stringResource(MR.strings.invalid_location, downloadsDir ?: ""))
} }
} }
@ -74,7 +51,7 @@ class DownloadProvider(
* @param source the source to query. * @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)
} }
/** /**
@ -114,7 +91,7 @@ class DownloadProvider(
val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList() val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter -> return mangaDir to chapters.mapNotNull { chapter ->
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence() getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence()
.mapNotNull { mangaDir.findFile(it) } .mapNotNull { mangaDir.findFile(it, true) }
.firstOrNull() .firstOrNull()
} }
} }

View File

@ -43,6 +43,7 @@ import okhttp3.Response
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.storage.extension
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNow import tachiyomi.core.util.lang.launchNow
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
@ -334,7 +335,7 @@ class Downloader(
} }
val chapterDirname = provider.getChapterDirName(download.chapter.name, download.chapter.scanlator) val chapterDirname = provider.getChapterDirName(download.chapter.name, download.chapter.scanlator)
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
try { try {
// If the page list already exists, start from the file // If the page list already exists, start from the file
@ -353,7 +354,7 @@ class Downloader(
// Delete all temporary (unfinished) files // Delete all temporary (unfinished) files
tmpDir.listFiles() tmpDir.listFiles()
?.filter { it.name!!.endsWith(".tmp") } ?.filter { it.extension == "tmp" }
?.forEach { it.delete() } ?.forEach { it.delete() }
download.status = Download.State.DOWNLOADING download.status = Download.State.DOWNLOADING
@ -479,7 +480,7 @@ class Downloader(
page.progress = 0 page.progress = 0
return flow { return flow {
val response = source.getImage(page) val response = source.getImage(page)
val file = tmpDir.createFile("$filename.tmp") val file = tmpDir.createFile("$filename.tmp")!!
try { try {
response.body.source().saveTo(file.openOutputStream()) response.body.source().saveTo(file.openOutputStream())
val extension = getImageExtension(response, file) val extension = getImageExtension(response, file)
@ -511,7 +512,7 @@ class Downloader(
* @param filename the filename of the image. * @param filename the filename of the image.
*/ */
private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile { private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile {
val tmpFile = tmpDir.createFile("$filename.tmp") val tmpFile = tmpDir.createFile("$filename.tmp")!!
cacheFile.inputStream().use { input -> cacheFile.inputStream().use { input ->
tmpFile.openOutputStream().use { output -> tmpFile.openOutputStream().use { output ->
input.copyTo(output) input.copyTo(output)
@ -602,7 +603,7 @@ class Downloader(
dirname: String, dirname: String,
tmpDir: UniFile, tmpDir: UniFile,
) { ) {
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")!!
ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut -> ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut ->
zipOut.setMethod(ZipEntry.STORED) zipOut.setMethod(ZipEntry.STORED)
@ -641,8 +642,8 @@ class Downloader(
val categories = getCategories.await(manga.id).map { it.name.trim() }.takeUnless { it.isEmpty() } val categories = getCategories.await(manga.id).map { it.name.trim() }.takeUnless { it.isEmpty() }
val comicInfo = getComicInfo(manga, chapter, chapterUrl, categories) val comicInfo = getComicInfo(manga, chapter, chapterUrl, categories)
// Remove the old file // Remove the old file
dir.findFile(COMIC_INFO_FILE)?.delete() dir.findFile(COMIC_INFO_FILE, true)?.delete()
dir.createFile(COMIC_INFO_FILE).openOutputStream().use { dir.createFile(COMIC_INFO_FILE)!!.openOutputStream().use {
val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo) val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo)
it.write(comicInfoString.toByteArray()) it.write(comicInfoString.toByteArray())
} }

View File

@ -33,12 +33,14 @@ import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode
import java.text.NumberFormat import java.text.NumberFormat
class LibraryUpdateNotifier(private val context: Context) { class LibraryUpdateNotifier(private val context: Context) {
private val preferences: SecurityPreferences by injectLazy() private val preferences: SecurityPreferences by injectLazy()
private val percentFormatter = NumberFormat.getPercentInstance().apply { private val percentFormatter = NumberFormat.getPercentInstance().apply {
roundingMode = RoundingMode.DOWN
maximumFractionDigits = 0 maximumFractionDigits = 0
} }
@ -78,12 +80,6 @@ class LibraryUpdateNotifier(private val context: Context) {
* @param total the total progress. * @param total the total progress.
*/ */
fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) { fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) {
if (preferences.hideNotificationContent().get()) {
progressNotificationBuilder
.setContentTitle(context.stringResource(MR.strings.notification_check_updates))
.setContentText("($current/$total)")
} else {
val updatingText = manga.joinToString("\n") { it.title.chop(40) }
progressNotificationBuilder progressNotificationBuilder
.setContentTitle( .setContentTitle(
context.stringResource( context.stringResource(
@ -91,7 +87,10 @@ class LibraryUpdateNotifier(private val context: Context) {
percentFormatter.format(current.toFloat() / total), percentFormatter.format(current.toFloat() / total),
), ),
) )
.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
if (!preferences.hideNotificationContent().get()) {
val updatingText = manga.joinToString("\n") { it.title.chop(40) }
progressNotificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
} }
context.notify( context.notify(

View File

@ -4,6 +4,7 @@ import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.lang.htmlDecode
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -25,7 +26,7 @@ data class ALManga(
title = title_user_pref title = title_user_pref
total_chapters = this@ALManga.total_chapters total_chapters = this@ALManga.total_chapters
cover_url = image_url_lge cover_url = image_url_lge
summary = description ?: "" summary = description?.htmlDecode() ?: ""
tracking_url = AnilistApi.mangaUrl(media_id) tracking_url = AnilistApi.mangaUrl(media_id)
publishing_status = this@ALManga.publishing_status publishing_status = this@ALManga.publishing_status
publishing_type = format publishing_type = format

View File

@ -26,6 +26,7 @@ import kotlinx.serialization.json.Json
import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import tachiyomi.core.storage.AndroidStorageFolderProvider
import tachiyomi.data.AndroidDatabaseHandler import tachiyomi.data.AndroidDatabaseHandler
import tachiyomi.data.Database import tachiyomi.data.Database
import tachiyomi.data.DatabaseHandler import tachiyomi.data.DatabaseHandler
@ -35,6 +36,7 @@ import tachiyomi.data.Mangas
import tachiyomi.data.StringListColumnAdapter import tachiyomi.data.StringListColumnAdapter
import tachiyomi.data.UpdateStrategyColumnAdapter import tachiyomi.data.UpdateStrategyColumnAdapter
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.io.LocalSourceFileSystem import tachiyomi.source.local.io.LocalSourceFileSystem
import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektModule
@ -124,8 +126,10 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { ImageSaver(app) } addSingletonFactory { ImageSaver(app) }
addSingletonFactory { LocalSourceFileSystem(app) } addSingletonFactory { AndroidStorageFolderProvider(app) }
addSingletonFactory { LocalSourceFileSystem(get()) }
addSingletonFactory { LocalCoverManager(app, get()) } addSingletonFactory { LocalCoverManager(app, get()) }
addSingletonFactory { StorageManager(app, get()) }
// Asynchronously init expensive components for a faster cold start // Asynchronously init expensive components for a faster cold start
ContextCompat.getMainExecutor(app).execute { ContextCompat.getMainExecutor(app).execute {

View File

@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.system.isDevFlavor import eu.kanade.tachiyomi.util.system.isDevFlavor
import tachiyomi.core.preference.AndroidPreferenceStore import tachiyomi.core.preference.AndroidPreferenceStore
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.provider.AndroidStorageFolderProvider import tachiyomi.core.storage.AndroidStorageFolderProvider
import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
@ -55,9 +55,6 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory { addSingletonFactory {
BackupPreferences(get()) BackupPreferences(get())
} }
addSingletonFactory {
AndroidStorageFolderProvider(app)
}
addSingletonFactory { addSingletonFactory {
StoragePreferences( StoragePreferences(
folderProvider = get<AndroidStorageFolderProvider>(), folderProvider = get<AndroidStorageFolderProvider>(),

View File

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.ui.security.UnlockActivity import eu.kanade.tachiyomi.ui.security.UnlockActivity
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil import eu.kanade.tachiyomi.util.system.AuthenticatorUtil
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported
import eu.kanade.tachiyomi.util.system.overridePendingTransitionCompat
import eu.kanade.tachiyomi.util.view.setSecureScreen import eu.kanade.tachiyomi.util.view.setSecureScreen
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -106,7 +107,7 @@ class SecureActivityDelegateImpl : SecureActivityDelegate, DefaultLifecycleObser
if (activity.isAuthenticationSupported()) { if (activity.isAuthenticationSupported()) {
if (!SecureActivityDelegate.requireUnlock) return if (!SecureActivityDelegate.requireUnlock) return
activity.startActivity(Intent(activity, UnlockActivity::class.java)) activity.startActivity(Intent(activity, UnlockActivity::class.java))
activity.overridePendingTransition(0, 0) activity.overridePendingTransitionCompat(0, 0)
} else { } else {
securityPreferences.useAuthenticator().set(false) securityPreferences.useAuthenticator().set(false)
} }

View File

@ -13,7 +13,6 @@ import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions import cafe.adriel.voyager.navigator.tab.TabOptions
import eu.kanade.presentation.components.TabbedScreen import eu.kanade.presentation.components.TabbedScreen
import eu.kanade.presentation.permissions.PermissionRequestHelper
import eu.kanade.presentation.util.Tab import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
@ -66,9 +65,6 @@ data class BrowseTab(
onChangeSearchQuery = extensionsScreenModel::search, onChangeSearchQuery = extensionsScreenModel::search,
) )
// For local source
PermissionRequestHelper.requestStoragePermission()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
(context as? MainActivity)?.ready = true (context as? MainActivity)?.ready = true
} }

View File

@ -66,6 +66,7 @@ object HomeScreen : Screen() {
private val showBottomNavEvent = Channel<Boolean>() private val showBottomNavEvent = Channel<Boolean>()
private const val TabFadeDuration = 200 private const val TabFadeDuration = 200
private const val TabNavigatorKey = "HomeTabs"
private val tabs = listOf( private val tabs = listOf(
LibraryTab, LibraryTab,
@ -80,6 +81,7 @@ object HomeScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
TabNavigator( TabNavigator(
tab = LibraryTab, tab = LibraryTab,
key = TabNavigatorKey,
) { tabNavigator -> ) { tabNavigator ->
// Provide usable navigator to content screen // Provide usable navigator to content screen
CompositionLocalProvider(LocalNavigator provides navigator) { CompositionLocalProvider(LocalNavigator provides navigator) {

View File

@ -70,6 +70,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.hasDisplayCutout import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import eu.kanade.tachiyomi.util.system.isNightMode import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.system.overridePendingTransitionCompat
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
@ -138,7 +139,7 @@ class ReaderActivity : BaseActivity() {
*/ */
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
registerSecureActivity(this) registerSecureActivity(this)
overridePendingTransition(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit) overridePendingTransitionCompat(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -269,7 +270,7 @@ class ReaderActivity : BaseActivity() {
override fun finish() { override fun finish() {
viewModel.onActivityFinish() viewModel.onActivityFinish()
super.finish() super.finish()
overridePendingTransition(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit) overridePendingTransitionCompat(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit)
} }
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.core.storage.toTempFile
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -88,13 +89,13 @@ class ChapterLoader(
source is LocalSource -> source.getFormat(chapter.chapter).let { format -> source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) { when (format) {
is Format.Directory -> DirectoryPageLoader(format.file) is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(format.file) is Format.Zip -> ZipPageLoader(format.file.toTempFile(context))
is Format.Rar -> try { is Format.Rar -> try {
RarPageLoader(format.file) RarPageLoader(format.file.toTempFile(context))
} catch (e: UnsupportedRarV5Exception) { } catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error)) error(context.stringResource(MR.strings.loader_rar5_error))
} }
is Format.Epub -> EpubPageLoader(format.file) is Format.Epub -> EpubPageLoader(format.file.toTempFile(context))
} }
} }
source is HttpSource -> HttpPageLoader(chapter, source) source is HttpSource -> HttpPageLoader(chapter, source)

View File

@ -1,25 +1,24 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.ImageUtil
import java.io.File
import java.io.FileInputStream
/** /**
* Loader used to load a chapter from a directory given on [file]. * Loader used to load a chapter from a directory given on [file].
*/ */
internal class DirectoryPageLoader(val file: File) : PageLoader() { internal class DirectoryPageLoader(val file: UniFile) : PageLoader() {
override var isLocal: Boolean = true override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
return file.listFiles() return file.listFiles()
?.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } ?.filter { !it.isDirectory && ImageUtil.isImage(it.name) { it.openInputStream() } }
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } ?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) }
?.mapIndexed { i, file -> ?.mapIndexed { i, file ->
val streamFn = { FileInputStream(file) } val streamFn = { file.openInputStream() }
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = streamFn
status = Page.State.READY status = Page.State.READY

View File

@ -10,9 +10,9 @@ 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 tachiyomi.core.storage.toTempFile
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File
/** /**
* Loader used to load a chapter from the downloaded chapters. * Loader used to load a chapter from the downloaded chapters.
@ -47,7 +47,7 @@ internal 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

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.overridePendingTransitionCompat
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
@ -35,7 +36,7 @@ class WebViewActivity : BaseActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
overridePendingTransition(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit) overridePendingTransitionCompat(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!WebViewUtil.supportsWebView(this)) { if (!WebViewUtil.supportsWebView(this)) {
@ -77,7 +78,7 @@ class WebViewActivity : BaseActivity() {
override fun finish() { override fun finish() {
super.finish() super.finish()
overridePendingTransition(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit) overridePendingTransitionCompat(R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_exit)
} }
private fun shareWebpage(url: String) { private fun shareWebpage(url: String) {

View File

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.util.system
import android.app.Activity
import android.os.Build
import androidx.annotation.AnimRes
fun Activity.overridePendingTransitionCompat(@AnimRes enterAnim: Int, @AnimRes exitAnim: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
overrideActivityTransition(Activity.OVERRIDE_TRANSITION_OPEN, enterAnim, exitAnim)
} else {
@Suppress("DEPRECATION")
overridePendingTransition(enterAnim, exitAnim)
}
}

View File

@ -165,7 +165,7 @@ fun Context.createReaderThemeContext(): Context {
* @return document size of [uri] or null if size can't be obtained * @return document size of [uri] or null if size can't be obtained
*/ */
fun Context.getUriSize(uri: Uri): Long? { fun Context.getUriSize(uri: Uri): Long? {
return UniFile.fromUri(this, uri).length().takeIf { it >= 0 } return UniFile.fromUri(this, uri)?.length()?.takeIf { it >= 0 }
} }
/** /**

View File

@ -1,6 +1,6 @@
object AndroidConfig { object AndroidConfig {
const val compileSdk = 34 const val compileSdk = 34
const val minSdk = 23 const val minSdk = 23
const val targetSdk = 29 const val targetSdk = 30
const val ndk = "22.1.7171670" const val ndk = "22.1.7171670"
} }

View File

@ -3,9 +3,7 @@ package eu.kanade.tachiyomi.util.storage
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.core.content.ContextCompat
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import java.io.File import java.io.File
@ -64,23 +62,6 @@ object DiskUtil {
} }
} }
/**
* Returns the root folders of all the available external storages.
*/
fun getExternalStorages(context: Context): List<File> {
return ContextCompat.getExternalFilesDirs(context, null)
.filterNotNull()
.mapNotNull {
val file = File(it.absolutePath.substringBefore("/Android/"))
val state = Environment.getExternalStorageState(file)
if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) {
file
} else {
null
}
}
}
/** /**
* Don't display downloaded chapters in gallery apps creating `.nomedia`. * Don't display downloaded chapters in gallery apps creating `.nomedia`.
*/ */

View File

@ -1,4 +1,4 @@
package tachiyomi.core.provider package tachiyomi.core.storage
import android.content.Context import android.content.Context
import android.os.Environment import android.os.Environment

View File

@ -1,4 +1,4 @@
package tachiyomi.core.provider package tachiyomi.core.storage
import java.io.File import java.io.File

View File

@ -0,0 +1,38 @@
package tachiyomi.core.storage
import android.content.Context
import android.os.Build
import android.os.FileUtils
import com.hippo.unifile.UniFile
import java.io.BufferedOutputStream
import java.io.File
val UniFile.extension: String?
get() = name?.substringAfterLast('.')
val UniFile.nameWithoutExtension: String?
get() = name?.substringBeforeLast('.')
fun UniFile.toTempFile(context: Context): File {
val inputStream = context.contentResolver.openInputStream(uri)!!
val tempFile = File.createTempFile(
nameWithoutExtension.orEmpty(),
null,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FileUtils.copy(inputStream, tempFile.outputStream())
} else {
BufferedOutputStream(tempFile.outputStream()).use { tmpOut ->
inputStream.use { input ->
val buffer = ByteArray(8192)
var count: Int
while (input.read(buffer).also { count = it } > 0) {
tmpOut.write(buffer, 0, count)
}
}
}
}
return tempFile
}

View File

@ -37,7 +37,9 @@ import kotlin.math.min
object ImageUtil { object ImageUtil {
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean { fun isImage(name: String?, openStream: (() -> InputStream)? = null): Boolean {
if (name == null) return false
val contentType = try { val contentType = try {
URLConnection.guessContentTypeFromName(name) URLConnection.guessContentTypeFromName(name)
} catch (e: Exception) { } catch (e: Exception) {
@ -243,7 +245,7 @@ object ImageUtil {
// Remove pre-existing split if exists (this split shouldn't exist under normal circumstances) // Remove pre-existing split if exists (this split shouldn't exist under normal circumstances)
tmpDir.findFile(splitImageName)?.delete() tmpDir.findFile(splitImageName)?.delete()
val splitFile = tmpDir.createFile(splitImageName) val splitFile = tmpDir.createFile(splitImageName)!!
val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset) val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset)

View File

@ -19,6 +19,8 @@ dependencies {
implementation(platform(kotlinx.coroutines.bom)) implementation(platform(kotlinx.coroutines.bom))
implementation(kotlinx.bundles.coroutines) implementation(kotlinx.bundles.coroutines)
implementation(libs.unifile)
api(libs.sqldelight.android.paging) api(libs.sqldelight.android.paging)
testImplementation(libs.bundles.test) testImplementation(libs.bundles.test)

View File

@ -0,0 +1,54 @@
package tachiyomi.domain.storage.service
import android.content.Context
import androidx.core.net.toUri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class StorageManager(
private val context: Context,
storagePreferences: StoragePreferences,
) {
private val scope = CoroutineScope(Dispatchers.IO)
private var baseDir: UniFile? = storagePreferences.baseStorageDirectory().get().let(::getBaseDir)
init {
storagePreferences.baseStorageDirectory().changes()
.onEach { baseDir = getBaseDir(it) }
.launchIn(scope)
}
private fun getBaseDir(path: String): UniFile? {
val file = UniFile.fromUri(context, path.toUri())
return file.takeIf { it?.exists() == true }?.also { parent ->
parent.createDirectory(AUTOMATIC_BACKUPS_PATH)
parent.createDirectory(LOCAL_SOURCE_PATH)
parent.createDirectory(DOWNLOADS_PATH).also {
DiskUtil.createNoMediaFile(it, context)
}
}
}
fun getAutomaticBackupsDirectory(): UniFile? {
return baseDir?.createDirectory(AUTOMATIC_BACKUPS_PATH)
}
fun getDownloadsDirectory(): UniFile? {
return baseDir?.createDirectory(DOWNLOADS_PATH)
}
fun getLocalSourceDirectory(): UniFile? {
return baseDir?.createDirectory(LOCAL_SOURCE_PATH)
}
}
private const val AUTOMATIC_BACKUPS_PATH = "autobackup"
private const val DOWNLOADS_PATH = "downloads"
private const val LOCAL_SOURCE_PATH = "local"

View File

@ -1,7 +1,7 @@
package tachiyomi.domain.storage.service package tachiyomi.domain.storage.service
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.provider.FolderProvider import tachiyomi.core.storage.FolderProvider
class StoragePreferences( class StoragePreferences(
private val folderProvider: FolderProvider, private val folderProvider: FolderProvider,
@ -9,9 +9,4 @@ class StoragePreferences(
) { ) {
fun baseStorageDirectory() = preferenceStore.getString("storage_dir", folderProvider.path()) fun baseStorageDirectory() = preferenceStore.getString("storage_dir", folderProvider.path())
companion object {
const val BACKUP_DIR = "backup"
const val DOWNLOADS_DIR = "downloads"
}
} }

View File

@ -1,5 +1,5 @@
[versions] [versions]
agp_version = "8.1.3" agp_version = "8.1.4"
lifecycle_version = "2.6.2" lifecycle_version = "2.6.2"
paging_version = "3.2.1" paging_version = "3.2.1"

View File

@ -1,6 +1,6 @@
[versions] [versions]
compiler = "1.5.4" compiler = "1.5.4"
compose-bom = "2023.12.00-alpha01" compose-bom = "2023.12.00-alpha02"
accompanist = "0.33.2-alpha" accompanist = "0.33.2-alpha"
[libraries] [libraries]
@ -22,7 +22,6 @@ material-core = { module = "androidx.compose.material:material" }
glance = "androidx.glance:glance-appwidget:1.0.0" glance = "androidx.glance:glance-appwidget:1.0.0"
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
lintchecks = { module = "com.slack.lint.compose:compose-lint-checks", version = "1.2.0" } lintchecks = { module = "com.slack.lint.compose:compose-lint-checks", version = "1.2.0" }

View File

@ -30,7 +30,7 @@ quickjs-android = "app.cash.quickjs:quickjs-android:0.9.2"
jsoup = "org.jsoup:jsoup:1.16.2" jsoup = "org.jsoup:jsoup:1.16.2"
disklrucache = "com.jakewharton:disklrucache:2.0.2" disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:17bec43" unifile = "com.github.tachiyomiorg:unifile:7c257e1c64"
junrar = "com.github.junrar:junrar:7.5.5" junrar = "com.github.junrar:junrar:7.5.5"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }

View File

@ -839,7 +839,6 @@
<string name="download_queue_size_warning">Warning: large bulk downloads may lead to sources becoming slower and/or blocking Tachiyomi. Tap to learn more.</string> <string name="download_queue_size_warning">Warning: large bulk downloads may lead to sources becoming slower and/or blocking Tachiyomi. Tap to learn more.</string>
<!-- Library update service notifications --> <!-- Library update service notifications -->
<string name="notification_check_updates">Checking for new chapters</string>
<string name="notification_updating_progress">Updating library… (%s)</string> <string name="notification_updating_progress">Updating library… (%s)</string>
<string name="notification_size_warning">Large updates harm sources and may lead to slower updates and also increased battery usage. Tap to learn more.</string> <string name="notification_size_warning">Large updates harm sources and may lead to slower updates and also increased battery usage. Tap to learn more.</string>
<string name="notification_new_chapters">New chapters found</string> <string name="notification_new_chapters">New chapters found</string>

View File

@ -4,6 +4,7 @@ import androidx.compose.animation.core.animate
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -46,6 +47,7 @@ fun PullRefresh(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val state = rememberPullToRefreshState( val state = rememberPullToRefreshState(
initialRefreshing = refreshing,
extraVerticalOffset = indicatorPadding.calculateTopPadding(), extraVerticalOffset = indicatorPadding.calculateTopPadding(),
enabled = enabled, enabled = enabled,
) )
@ -84,12 +86,15 @@ fun PullRefresh(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(contentPadding), .padding(contentPadding),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@Composable @Composable
private fun rememberPullToRefreshState( private fun rememberPullToRefreshState(
initialRefreshing: Boolean,
extraVerticalOffset: Dp, extraVerticalOffset: Dp,
positionalThreshold: Dp = 64.dp, positionalThreshold: Dp = 64.dp,
enabled: () -> Boolean = { true }, enabled: () -> Boolean = { true },
@ -108,7 +113,7 @@ private fun rememberPullToRefreshState(
), ),
) { ) {
PullToRefreshStateImpl( PullToRefreshStateImpl(
initialRefreshing = false, initialRefreshing = initialRefreshing,
extraVerticalOffset = extraVerticalOffsetPx, extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx, positionalThreshold = positionalThresholdPx,
enabled = enabled, enabled = enabled,
@ -133,18 +138,21 @@ private class PullToRefreshStateImpl(
) : PullToRefreshState { ) : PullToRefreshState {
override val progress get() = adjustedDistancePulled / positionalThreshold override val progress get() = adjustedDistancePulled / positionalThreshold
override var verticalOffset by mutableFloatStateOf(0f) override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f)
override var isRefreshing by mutableStateOf(initialRefreshing) override var isRefreshing by mutableStateOf(initialRefreshing)
private val refreshingVerticalOffset: Float
get() = positionalThreshold + extraVerticalOffset
override fun startRefresh() { override fun startRefresh() {
isRefreshing = true isRefreshing = true
verticalOffset = positionalThreshold + extraVerticalOffset verticalOffset = refreshingVerticalOffset
} }
suspend fun startRefreshAnimated() { suspend fun startRefreshAnimated() {
isRefreshing = true isRefreshing = true
animateTo(positionalThreshold + extraVerticalOffset) animateTo(refreshingVerticalOffset)
} }
override fun endRefresh() { override fun endRefresh() {
@ -196,7 +204,7 @@ private class PullToRefreshStateImpl(
val newOffset = (distancePulled + available.y).coerceAtLeast(0f) val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled val dragConsumed = newOffset - distancePulled
distancePulled = newOffset distancePulled = newOffset
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress) verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f))
dragConsumed dragConsumed
} }
return Offset(0f, y) return Offset(0f, y)

View File

@ -11,6 +11,8 @@ 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.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import logcat.LogPriority import logcat.LogPriority
@ -22,6 +24,9 @@ import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.metadata.comicinfo.copyFromComicInfo import tachiyomi.core.metadata.comicinfo.copyFromComicInfo
import tachiyomi.core.metadata.comicinfo.getComicInfo import tachiyomi.core.metadata.comicinfo.getComicInfo
import tachiyomi.core.metadata.tachiyomi.MangaDetails import tachiyomi.core.metadata.tachiyomi.MangaDetails
import tachiyomi.core.storage.extension
import tachiyomi.core.storage.nameWithoutExtension
import tachiyomi.core.storage.toTempFile
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.ImageUtil
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
@ -33,11 +38,9 @@ import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.io.Archive import tachiyomi.source.local.io.Archive
import tachiyomi.source.local.io.Format import tachiyomi.source.local.io.Format
import tachiyomi.source.local.io.LocalSourceFileSystem import tachiyomi.source.local.io.LocalSourceFileSystem
import tachiyomi.source.local.metadata.fillChapterMetadata import tachiyomi.source.local.metadata.fillMetadata
import tachiyomi.source.local.metadata.fillMangaMetadata
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -72,22 +75,22 @@ actual class LocalSource(
override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LATEST_FILTERS) override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LATEST_FILTERS)
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage { override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = withIOContext {
val baseDirsFiles = fileSystem.getFilesInBaseDirectories() val lastModifiedLimit = if (filters === LATEST_FILTERS) {
val lastModifiedLimit by lazy {
if (filters === LATEST_FILTERS) {
System.currentTimeMillis() - LATEST_THRESHOLD System.currentTimeMillis() - LATEST_THRESHOLD
} else { } else {
0L 0L
} }
}
var mangaDirs = baseDirsFiles var mangaDirs = fileSystem.getFilesInBaseDirectory()
// Filter out files that are hidden and is not a folder // Filter out files that are hidden and is not a folder
.filter { it.isDirectory && !it.name.startsWith('.') } .filter { it.isDirectory && !it.name.orEmpty().startsWith('.') }
.distinctBy { it.name } .distinctBy { it.name }
.filter { // Filter by query or last modified .filter {
if (lastModifiedLimit == 0L) { if (lastModifiedLimit == 0L && query.isBlank()) {
it.name.contains(query, ignoreCase = true) true
} else if (lastModifiedLimit == 0L) {
it.name.orEmpty().contains(query, ignoreCase = true)
} else { } else {
it.lastModified() >= lastModifiedLimit it.lastModified() >= lastModifiedLimit
} }
@ -97,71 +100,53 @@ actual class LocalSource(
when (filter) { when (filter) {
is OrderBy.Popular -> { is OrderBy.Popular -> {
mangaDirs = if (filter.state!!.ascending) { mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() })
} else { } else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() })
} }
} }
is OrderBy.Latest -> { is OrderBy.Latest -> {
mangaDirs = if (filter.state!!.ascending) { mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified) mangaDirs.sortedBy(UniFile::lastModified)
} else { } else {
mangaDirs.sortedByDescending(File::lastModified) mangaDirs.sortedByDescending(UniFile::lastModified)
} }
} }
else -> { else -> {
/* Do nothing */ /* Do nothing */
} }
} }
} }
// Transform mangaDirs to list of SManga val mangas = mangaDirs
val mangas = mangaDirs.map { mangaDir -> .map { mangaDir ->
async {
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
coverManager.find(mangaDir.name) coverManager.find(mangaDir.name.orEmpty())?.let {
?.takeIf(File::exists) thumbnail_url = it.filePath
?.let { thumbnail_url = it.absolutePath }
} }
} }
}
}
.awaitAll()
// Fetch chapters of all the manga MangasPage(mangas, false)
mangas.forEach { manga ->
val chapters = getChapterList(manga)
if (chapters.isNotEmpty()) {
val chapter = chapters.last()
val format = getFormat(chapter)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(manga)
}
}
// Copy the cover from the first chapter found if not available
if (manga.thumbnail_url == null) {
updateCover(chapter, manga)
}
}
}
return MangasPage(mangas.toList(), false)
} }
// Manga details related // Manga details related
override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext { override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext {
coverManager.find(manga.url)?.let { coverManager.find(manga.url)?.let {
manga.thumbnail_url = it.absolutePath manga.thumbnail_url = it.filePath
} }
// Augment manga details based on metadata files // Augment manga details based on metadata files
try { try {
val mangaDir = fileSystem.getMangaDirectory(manga.url) val mangaDir by lazy { fileSystem.getMangaDirectory(manga.url) }
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url).toList() val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url)
val comicInfoFile = mangaDirFiles val comicInfoFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE } .firstOrNull { it.name == COMIC_INFO_FILE }
@ -174,13 +159,13 @@ actual class LocalSource(
// Top level ComicInfo.xml // Top level ComicInfo.xml
comicInfoFile != null -> { comicInfoFile != null -> {
noXmlFile?.delete() noXmlFile?.delete()
setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga) setMangaDetailsFromComicInfoFile(comicInfoFile.openInputStream(), manga)
} }
// Old custom JSON format // Old custom JSON format
// TODO: remove support for this entirely after a while // TODO: remove support for this entirely after a while
legacyJsonDetailsFile != null -> { legacyJsonDetailsFile != null -> {
json.decodeFromStream<MangaDetails>(legacyJsonDetailsFile.inputStream()).run { json.decodeFromStream<MangaDetails>(legacyJsonDetailsFile.openInputStream()).run {
title?.let { manga.title = it } title?.let { manga.title = it }
author?.let { manga.author = it } author?.let { manga.author = it }
artist?.let { manga.artist = it } artist?.let { manga.artist = it }
@ -190,7 +175,7 @@ actual class LocalSource(
} }
// Replace with ComicInfo.xml file // Replace with ComicInfo.xml file
val comicInfo = manga.getComicInfo() val comicInfo = manga.getComicInfo()
UniFile.fromFile(mangaDir) mangaDir
?.createFile(COMIC_INFO_FILE) ?.createFile(COMIC_INFO_FILE)
?.openOutputStream() ?.openOutputStream()
?.use { ?.use {
@ -206,14 +191,14 @@ actual class LocalSource(
.filter(Archive::isSupported) .filter(Archive::isSupported)
.toList() .toList()
val folderPath = mangaDir?.absolutePath val folderPath = mangaDir?.filePath
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath) val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
if (copiedFile != null) { if (copiedFile != null) {
setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga) setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga)
} else { } else {
// Avoid re-scanning // Avoid re-scanning
File("$folderPath/.noxml").createNewFile() mangaDir?.createFile(".noxml")
} }
} }
} }
@ -224,11 +209,11 @@ actual class LocalSource(
return@withIOContext manga return@withIOContext manga
} }
private fun copyComicInfoFileFromArchive(chapterArchives: List<File>, folderPath: String?): File? { private fun copyComicInfoFileFromArchive(chapterArchives: List<UniFile>, folderPath: String?): File? {
for (chapter in chapterArchives) { for (chapter in chapterArchives) {
when (Format.valueOf(chapter)) { when (Format.valueOf(chapter)) {
is Format.Zip -> { is Format.Zip -> {
ZipFile(chapter).use { zip: ZipFile -> ZipFile(chapter.toTempFile(context)).use { zip: ZipFile ->
zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile -> zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile ->
zip.getInputStream(comicInfoFile).buffered().use { stream -> zip.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folderPath) return copyComicInfoFile(stream, folderPath)
@ -237,7 +222,7 @@ actual class LocalSource(
} }
} }
is Format.Rar -> { is Format.Rar -> {
JunrarArchive(chapter).use { rar -> JunrarArchive(chapter.toTempFile(context)).use { rar ->
rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile -> rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile ->
rar.getInputStream(comicInfoFile).buffered().use { stream -> rar.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folderPath) return copyComicInfoFile(stream, folderPath)
@ -268,8 +253,8 @@ actual class LocalSource(
} }
// Chapters // Chapters
override suspend fun getChapterList(manga: SManga): List<SChapter> { override suspend fun getChapterList(manga: SManga): List<SChapter> = withIOContext {
return fileSystem.getFilesInMangaDirectory(manga.url) val chapters = fileSystem.getFilesInMangaDirectory(manga.url)
// Only keep supported formats // Only keep supported formats
.filter { it.isDirectory || Archive.isSupported(it) } .filter { it.isDirectory || Archive.isSupported(it) }
.map { chapterFile -> .map { chapterFile ->
@ -279,7 +264,7 @@ actual class LocalSource(
chapterFile.name chapterFile.name
} else { } else {
chapterFile.nameWithoutExtension chapterFile.nameWithoutExtension
} }.orEmpty()
date_upload = chapterFile.lastModified() date_upload = chapterFile.lastModified()
chapter_number = ChapterRecognition chapter_number = ChapterRecognition
.parseChapterNumber(manga.title, this.name, this.chapter_number.toDouble()) .parseChapterNumber(manga.title, this.name, this.chapter_number.toDouble())
@ -287,8 +272,8 @@ actual class LocalSource(
val format = Format.valueOf(chapterFile) val format = Format.valueOf(chapterFile)
if (format is Format.Epub) { if (format is Format.Epub) {
EpubFile(format.file).use { epub -> EpubFile(format.file.toTempFile(context)).use { epub ->
epub.fillChapterMetadata(this) epub.fillMetadata(manga, this)
} }
} }
} }
@ -297,7 +282,15 @@ actual class LocalSource(
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
} }
.toList()
// Copy the cover from the first chapter found if not available
if (manga.thumbnail_url.isNullOrBlank()) {
chapters.lastOrNull()?.let { chapter ->
updateCover(chapter, manga)
}
}
chapters
} }
// Filters // Filters
@ -308,9 +301,10 @@ actual class LocalSource(
fun getFormat(chapter: SChapter): Format { fun getFormat(chapter: SChapter): Format {
try { try {
return fileSystem.getBaseDirectories() val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2)
.map { dir -> File(dir, chapter.url) } return fileSystem.getBaseDirectory()
.find { it.exists() } ?.findFile(mangaDirName, true)
?.findFile(chapterName, true)
?.let(Format.Companion::valueOf) ?.let(Format.Companion::valueOf)
?: throw Exception(context.stringResource(MR.strings.chapter_not_found)) ?: throw Exception(context.stringResource(MR.strings.chapter_not_found))
} catch (e: Format.UnknownFormatException) { } catch (e: Format.UnknownFormatException) {
@ -320,18 +314,24 @@ actual class LocalSource(
} }
} }
private fun updateCover(chapter: SChapter, manga: SManga): File? { private fun updateCover(chapter: SChapter, manga: SManga): UniFile? {
return try { 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 ->
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(
f2.name.orEmpty(),
)
}
?.find {
!it.isDirectory && ImageUtil.isImage(it.name) { it.openInputStream() }
}
entry?.let { coverManager.update(manga, it.inputStream()) } entry?.let { coverManager.update(manga, it.openInputStream()) }
} }
is Format.Zip -> { is Format.Zip -> {
ZipFile(format.file).use { zip -> ZipFile(format.file.toTempFile(context)).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) } }
@ -340,7 +340,7 @@ actual class LocalSource(
} }
} }
is Format.Rar -> { is Format.Rar -> {
JunrarArchive(format.file).use { archive -> JunrarArchive(format.file.toTempFile(context)).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) } }
@ -349,7 +349,7 @@ actual class LocalSource(
} }
} }
is Format.Epub -> { is Format.Epub -> {
EpubFile(format.file).use { epub -> EpubFile(format.file.toTempFile(context)).use { epub ->
val entry = epub.getImagesFromPages() val entry = epub.getImagesFromPages()
.firstOrNull() .firstOrNull()
?.let { epub.getEntry(it) } ?.let { epub.getEntry(it) }

View File

@ -4,9 +4,9 @@ import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.core.storage.nameWithoutExtension
import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem import tachiyomi.source.local.io.LocalSourceFileSystem
import java.io.File
import java.io.InputStream import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg" private const val DEFAULT_COVER_NAME = "cover.jpg"
@ -16,43 +16,35 @@ actual class LocalCoverManager(
private val fileSystem: LocalSourceFileSystem, private val fileSystem: LocalSourceFileSystem,
) { ) {
actual fun find(mangaUrl: String): File? { actual fun find(mangaUrl: String): UniFile? {
return fileSystem.getFilesInMangaDirectory(mangaUrl) return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with "cover" // Get all file whose names start with "cover"
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) } .filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// Get the first actual image // Get the first actual image
.firstOrNull { .firstOrNull { ImageUtil.isImage(it.name) { it.openInputStream() } }
ImageUtil.isImage(it.name) { it.inputStream() }
}
} }
actual fun update( actual fun update(
manga: SManga, manga: SManga,
inputStream: InputStream, inputStream: InputStream,
): File? { ): UniFile? {
val directory = fileSystem.getMangaDirectory(manga.url) val directory = fileSystem.getMangaDirectory(manga.url)
if (directory == null) { if (directory == null) {
inputStream.close() inputStream.close()
return null return null
} }
var targetFile = find(manga.url) val targetFile = find(manga.url) ?: directory.createFile(DEFAULT_COVER_NAME)!!
if (targetFile == null) {
targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME)
targetFile.createNewFile()
}
// It might not exist at this point
targetFile.parentFile?.mkdirs()
inputStream.use { input -> inputStream.use { input ->
targetFile.outputStream().use { output -> targetFile.openOutputStream().use { output ->
input.copyTo(output) input.copyTo(output)
} }
} }
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context) DiskUtil.createNoMediaFile(directory, context)
manga.thumbnail_url = targetFile.absolutePath manga.thumbnail_url = targetFile.uri.toString()
return targetFile return targetFile
} }
} }

View File

@ -1,40 +1,30 @@
package tachiyomi.source.local.io package tachiyomi.source.local.io
import android.content.Context import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.storage.DiskUtil import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR
import java.io.File
actual class LocalSourceFileSystem( actual class LocalSourceFileSystem(
private val context: Context, private val storageManager: StorageManager,
) { ) {
private val baseFolderLocation = "${context.stringResource(MR.strings.app_name)}${File.separator}local" actual fun getBaseDirectory(): UniFile? {
return storageManager.getLocalSourceDirectory()
actual fun getBaseDirectories(): Sequence<File> {
return DiskUtil.getExternalStorages(context)
.map { File(it.absolutePath, baseFolderLocation) }
.asSequence()
} }
actual fun getFilesInBaseDirectories(): Sequence<File> { actual fun getFilesInBaseDirectory(): List<UniFile> {
return getBaseDirectories() return getBaseDirectory()?.listFiles().orEmpty().toList()
// Get all the files inside all baseDir
.flatMap { it.listFiles().orEmpty().toList() }
} }
actual fun getMangaDirectory(name: String): File? { actual fun getMangaDirectory(name: String): UniFile? {
return getFilesInBaseDirectories() return getBaseDirectory()
// Get the first mangaDir or null ?.findFile(name, true)
.firstOrNull { it.isDirectory && it.name == name } ?.takeIf { it.isDirectory }
} }
actual fun getFilesInMangaDirectory(name: String): Sequence<File> { actual fun getFilesInMangaDirectory(name: String): List<UniFile> {
return getFilesInBaseDirectories() return getBaseDirectory()
// Filter out ones that are not related to the manga and is not a directory ?.findFile(name, true)
.filter { it.isDirectory && it.name == name } ?.takeIf { it.isDirectory }
// Get all the files inside the filtered folders ?.listFiles().orEmpty().toList()
.flatMap { it.listFiles().orEmpty().toList() }
} }
} }

View File

@ -8,37 +8,25 @@ import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
/** /**
* Fills manga metadata using this epub file's metadata. * Fills manga and chapter metadata using this epub file's metadata.
*/ */
fun EpubFile.fillMangaMetadata(manga: SManga) { fun EpubFile.fillMetadata(manga: SManga, chapter: SChapter) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val creator = doc.getElementsByTag("dc:creator").first()
val description = doc.getElementsByTag("dc:description").first()
manga.author = creator?.text()
manga.description = description?.text()
}
/**
* Fills chapter metadata using this epub file's metadata.
*/
fun EpubFile.fillChapterMetadata(chapter: SChapter) {
val ref = getPackageHref() val ref = getPackageHref()
val doc = getPackageDocument(ref) val doc = getPackageDocument(ref)
val title = doc.getElementsByTag("dc:title").first() val title = doc.getElementsByTag("dc:title").first()
val publisher = doc.getElementsByTag("dc:publisher").first() val publisher = doc.getElementsByTag("dc:publisher").first()
val creator = doc.getElementsByTag("dc:creator").first() val creator = doc.getElementsByTag("dc:creator").first()
val description = doc.getElementsByTag("dc:description").first()
var date = doc.getElementsByTag("dc:date").first() var date = doc.getElementsByTag("dc:date").first()
if (date == null) { if (date == null) {
date = doc.select("meta[property=dcterms:modified]").first() date = doc.select("meta[property=dcterms:modified]").first()
} }
if (title != null) { creator?.text()?.let { manga.author = it }
chapter.name = title.text() description?.text()?.let { manga.description = it }
}
title?.text()?.let { chapter.name = it }
if (publisher != null) { if (publisher != null) {
chapter.scanlator = publisher.text() chapter.scanlator = publisher.text()

View File

@ -1,12 +1,12 @@
package tachiyomi.source.local.image package tachiyomi.source.local.image
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import java.io.File
import java.io.InputStream import java.io.InputStream
expect class LocalCoverManager { expect class LocalCoverManager {
fun find(mangaUrl: String): File? fun find(mangaUrl: String): UniFile?
fun update(manga: SManga, inputStream: InputStream): File? fun update(manga: SManga, inputStream: InputStream): UniFile?
} }

View File

@ -1,12 +1,13 @@
package tachiyomi.source.local.io package tachiyomi.source.local.io
import java.io.File import com.hippo.unifile.UniFile
import tachiyomi.core.storage.extension
object Archive { object Archive {
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub") private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")
fun isSupported(file: File): Boolean = with(file) { fun isSupported(file: UniFile): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES return file.extension in SUPPORTED_ARCHIVE_TYPES
} }
} }

View File

@ -1,18 +1,19 @@
package tachiyomi.source.local.io package tachiyomi.source.local.io
import java.io.File import com.hippo.unifile.UniFile
import tachiyomi.core.storage.extension
sealed interface Format { sealed interface 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
class UnknownFormatException : Exception() class UnknownFormatException : Exception()
companion object { companion object {
fun valueOf(file: File) = with(file) { fun valueOf(file: UniFile) = with(file) {
when { when {
isDirectory -> Directory(this) isDirectory -> Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this) extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this)

View File

@ -1,14 +1,14 @@
package tachiyomi.source.local.io package tachiyomi.source.local.io
import java.io.File import com.hippo.unifile.UniFile
expect class LocalSourceFileSystem { expect class LocalSourceFileSystem {
fun getBaseDirectories(): Sequence<File> fun getBaseDirectory(): UniFile?
fun getFilesInBaseDirectories(): Sequence<File> fun getFilesInBaseDirectory(): List<UniFile>
fun getMangaDirectory(name: String): File? fun getMangaDirectory(name: String): UniFile?
fun getFilesInMangaDirectory(name: String): Sequence<File> fun getFilesInMangaDirectory(name: String): List<UniFile>
} }