From 8a7d6a328ae88cd0939892ec1c90fbbdc5eeacbd Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 2 Jan 2024 18:34:49 -0500 Subject: [PATCH 01/12] Update Mullvad DoH configuration Closes #10282 --- .../main/java/eu/kanade/tachiyomi/network/DohProviders.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt b/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt index 95f448f13..e6995448b 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt @@ -128,14 +128,13 @@ fun OkHttpClient.Builder.dohQuad101() = dns( /* * Mullvad DoH * without ad blocking option - * Source : https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/ + * Source: https://mullvad.net/en/help/dns-over-https-and-dns-over-tls */ fun OkHttpClient.Builder.dohMullvad() = dns( DnsOverHttps.Builder().client(build()) - .url("https://doh.mullvad.net/dns-query".toHttpUrl()) + .url(" https://dns.mullvad.net/dns-query".toHttpUrl()) .bootstrapDnsHosts( InetAddress.getByName("194.242.2.2"), - InetAddress.getByName("193.19.108.2"), InetAddress.getByName("2a07:e340::2"), ) .build(), @@ -144,7 +143,7 @@ fun OkHttpClient.Builder.dohMullvad() = dns( /* * Control D * unfiltered option - * Source : https://controld.com/free-dns/? + * Source: https://controld.com/free-dns/? */ fun OkHttpClient.Builder.dohControlD() = dns( DnsOverHttps.Builder().client(build()) From 4e221397ceaec334307546920b3e1168e56f5433 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 4 Jan 2024 18:02:40 -0500 Subject: [PATCH 02/12] Remove tmp chapter files after exiting reader --- .../java/eu/kanade/tachiyomi/di/AppModule.kt | 3 ++ .../tachiyomi/ui/reader/ReaderViewModel.kt | 5 ++- .../ui/reader/loader/ChapterLoader.kt | 18 +++++--- .../ui/reader/loader/DownloadPageLoader.kt | 7 +-- .../core/storage/UniFileExtensions.kt | 29 ------------ .../core/storage/UniFileTempFileManager.kt | 44 +++++++++++++++++++ .../tachiyomi/source/local/LocalSource.kt | 15 ++++--- 7 files changed, 76 insertions(+), 45 deletions(-) create mode 100644 core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt index 4e0866dad..ef0f65fff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -27,6 +27,7 @@ import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.serialization.XML import tachiyomi.core.storage.AndroidStorageFolderProvider +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.data.AndroidDatabaseHandler import tachiyomi.data.Database import tachiyomi.data.DatabaseHandler @@ -111,6 +112,8 @@ class AppModule(val app: Application) : InjektModule { ProtoBuf } + addSingletonFactory { UniFileTempFileManager(app) } + addSingletonFactory { ChapterCache(app, get()) } addSingletonFactory { CoverCache(app) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 37116eeef..789d26e3e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import logcat.LogPriority import tachiyomi.core.preference.toggle +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchNonCancellable import tachiyomi.core.util.lang.withIOContext @@ -85,6 +86,7 @@ class ReaderViewModel @JvmOverloads constructor( private val sourceManager: SourceManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), private val downloadProvider: DownloadProvider = Injekt.get(), + private val tempFileManager: UniFileTempFileManager = Injekt.get(), private val imageSaver: ImageSaver = Injekt.get(), preferences: BasePreferences = Injekt.get(), val readerPreferences: ReaderPreferences = Injekt.get(), @@ -269,7 +271,7 @@ class ReaderViewModel @JvmOverloads constructor( val context = Injekt.get() val source = sourceManager.getOrStub(manga.source) - loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source) + loader = ChapterLoader(context, downloadManager, downloadProvider, tempFileManager, manga, source) loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) Result.success(true) @@ -904,6 +906,7 @@ class ReaderViewModel @JvmOverloads constructor( private fun deletePendingChapters() { viewModelScope.launchNonCancellable { downloadManager.deletePendingChapters() + tempFileManager.deleteTempFiles() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 6a31ed029..b8f97c5f4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import tachiyomi.core.i18n.stringResource -import tachiyomi.core.storage.toTempFile +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.logcat import tachiyomi.domain.manga.model.Manga @@ -24,6 +24,7 @@ class ChapterLoader( private val context: Context, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, + private val tempFileManager: UniFileTempFileManager, private val manga: Manga, private val source: Source, ) { @@ -85,17 +86,24 @@ class ChapterLoader( skipCache = true, ) return when { - isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider) + isDownloaded -> DownloadPageLoader( + chapter, + manga, + source, + downloadManager, + downloadProvider, + tempFileManager, + ) source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { is Format.Directory -> DirectoryPageLoader(format.file) - is Format.Zip -> ZipPageLoader(format.file.toTempFile(context)) + is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file)) is Format.Rar -> try { - RarPageLoader(format.file.toTempFile(context)) + RarPageLoader(tempFileManager.createTempFile(format.file)) } catch (e: UnsupportedRarV5Exception) { error(context.stringResource(MR.strings.loader_rar5_error)) } - is Format.Epub -> EpubPageLoader(format.file.toTempFile(context)) + is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file)) } } source is HttpSource -> HttpPageLoader(chapter, source) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index 3d385551d..775b493b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -10,7 +10,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import tachiyomi.core.storage.toTempFile +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.domain.manga.model.Manga import uy.kohesive.injekt.injectLazy @@ -23,6 +23,7 @@ internal class DownloadPageLoader( private val source: Source, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, + private val tempFileManager: UniFileTempFileManager, ) : PageLoader() { private val context: Application by injectLazy() @@ -46,8 +47,8 @@ internal class DownloadPageLoader( zipPageLoader?.recycle() } - private suspend fun getPagesFromArchive(chapterPath: UniFile): List { - val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it } + private suspend fun getPagesFromArchive(file: UniFile): List { + val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it } return loader.getPages() } diff --git a/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt b/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt index 8e2bf43fc..afe60ed35 100644 --- a/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt +++ b/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt @@ -1,11 +1,6 @@ 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('.') @@ -15,27 +10,3 @@ val UniFile.nameWithoutExtension: String? val UniFile.displayablePath: String get() = filePath ?: uri.toString() - -fun UniFile.toTempFile(context: Context): File { - val inputStream = context.contentResolver.openInputStream(uri)!! - val tempFile = File.createTempFile( - nameWithoutExtension.orEmpty().padEnd(3), // Prefix must be 3+ chars - null, - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - FileUtils.copy(inputStream, tempFile.outputStream()) - } else { - BufferedOutputStream(tempFile.outputStream()).use { tmpOut -> - inputStream.use { input -> - val buffer = ByteArray(8192) - var count: Int - while (input.read(buffer).also { count = it } > 0) { - tmpOut.write(buffer, 0, count) - } - } - } - } - - return tempFile -} diff --git a/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt b/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt new file mode 100644 index 000000000..938494613 --- /dev/null +++ b/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt @@ -0,0 +1,44 @@ +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 + +class UniFileTempFileManager( + private val context: Context, +) { + + private val dir = File(context.externalCacheDir, "tmp").also { it.mkdir() } + + fun createTempFile(file: UniFile): File { + val inputStream = context.contentResolver.openInputStream(file.uri)!! + val tempFile = File.createTempFile( + file.nameWithoutExtension.orEmpty().padEnd(3), // Prefix must be 3+ chars + null, + dir, + ) + + 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 deleteTempFiles() { + dir.deleteRecursively() + } +} diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt index 6177e7746..d92f93900 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt @@ -24,9 +24,9 @@ import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.copyFromComicInfo import tachiyomi.core.metadata.comicinfo.getComicInfo import tachiyomi.core.metadata.tachiyomi.MangaDetails +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.core.storage.extension import tachiyomi.core.storage.nameWithoutExtension -import tachiyomi.core.storage.toTempFile import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.logcat @@ -56,6 +56,7 @@ actual class LocalSource( private val json: Json by injectLazy() private val xml: XML by injectLazy() + private val tempFileManager: UniFileTempFileManager by injectLazy() private val POPULAR_FILTERS = FilterList(OrderBy.Popular(context)) private val LATEST_FILTERS = FilterList(OrderBy.Latest(context)) @@ -213,7 +214,7 @@ actual class LocalSource( for (chapter in chapterArchives) { when (Format.valueOf(chapter)) { is Format.Zip -> { - ZipFile(chapter.toTempFile(context)).use { zip: ZipFile -> + ZipFile(tempFileManager.createTempFile(chapter)).use { zip: ZipFile -> zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile -> zip.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) @@ -222,7 +223,7 @@ actual class LocalSource( } } is Format.Rar -> { - JunrarArchive(chapter.toTempFile(context)).use { rar -> + JunrarArchive(tempFileManager.createTempFile(chapter)).use { rar -> rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile -> rar.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) @@ -272,7 +273,7 @@ actual class LocalSource( val format = Format.valueOf(chapterFile) if (format is Format.Epub) { - EpubFile(format.file.toTempFile(context)).use { epub -> + EpubFile(tempFileManager.createTempFile(format.file)).use { epub -> epub.fillMetadata(manga, this) } } @@ -331,7 +332,7 @@ actual class LocalSource( entry?.let { coverManager.update(manga, it.openInputStream()) } } is Format.Zip -> { - ZipFile(format.file.toTempFile(context)).use { zip -> + ZipFile(tempFileManager.createTempFile(format.file)).use { zip -> val entry = zip.entries().toList() .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } @@ -340,7 +341,7 @@ actual class LocalSource( } } is Format.Rar -> { - JunrarArchive(format.file.toTempFile(context)).use { archive -> + JunrarArchive(tempFileManager.createTempFile(format.file)).use { archive -> val entry = archive.fileHeaders .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } @@ -349,7 +350,7 @@ actual class LocalSource( } } is Format.Epub -> { - EpubFile(format.file.toTempFile(context)).use { epub -> + EpubFile(tempFileManager.createTempFile(format.file)).use { epub -> val entry = epub.getImagesFromPages() .firstOrNull() ?.let { epub.getEntry(it) } From 493da5c3f471b53615e80411d942fc2c92a33ec5 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 08:53:45 -0500 Subject: [PATCH 03/12] Force users to retrust unknown extensions on cold starts --- .../tachiyomi/extension/ExtensionManager.kt | 1 - .../tachiyomi/extension/util/ExtensionLoader.kt | 17 ++++++++++++----- .../commonMain/resources/MR/base/strings.xml | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 58ffe1111..4b43d11ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -258,7 +258,6 @@ class ExtensionManager( val untrustedSignatures = _untrustedExtensionsFlow.value.map { it.signatureHash }.toSet() if (signature !in untrustedSignatures) return - ExtensionLoader.trustedSignatures += signature preferences.trustedSignatures() += signature val nowTrustedExtensions = _untrustedExtensionsFlow.value.filter { it.signatureHash == signature } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index eccf66cd6..ecb4d09e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo +import eu.kanade.tachiyomi.util.system.isDevFlavor import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking @@ -62,11 +63,6 @@ internal object ExtensionLoader { // inorichi's key private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" - /** - * List of the trusted signatures. - */ - var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() - private const val PRIVATE_EXTENSION_EXTENSION = "ext" private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") @@ -123,6 +119,12 @@ internal object ExtensionLoader { * @param context The application context. */ fun loadExtensions(context: Context): List { + // Always make users trust unknown extensions on cold starts in non-dev builds + // due to inherent security risks + if (!isDevFlavor) { + preferences.trustedSignatures().delete() + } + val pkgManager = context.packageManager val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -394,6 +396,11 @@ internal object ExtensionLoader { } private fun hasTrustedSignature(signatures: List): Boolean { + if (officialSignature in signatures) { + return true + } + + val trustedSignatures = preferences.trustedSignatures().get() return trustedSignatures.any { signatures.contains(it) } } diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index cad687b24..4f899abbf 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -318,7 +318,7 @@ Uninstall App info Untrusted extension - This extension was signed with an untrusted certificate and wasn\'t activated.\n\nA malicious extension could read any stored login credentials or execute arbitrary code.\n\nBy trusting this certificate you accept these risks. + This extension was signed by any unknown author and wasn\'t loaded.\n\nMalicious extensions can read any stored login credentials or execute arbitrary code.\n\nBy trusting this extension\'s certificate, you accept these risks. This extension is no longer available. It may not function properly and can cause issues with the app. Uninstalling it is recommended. This extension is not from the official list. Failed to get extensions list From 8409ebe4ebbde0957aec122e11acae65ebda78e2 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 15:48:56 -0500 Subject: [PATCH 04/12] Fix temp chapter files not being able to be created when reading --- .../java/tachiyomi/core/storage/UniFileTempFileManager.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt b/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt index 938494613..0aa9f4b85 100644 --- a/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt +++ b/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt @@ -11,9 +11,11 @@ class UniFileTempFileManager( private val context: Context, ) { - private val dir = File(context.externalCacheDir, "tmp").also { it.mkdir() } + private val dir = File(context.externalCacheDir, "tmp") fun createTempFile(file: UniFile): File { + dir.mkdirs() + val inputStream = context.contentResolver.openInputStream(file.uri)!! val tempFile = File.createTempFile( file.nameWithoutExtension.orEmpty().padEnd(3), // Prefix must be 3+ chars From e0a0942015accc217a881739abf8df9d74eab6da Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 17:07:49 -0500 Subject: [PATCH 05/12] Remove custom extension readme/changelog URLs These were barely used/maintained, so just killing them. Changelog menu item still exists to take you to the relevant git history. --- .../browse/ExtensionDetailsScreen.kt | 9 --------- .../extension/api/ExtensionGithubApi.kt | 4 ---- .../tachiyomi/extension/model/Extension.kt | 8 -------- .../tachiyomi/extension/util/ExtensionLoader.kt | 2 -- .../extension/details/ExtensionDetailsScreen.kt | 1 - .../details/ExtensionDetailsScreenModel.kt | 17 ----------------- .../commonMain/resources/MR/base/strings.xml | 1 - 7 files changed, 42 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 65da03142..5ab0159d7 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog @@ -67,7 +66,6 @@ fun ExtensionDetailsScreen( state: ExtensionDetailsScreenModel.State, onClickSourcePreferences: (sourceId: Long) -> Unit, onClickWhatsNew: () -> Unit, - onClickReadme: () -> Unit, onClickEnableAll: () -> Unit, onClickDisableAll: () -> Unit, onClickClearCookies: () -> Unit, @@ -91,13 +89,6 @@ fun ExtensionDetailsScreen( onClick = onClickWhatsNew, ), ) - add( - AppBar.Action( - title = stringResource(MR.strings.action_faq_and_guides), - icon = Icons.AutoMirrored.Outlined.HelpOutline, - onClick = onClickReadme, - ), - ) } addAll( listOf( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index e9421fed9..3032aee03 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -126,8 +126,6 @@ internal class ExtensionGithubApi { libVersion = it.extractLibVersion(), lang = it.lang, isNsfw = it.nsfw == 1, - hasReadme = it.hasReadme == 1, - hasChangelog = it.hasChangelog == 1, sources = it.sources?.map(extensionSourceMapper).orEmpty(), apkName = it.apk, iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", @@ -164,8 +162,6 @@ private data class ExtensionJsonObject( val code: Long, val version: String, val nsfw: Int, - val hasReadme: Int = 0, - val hasChangelog: Int = 0, val sources: List?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index e7eab29b6..7f4a316aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -13,8 +13,6 @@ sealed class Extension { abstract val libVersion: Double abstract val lang: String? abstract val isNsfw: Boolean - abstract val hasReadme: Boolean - abstract val hasChangelog: Boolean data class Installed( override val name: String, @@ -24,8 +22,6 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, - override val hasReadme: Boolean, - override val hasChangelog: Boolean, val pkgFactory: String?, val sources: List, val icon: Drawable?, @@ -43,8 +39,6 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, - override val hasReadme: Boolean, - override val hasChangelog: Boolean, val sources: List, val apkName: String, val iconUrl: String, @@ -75,7 +69,5 @@ sealed class Extension { val signatureHash: String, override val lang: String? = null, override val isNsfw: Boolean = false, - override val hasReadme: Boolean = false, - override val hasChangelog: Boolean = false, ) : Extension() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index ecb4d09e2..a01ee5cb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -331,8 +331,6 @@ internal object ExtensionLoader { libVersion = libVersion, lang = lang, isNsfw = isNsfw, - hasReadme = hasReadme, - hasChangelog = hasChangelog, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), isUnofficial = !isOfficiallySigned(signatures), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt index 1797dc89d..79af92328 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt @@ -37,7 +37,6 @@ data class ExtensionDetailsScreen( state = state, onClickSourcePreferences = { navigator.push(SourcePreferencesScreen(it)) }, onClickWhatsNew = { uriHandler.openUri(screenModel.getChangelogUrl()) }, - onClickReadme = { uriHandler.openUri(screenModel.getReadmeUrl()) }, onClickEnableAll = { screenModel.toggleSources(true) }, onClickDisableAll = { screenModel.toggleSources(false) }, onClickClearCookies = screenModel::clearCookies, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt index a0ed5495b..c6e821bbd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt @@ -31,8 +31,6 @@ import uy.kohesive.injekt.api.get private const val URL_EXTENSION_COMMITS = "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master" -private const val URL_EXTENSION_BLOB = - "https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master" class ExtensionDetailsScreenModel( pkgName: String, @@ -93,26 +91,11 @@ class ExtensionDetailsScreenModel( val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") val pkgFactory = extension.pkgFactory - if (extension.hasChangelog) { - return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md") - } // Falling back on GitHub commit history because there is no explicit changelog in extension return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory) } - fun getReadmeUrl(): String { - val extension = state.value.extension ?: return "" - - if (!extension.hasReadme) { - return "https://tachiyomi.org/docs/faq/browse/extensions" - } - - val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") - val pkgFactory = extension.pkgFactory - return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md") - } - fun clearCookies() { val extension = state.value.extension ?: return diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 4f899abbf..e130180c1 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -163,7 +163,6 @@ Forward Refresh Start downloading now - FAQ and Guides Not now From 32bed9b041d76a6bac699c56c78d1692b71dfc0b Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 17:08:39 -0500 Subject: [PATCH 06/12] Change fetch interval action to show days until next expected update --- .../kanade/presentation/manga/MangaScreen.kt | 15 ++-- .../manga/components/MangaDialogs.kt | 76 ++++++++++++------- .../manga/components/MangaInfoHeader.kt | 39 ++++++---- .../settings/screen/SettingsLibraryScreen.kt | 2 +- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 9 ++- .../tachiyomi/domain/manga/model/Manga.kt | 7 ++ .../commonMain/resources/MR/base/strings.xml | 15 ++-- 7 files changed, 102 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 7a6d8d50b..444af9aec 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -78,12 +78,13 @@ import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.isScrolledToEnd import tachiyomi.presentation.core.util.isScrollingUp import tachiyomi.source.local.isLocal +import java.time.Instant @Composable fun MangaScreen( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - fetchInterval: Int?, + nextUpdate: Instant?, isTabletUi: Boolean, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, @@ -138,7 +139,7 @@ fun MangaScreen( MangaScreenSmallImpl( state = state, snackbarHostState = snackbarHostState, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, onBackClicked = onBackClicked, @@ -175,7 +176,7 @@ fun MangaScreen( snackbarHostState = snackbarHostState, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, onBackClicked = onBackClicked, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, @@ -211,7 +212,7 @@ fun MangaScreen( private fun MangaScreenSmallImpl( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - fetchInterval: Int?, + nextUpdate: Instant?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -402,7 +403,7 @@ private fun MangaScreenSmallImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, isUserIntervalMode = state.manga.fetchInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, @@ -462,7 +463,7 @@ private fun MangaScreenSmallImpl( fun MangaScreenLargeImpl( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - fetchInterval: Int?, + nextUpdate: Instant?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -641,7 +642,7 @@ fun MangaScreenLargeImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, isUserIntervalMode = state.manga.fetchInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt index f59b4574a..ace822cd1 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt @@ -2,8 +2,11 @@ package eu.kanade.presentation.manga.components import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -20,6 +23,7 @@ import kotlinx.collections.immutable.toImmutableList import tachiyomi.domain.manga.interactor.FetchInterval import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.WheelTextPicker +import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource import java.time.Instant @@ -59,57 +63,71 @@ fun DeleteChaptersDialog( @Composable fun SetIntervalDialog( interval: Int, - nextUpdate: Long, + nextUpdate: Instant?, onDismissRequest: () -> Unit, - onValueChanged: (Int) -> Unit, + onValueChanged: ((Int) -> Unit)? = null, ) { var selectedInterval by rememberSaveable { mutableIntStateOf(if (interval < 0) -interval else 0) } val nextUpdateDays = remember(nextUpdate) { - val now = Instant.now() - val nextUpdateInstant = Instant.ofEpochMilli(nextUpdate) - - now.until(nextUpdateInstant, ChronoUnit.DAYS) + return@remember if (nextUpdate != null) { + val now = Instant.now() + now.until(nextUpdate, ChronoUnit.DAYS).toInt() + } else { + null + } } + // TODO: selecting "1" then doesn't allow for future changes unless defaulting first? AlertDialog( onDismissRequest = onDismissRequest, - title = { Text(stringResource(MR.strings.manga_modify_calculated_interval_title)) }, + title = { Text(stringResource(MR.strings.pref_library_update_smart_update)) }, text = { Column { - if (nextUpdateDays >= 0) { + if (nextUpdateDays != null && nextUpdateDays >= 0) { Text( stringResource( MR.strings.manga_interval_expected_update, pluralStringResource( MR.plurals.day, - count = nextUpdateDays.toInt(), + count = nextUpdateDays, nextUpdateDays, ), + pluralStringResource( + MR.plurals.day, + count = interval, + interval, + ), ), ) + + Spacer(Modifier.height(MaterialTheme.padding.small)) } - BoxWithConstraints( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - val size = DpSize(width = maxWidth / 2, height = 128.dp) - val items = (0..FetchInterval.MAX_INTERVAL) - .map { - if (it == 0) { - stringResource(MR.strings.label_default) - } else { - it.toString() + if (onValueChanged != null) { + Text(stringResource(MR.strings.manga_interval_custom_amount)) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val size = DpSize(width = maxWidth / 2, height = 128.dp) + val items = (0..FetchInterval.MAX_INTERVAL) + .map { + if (it == 0) { + stringResource(MR.strings.label_default) + } else { + it.toString() + } } - } - .toImmutableList() - WheelTextPicker( - items = items, - size = size, - startIndex = selectedInterval, - onSelectionChanged = { selectedInterval = it }, - ) + .toImmutableList() + WheelTextPicker( + items = items, + size = size, + startIndex = selectedInterval, + onSelectionChanged = { selectedInterval = it }, + ) + } } } }, @@ -120,7 +138,7 @@ fun SetIntervalDialog( }, confirmButton = { TextButton(onClick = { - onValueChanged(selectedInterval) + onValueChanged?.invoke(selectedInterval) onDismissRequest() }) { Text(text = stringResource(MR.strings.action_ok)) diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt index 7b5de2467..283f71bbb 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt @@ -86,7 +86,8 @@ import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication import tachiyomi.presentation.core.util.secondaryItemAlpha -import kotlin.math.absoluteValue +import java.time.Instant +import java.time.temporal.ChronoUnit import kotlin.math.roundToInt private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)) @@ -165,7 +166,7 @@ fun MangaInfoBox( fun MangaActionRow( favorite: Boolean, trackingCount: Int, - fetchInterval: Int?, + nextUpdate: Instant?, isUserIntervalMode: Boolean, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, @@ -177,6 +178,16 @@ fun MangaActionRow( ) { val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) + // TODO: show something better when using custom interval + val nextUpdateDays = remember(nextUpdate) { + return@remember if (nextUpdate != null) { + val now = Instant.now() + now.until(nextUpdate, ChronoUnit.DAYS).toInt() + } else { + null + } + } + Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { MangaActionButton( title = if (favorite) { @@ -189,18 +200,20 @@ fun MangaActionRow( onClick = onAddToLibraryClicked, onLongClick = onEditCategory, ) - if (onEditIntervalClicked != null && fetchInterval != null) { - MangaActionButton( - title = pluralStringResource( + MangaActionButton( + title = if (nextUpdateDays != null) { + pluralStringResource( MR.plurals.day, - count = fetchInterval.absoluteValue, - fetchInterval.absoluteValue, - ), - icon = Icons.Default.HourglassEmpty, - color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, - onClick = onEditIntervalClicked, - ) - } + count = nextUpdateDays, + nextUpdateDays, + ) + } else { + stringResource(MR.strings.not_applicable) + }, + icon = Icons.Default.HourglassEmpty, + color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, + onClick = { onEditIntervalClicked?.invoke() }, + ) MangaActionButton( title = if (trackingCount == 0) { stringResource(MR.strings.manga_tracking_tab) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index 1ad7410be..346a60b86 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -198,7 +198,7 @@ object SettingsLibraryScreen : SearchableSettings { ), Preference.PreferenceItem.MultiSelectListPreference( pref = libraryPreferences.autoUpdateMangaRestrictions(), - title = stringResource(MR.strings.pref_library_update_manga_restriction), + title = stringResource(MR.strings.pref_library_update_smart_update), entries = persistentMapOf( MANGA_HAS_UNREAD to stringResource(MR.strings.pref_update_only_completely_read), MANGA_NON_READ to stringResource(MR.strings.pref_update_only_started), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index f4c7c48b6..b73b4bcc3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -104,7 +104,7 @@ class MangaScreen( MangaScreen( state = successState, snackbarHostState = screenModel.snackbarHostState, - fetchInterval = successState.manga.fetchInterval, + nextUpdate = successState.manga.expectedNextUpdate, isTabletUi = isTabletUi(), chapterSwipeStartAction = screenModel.chapterSwipeStartAction, chapterSwipeEndAction = screenModel.chapterSwipeEndAction, @@ -146,7 +146,7 @@ class MangaScreen( onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, onEditCategoryClicked = screenModel::showChangeCategoryDialog.takeIf { successState.manga.favorite }, onEditFetchIntervalClicked = screenModel::showSetFetchIntervalDialog.takeIf { - screenModel.isUpdateIntervalEnabled && successState.manga.favorite + successState.manga.favorite }, onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) @@ -243,9 +243,10 @@ class MangaScreen( is MangaScreenModel.Dialog.SetFetchInterval -> { SetIntervalDialog( interval = dialog.manga.fetchInterval, - nextUpdate = dialog.manga.nextUpdate, + nextUpdate = dialog.manga.expectedNextUpdate, onDismissRequest = onDismissRequest, - onValueChanged = { screenModel.setFetchInterval(dialog.manga, it) }, + onValueChanged = { interval: Int -> screenModel.setFetchInterval(dialog.manga, interval) } + .takeIf { screenModel.isUpdateIntervalEnabled }, ) } } diff --git a/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt b/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt index f694355a4..2b99c29bf 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt @@ -1,8 +1,10 @@ package tachiyomi.domain.manga.model +import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.UpdateStrategy import tachiyomi.core.preference.TriState import java.io.Serializable +import java.time.Instant data class Manga( val id: Long, @@ -29,6 +31,11 @@ data class Manga( val favoriteModifiedAt: Long?, ) : Serializable { + val expectedNextUpdate: Instant? + get() = nextUpdate + .takeIf { status != SManga.COMPLETED.toLong() } + ?.let { Instant.ofEpochMilli(it) } + val sorting: Long get() = chapterFlags and CHAPTER_SORTING_MASK diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index e130180c1..df8d826a6 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -275,12 +275,12 @@ When charging Restrictions: %s - Skip updating entries - With unread chapter(s) - With \"Completed\" status - That haven\'t been started + Smart update + Skip entries with unread chapter(s) + Skip entries with \"Completed\" status + Skip unstarted entries + Predict next release time Show unread count on Updates icon - Outside expected release period Automatically refresh metadata Check for new cover and details when updating library @@ -668,9 +668,10 @@ Chapter %1$s Estimate every Set to update every + Next update - Next update expected in around %s - Customize interval + Next update expected in around %1$s, checking around every %2$s + Custom update frequency: Downloading (%1$d/%2$d) Error Paused From c17ada2c98041877ab901efb9b03497130ead34a Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 17:28:08 -0500 Subject: [PATCH 07/12] Support external repos Largely taken from SY. Co-authored-by: jobobby04 --- .../java/eu/kanade/domain/DomainModule.kt | 7 ++ .../source/interactor/CreateSourceRepo.kt | 34 ++++++ .../source/interactor/DeleteSourceRepos.kt | 12 ++ .../source/interactor/GetSourceRepos.kt | 12 ++ .../source/service/SourcePreferences.kt | 2 + .../browse/ExtensionDetailsScreen.kt | 18 ++- .../presentation/category/SourceRepoScreen.kt | 60 ++++++++++ .../category/components/CategoryDialogs.kt | 76 ++++++------ .../components/repo/SourceRepoContent.kt | 79 ++++++++++++ .../presentation/category/repos/RepoScreen.kt | 75 ++++++++++++ .../category/repos/RepoScreenModel.kt | 112 ++++++++++++++++++ .../settings/screen/SettingsBrowseScreen.kt | 17 +++ .../extension/api/ExtensionGithubApi.kt | 39 ++++-- .../tachiyomi/extension/model/Extension.kt | 4 + .../source/browse/BrowseSourceScreenModel.kt | 9 +- .../tachiyomi/ui/category/CategoryScreen.kt | 14 ++- .../ui/library/LibraryScreenModel.kt | 21 ++-- .../tachiyomi/ui/manga/MangaScreenModel.kt | 9 +- .../commonMain/resources/MR/base/plurals.xml | 5 + .../commonMain/resources/MR/base/strings.xml | 11 ++ 20 files changed, 557 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 778b7645c..6ef3a4446 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -11,8 +11,11 @@ import eu.kanade.domain.manga.interactor.GetExcludedScanlators import eu.kanade.domain.manga.interactor.SetExcludedScanlators import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.source.interactor.CreateSourceRepo +import eu.kanade.domain.source.interactor.DeleteSourceRepos import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetLanguagesWithSources +import eu.kanade.domain.source.interactor.GetSourceRepos import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.domain.source.interactor.ToggleLanguage @@ -167,5 +170,9 @@ class DomainModule : InjektModule { addFactory { ToggleLanguage(get()) } addFactory { ToggleSource(get()) } addFactory { ToggleSourcePin(get()) } + + addFactory { CreateSourceRepo(get()) } + addFactory { DeleteSourceRepos(get()) } + addFactory { GetSourceRepos(get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt new file mode 100644 index 000000000..1140b4eb9 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt @@ -0,0 +1,34 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import tachiyomi.core.preference.plusAssign + +class CreateSourceRepo(private val preferences: SourcePreferences) { + + fun await(name: String): Result { + // Do not allow invalid formats + if (!name.matches(repoRegex)) { + return Result.InvalidName + } + + preferences.extensionRepos() += name + + return Result.Success + } + + sealed class Result { + data object InvalidName : Result() + data object Success : Result() + } + + /** + * Returns true if a repo with the given name already exists. + */ + private fun repoExists(name: String): Boolean { + return preferences.extensionRepos().get().any { it.equals(name, true) } + } + + companion object { + val repoRegex = """^[a-zA-Z0-9-_.]*?\/[a-zA-Z0-9-_.]*?$""".toRegex() + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt new file mode 100644 index 000000000..e8cd4721a --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt @@ -0,0 +1,12 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences + +class DeleteSourceRepos(private val preferences: SourcePreferences) { + + fun await(repos: List) { + preferences.extensionRepos().set( + preferences.extensionRepos().get().filterNot { it in repos }.toSet(), + ) + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt new file mode 100644 index 000000000..25e3b3a21 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt @@ -0,0 +1,12 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetSourceRepos(private val preferences: SourcePreferences) { + + fun subscribe(): Flow> { + return preferences.extensionRepos().changes().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) } + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index 0fe4ce23f..ea00bfc69 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -38,6 +38,8 @@ class SourcePreferences( SetMigrateSorting.Direction.ASCENDING, ) + fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet()) + fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0) fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet()) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 5ab0159d7..3c4b8e2ca 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -116,7 +117,7 @@ fun ExtensionDetailsScreen( ) { paddingValues -> if (state.extension == null) { EmptyScreen( - stringRes = MR.strings.empty_screen, + MR.strings.empty_screen, modifier = Modifier.padding(paddingValues), ) return@Scaffold @@ -149,6 +150,21 @@ private fun ExtensionDetails( contentPadding = contentPadding, ) { when { + extension.isRepoSource -> + item { + val uriHandler = LocalUriHandler.current + WarningBanner( + MR.strings.repo_extension_message, + modifier = Modifier.clickable { + extension.repoUrl ?: return@clickable + uriHandler.openUri( + extension.repoUrl + .replace("https://raw.githubusercontent.com", "https://github.com") + .removeSuffix("/repo/"), + ) + }, + ) + } extension.isUnofficial -> item { WarningBanner(MR.strings.unofficial_extension_message) diff --git a/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt b/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt new file mode 100644 index 000000000..780ad0f0a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt @@ -0,0 +1,60 @@ +package eu.kanade.presentation.category + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import eu.kanade.presentation.category.components.CategoryFloatingActionButton +import eu.kanade.presentation.category.components.repo.SourceRepoContent +import eu.kanade.presentation.category.repos.RepoScreenState +import eu.kanade.presentation.components.AppBar +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.components.material.topSmallPaddingValues +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.util.plus + +@Composable +fun SourceRepoScreen( + state: RepoScreenState.Success, + onClickCreate: () -> Unit, + onClickDelete: (String) -> Unit, + navigateUp: () -> Unit, +) { + val lazyListState = rememberLazyListState() + Scaffold( + topBar = { scrollBehavior -> + AppBar( + navigateUp = navigateUp, + title = stringResource(MR.strings.label_extension_repos), + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + CategoryFloatingActionButton( + lazyListState = lazyListState, + onCreate = onClickCreate, + ) + }, + ) { paddingValues -> + if (state.isEmpty) { + EmptyScreen( + MR.strings.information_empty_repos, + modifier = Modifier.padding(paddingValues), + ) + return@Scaffold + } + + SourceRepoContent( + repos = state.repos, + lazyListState = lazyListState, + paddingValues = paddingValues + topSmallPaddingValues + + PaddingValues(horizontal = MaterialTheme.padding.medium), + onClickDelete = onClickDelete, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt index d7a484c6d..676a5b195 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt @@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import dev.icerock.moko.resources.StringResource import eu.kanade.core.preference.asToggleableState import eu.kanade.presentation.category.visualName import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import tachiyomi.core.preference.CheckboxState import tachiyomi.domain.category.model.Category @@ -40,12 +42,15 @@ import kotlin.time.Duration.Companion.seconds fun CategoryCreateDialog( onDismissRequest: () -> Unit, onCreate: (String) -> Unit, - categories: ImmutableList, + categories: ImmutableList, + title: String, + extraMessage: String? = null, + alreadyExistsError: StringResource = MR.strings.error_category_exists, ) { var name by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { categories.anyWithName(name) } + val nameAlreadyExists = remember(name) { categories.contains(name) } AlertDialog( onDismissRequest = onDismissRequest, @@ -66,25 +71,32 @@ fun CategoryCreateDialog( } }, title = { - Text(text = stringResource(MR.strings.action_add_category)) + Text(text = title) }, text = { - OutlinedTextField( - modifier = Modifier.focusRequester(focusRequester), - value = name, - onValueChange = { name = it }, - label = { Text(text = stringResource(MR.strings.name)) }, - supportingText = { - val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { - MR.strings.error_category_exists - } else { - MR.strings.information_required_plain - } - Text(text = stringResource(msgRes)) - }, - isError = name.isNotEmpty() && nameAlreadyExists, - singleLine = true, - ) + Column { + extraMessage?.let { Text(it) } + + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = name, + onValueChange = { name = it }, + label = { + Text(text = stringResource(MR.strings.name)) + }, + supportingText = { + val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { + alreadyExistsError + } else { + MR.strings.information_required_plain + } + Text(text = stringResource(msgRes)) + }, + isError = name.isNotEmpty() && nameAlreadyExists, + singleLine = true, + ) + } }, ) @@ -99,14 +111,15 @@ fun CategoryCreateDialog( fun CategoryRenameDialog( onDismissRequest: () -> Unit, onRename: (String) -> Unit, - categories: ImmutableList, - category: Category, + categories: ImmutableList, + category: String, + alreadyExistsError: StringResource = MR.strings.error_category_exists, ) { - var name by remember { mutableStateOf(category.name) } + var name by remember { mutableStateOf(category) } var valueHasChanged by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { categories.anyWithName(name) } + val nameAlreadyExists = remember(name) { categories.contains(name) } AlertDialog( onDismissRequest = onDismissRequest, @@ -140,7 +153,7 @@ fun CategoryRenameDialog( label = { Text(text = stringResource(MR.strings.name)) }, supportingText = { val msgRes = if (valueHasChanged && nameAlreadyExists) { - MR.strings.error_category_exists + alreadyExistsError } else { MR.strings.information_required_plain } @@ -163,7 +176,8 @@ fun CategoryRenameDialog( fun CategoryDeleteDialog( onDismissRequest: () -> Unit, onDelete: () -> Unit, - category: Category, + title: String, + text: String, ) { AlertDialog( onDismissRequest = onDismissRequest, @@ -181,10 +195,10 @@ fun CategoryDeleteDialog( } }, title = { - Text(text = stringResource(MR.strings.delete_category)) + Text(text = title) }, text = { - Text(text = stringResource(MR.strings.delete_category_confirmation, category.name)) + Text(text = text) }, ) } @@ -220,7 +234,7 @@ fun CategorySortAlphabeticallyDialog( @Composable fun ChangeCategoryDialog( - initialSelection: List>, + initialSelection: ImmutableList>, onDismissRequest: () -> Unit, onEditCategories: () -> Unit, onConfirm: (List, List) -> Unit, @@ -292,7 +306,7 @@ fun ChangeCategoryDialog( if (index != -1) { val mutableList = selection.toMutableList() mutableList[index] = it.next() - selection = mutableList.toList() + selection = mutableList.toList().toImmutableList() } } Row( @@ -326,7 +340,3 @@ fun ChangeCategoryDialog( }, ) } - -private fun List.anyWithName(name: String): Boolean { - return any { name == it.name } -} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt b/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt new file mode 100644 index 000000000..b3cda3fb1 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt @@ -0,0 +1,79 @@ +package eu.kanade.presentation.category.components.repo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.collections.immutable.ImmutableList +import tachiyomi.presentation.core.components.material.padding + +@Composable +fun SourceRepoContent( + repos: ImmutableList, + lazyListState: LazyListState, + paddingValues: PaddingValues, + onClickDelete: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + state = lazyListState, + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + modifier = modifier, + ) { + items(repos) { repo -> + SourceRepoListItem( + modifier = Modifier.animateItemPlacement(), + repo = repo, + onDelete = { onClickDelete(repo) }, + ) + } + } +} + +@Composable +private fun SourceRepoListItem( + repo: String, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = MaterialTheme.padding.medium, + top = MaterialTheme.padding.medium, + end = MaterialTheme.padding.medium, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "") + Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium)) + } + Row { + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onDelete) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "") + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt new file mode 100644 index 000000000..7a0d089e6 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt @@ -0,0 +1,75 @@ +package eu.kanade.presentation.category.repos + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.category.SourceRepoScreen +import eu.kanade.presentation.category.components.CategoryCreateDialog +import eu.kanade.presentation.category.components.CategoryDeleteDialog +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.LoadingScreen + +class RepoScreen : Screen() { + + @Composable + override fun Content() { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val screenModel = rememberScreenModel { RepoScreenModel() } + + val state by screenModel.state.collectAsState() + + if (state is RepoScreenState.Loading) { + LoadingScreen() + return + } + + val successState = state as RepoScreenState.Success + + SourceRepoScreen( + state = successState, + onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, + onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, + navigateUp = navigator::pop, + ) + + when (val dialog = successState.dialog) { + null -> {} + RepoDialog.Create -> { + CategoryCreateDialog( + onDismissRequest = screenModel::dismissDialog, + onCreate = { screenModel.createRepo(it) }, + categories = successState.repos, + title = stringResource(MR.strings.action_add_repo), + extraMessage = stringResource(MR.strings.action_add_repo_message), + alreadyExistsError = MR.strings.error_repo_exists, + ) + } + is RepoDialog.Delete -> { + CategoryDeleteDialog( + onDismissRequest = screenModel::dismissDialog, + onDelete = { screenModel.deleteRepos(listOf(dialog.repo)) }, + title = stringResource(MR.strings.action_delete_repo), + text = stringResource(MR.strings.delete_repo_confirmation, dialog.repo), + ) + } + } + + LaunchedEffect(Unit) { + screenModel.events.collectLatest { event -> + if (event is RepoEvent.LocalizedMessage) { + context.toast(event.stringRes) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt new file mode 100644 index 000000000..039990b20 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt @@ -0,0 +1,112 @@ +package eu.kanade.presentation.category.repos + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import dev.icerock.moko.resources.StringResource +import eu.kanade.domain.source.interactor.CreateSourceRepo +import eu.kanade.domain.source.interactor.DeleteSourceRepos +import eu.kanade.domain.source.interactor.GetSourceRepos +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import tachiyomi.core.util.lang.launchIO +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class RepoScreenModel( + private val getSourceRepos: GetSourceRepos = Injekt.get(), + private val createSourceRepo: CreateSourceRepo = Injekt.get(), + private val deleteSourceRepos: DeleteSourceRepos = Injekt.get(), +) : StateScreenModel(RepoScreenState.Loading) { + + private val _events: Channel = Channel(Int.MAX_VALUE) + val events = _events.receiveAsFlow() + + init { + screenModelScope.launchIO { + getSourceRepos.subscribe() + .collectLatest { repos -> + mutableState.update { + RepoScreenState.Success( + repos = repos.toImmutableList(), + ) + } + } + } + } + + /** + * Creates and adds a new repo to the database. + * + * @param name The name of the repo to create. + */ + fun createRepo(name: String) { + screenModelScope.launchIO { + when (createSourceRepo.await(name)) { + is CreateSourceRepo.Result.InvalidName -> _events.send(RepoEvent.InvalidName) + else -> {} + } + } + } + + /** + * Deletes the given repos from the database. + * + * @param repos The list of repos to delete. + */ + fun deleteRepos(repos: List) { + screenModelScope.launchIO { + deleteSourceRepos.await(repos) + } + } + + fun showDialog(dialog: RepoDialog) { + mutableState.update { + when (it) { + RepoScreenState.Loading -> it + is RepoScreenState.Success -> it.copy(dialog = dialog) + } + } + } + + fun dismissDialog() { + mutableState.update { + when (it) { + RepoScreenState.Loading -> it + is RepoScreenState.Success -> it.copy(dialog = null) + } + } + } +} + +sealed class RepoEvent { + sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent() + data object InvalidName : LocalizedMessage(MR.strings.invalid_repo_name) + data object InternalError : LocalizedMessage(MR.strings.internal_error) +} + +sealed class RepoDialog { + data object Create : RepoDialog() + data class Delete(val repo: String) : RepoDialog() +} + +sealed class RepoScreenState { + + @Immutable + data object Loading : RepoScreenState() + + @Immutable + data class Success( + val repos: ImmutableList, + val dialog: RepoDialog? = null, + ) : RepoScreenState() { + + val isEmpty: Boolean + get() = repos.isEmpty() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt index 61c1db21e..9fb467075 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -2,16 +2,22 @@ package eu.kanade.presentation.more.settings.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.presentation.category.repos.RepoScreen import eu.kanade.presentation.more.settings.Preference import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import kotlinx.collections.immutable.persistentListOf import tachiyomi.core.i18n.stringResource import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -24,7 +30,11 @@ object SettingsBrowseScreen : SearchableSettings { @Composable override fun getPreferences(): List { val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val sourcePreferences = remember { Injekt.get() } + val reposCount by sourcePreferences.extensionRepos().collectAsState() + return listOf( Preference.PreferenceGroup( title = stringResource(MR.strings.label_sources), @@ -33,6 +43,13 @@ object SettingsBrowseScreen : SearchableSettings { pref = sourcePreferences.hideInLibraryItems(), title = stringResource(MR.strings.pref_hide_in_library_items), ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.label_extension_repos), + subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size), + onClick = { + navigator.push(RepoScreen()) + }, + ), ), ), Preference.PreferenceGroup( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index 3032aee03..184f4ef45 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context +import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult @@ -24,6 +25,7 @@ internal class ExtensionGithubApi { private val networkService: NetworkHelper by injectLazy() private val preferenceStore: PreferenceStore by injectLazy() + private val sourcePreferences: SourcePreferences by injectLazy() private val extensionManager: ExtensionManager by injectLazy() private val json: Json by injectLazy() @@ -58,7 +60,20 @@ internal class ExtensionGithubApi { val extensions = with(json) { response .parseAs>() - .toExtensions() + .toExtensions() + sourcePreferences.extensionRepos() + .get() + .flatMap { repoPath -> + val url = if (requiresFallbackSource) { + "$FALLBACK_BASE_URL$repoPath@repo/" + } else { + "$BASE_URL$repoPath/repo/" + } + networkService.client + .newCall(GET("${url}index.min.json")) + .awaitSuccess() + .parseAs>() + .toExtensions(url, repoSource = true) + } } // Sanity check - a small number of extensions probably means something broke @@ -71,10 +86,7 @@ internal class ExtensionGithubApi { } } - suspend fun checkForUpdates( - context: Context, - fromAvailableExtensionList: Boolean = false, - ): List? { + suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List? { // Limit checks to once a day at most if (!fromAvailableExtensionList && Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds @@ -111,7 +123,10 @@ internal class ExtensionGithubApi { return extensionsWithUpdate } - private fun List.toExtensions(): List { + private fun List.toExtensions( + repoUrl: String = getUrlPrefix(), + repoSource: Boolean = false, + ): List { return this .filter { val libVersion = it.extractLibVersion() @@ -128,13 +143,15 @@ internal class ExtensionGithubApi { isNsfw = it.nsfw == 1, sources = it.sources?.map(extensionSourceMapper).orEmpty(), apkName = it.apk, - iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", + iconUrl = "${repoUrl}icon/${it.pkg}.png", + repoUrl = repoUrl, + isRepoSource = repoSource, ) } } fun getApkUrl(extension: Extension.Available): String { - return "${getUrlPrefix()}apk/${extension.apkName}" + return "${extension.repoUrl}/apk/${extension.apkName}" } private fun getUrlPrefix(): String { @@ -150,8 +167,10 @@ internal class ExtensionGithubApi { } } -private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/" -private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/" +private const val BASE_URL = "https://raw.githubusercontent.com/" +private const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/" +private const val FALLBACK_BASE_URL = "https://gcore.jsdelivr.net/gh/" +private const val FALLBACK_REPO_URL_PREFIX = "${FALLBACK_BASE_URL}tachiyomiorg/tachiyomi-extensions@repo/" @Serializable private data class ExtensionJsonObject( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index 7f4a316aa..4dbf09a26 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -29,6 +29,8 @@ sealed class Extension { val isObsolete: Boolean = false, val isUnofficial: Boolean = false, val isShared: Boolean, + val repoUrl: String? = null, + val isRepoSource: Boolean = false, ) : Extension() data class Available( @@ -42,6 +44,8 @@ sealed class Extension { val sources: List, val apkName: String, val iconUrl: String, + val repoUrl: String, + val isRepoSource: Boolean, ) : Extension() { data class Source( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 4d0c49404..4b0835850 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -265,7 +267,10 @@ class BrowseSourceScreenModel( else -> { val preselectedIds = getCategories.await(manga.id).map { it.id } setDialog( - Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }), + Dialog.ChangeMangaCategory( + manga, + categories.mapAsCheckboxState { it.id in preselectedIds }.toImmutableList(), + ), ) } } @@ -338,7 +343,7 @@ class BrowseSourceScreenModel( data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class ChangeMangaCategory( val manga: Manga, - val initialSelection: List>, + val initialSelection: ImmutableList>, ) : Dialog data class Migrate(val newManga: Manga) : Dialog } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt index bc9fbd4a5..dcd0246bb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.util.fastMap import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -15,7 +16,10 @@ import eu.kanade.presentation.category.components.CategoryRenameDialog import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.util.system.toast +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.collectLatest +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen class CategoryScreen : Screen() { @@ -52,22 +56,24 @@ class CategoryScreen : Screen() { CategoryCreateDialog( onDismissRequest = screenModel::dismissDialog, onCreate = screenModel::createCategory, - categories = successState.categories, + categories = successState.categories.fastMap { it.name }.toImmutableList(), + title = stringResource(MR.strings.action_add_category), ) } is CategoryDialog.Rename -> { CategoryRenameDialog( onDismissRequest = screenModel::dismissDialog, onRename = { screenModel.renameCategory(dialog.category, it) }, - categories = successState.categories, - category = dialog.category, + categories = successState.categories.fastMap { it.name }.toImmutableList(), + category = dialog.category.name, ) } is CategoryDialog.Delete -> { CategoryDeleteDialog( onDismissRequest = screenModel::dismissDialog, onDelete = { screenModel.deleteCategory(dialog.category.id) }, - category = dialog.category, + title = stringResource(MR.strings.delete_category), + text = stringResource(MR.strings.delete_category_confirmation, dialog.category.name), ) } is CategoryDialog.SortAlphabetically -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index a19753a84..105f1e2b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -28,9 +28,11 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -661,13 +663,15 @@ class LibraryScreenModel( val common = getCommonCategories(mangaList) // Get indexes of the mix categories to preselect. val mix = getMixCategories(mangaList) - val preselected = categories.map { - when (it) { - in common -> CheckboxState.State.Checked(it) - in mix -> CheckboxState.TriState.Exclude(it) - else -> CheckboxState.State.None(it) + val preselected = categories + .map { + when (it) { + in common -> CheckboxState.State.Checked(it) + in mix -> CheckboxState.TriState.Exclude(it) + else -> CheckboxState.State.None(it) + } } - } + .toImmutableList() mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) } } } @@ -683,7 +687,10 @@ class LibraryScreenModel( sealed interface Dialog { data object SettingsSheet : Dialog - data class ChangeCategory(val manga: List, val initialSelection: List>) : Dialog + data class ChangeCategory( + val manga: List, + val initialSelection: ImmutableList>, + ) : Dialog data class DeleteManga(val manga: List) : Dialog } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index aa85aec5a..e7446e600 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.shouldDownloadNewChapters +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.catch @@ -360,7 +362,7 @@ class MangaScreenModel( successState.copy( dialog = Dialog.ChangeCategory( manga = manga, - initialSelection = categories.mapAsCheckboxState { it.id in selection }, + initialSelection = categories.mapAsCheckboxState { it.id in selection }.toImmutableList(), ), ) } @@ -992,7 +994,10 @@ class MangaScreenModel( // Track sheet - end sealed interface Dialog { - data class ChangeCategory(val manga: Manga, val initialSelection: List>) : Dialog + data class ChangeCategory( + val manga: Manga, + val initialSelection: ImmutableList>, + ) : Dialog data class DeleteChapters(val chapters: List) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class SetFetchInterval(val manga: Manga) : Dialog diff --git a/i18n/src/commonMain/resources/MR/base/plurals.xml b/i18n/src/commonMain/resources/MR/base/plurals.xml index 21b544618..2f10b004c 100644 --- a/i18n/src/commonMain/resources/MR/base/plurals.xml +++ b/i18n/src/commonMain/resources/MR/base/plurals.xml @@ -80,4 +80,9 @@ Extension update available %d extension updates available + + + %d repo + %d repos + diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index df8d826a6..a724bac5e 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -336,6 +336,17 @@ Shizuku is not running Install and start Shizuku to use Shizuku as extension installer. + + Extension repos + You have no repos set. + Add repo + Add additional repos to Tachiyomi, the format of a repo is \"username/repo\", with username being the repo owner, and repo being the repo name. + This repo already exists! + Delete repo + Invalid repo name + Do you wish to delete the repo \"%s\"? + This extension is from an external repo. Tap to view the repo. + Fullscreen Show tap zones overlay From dba5e6fbfdf65f0482f681b22defb759e9b32437 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 17:36:59 -0500 Subject: [PATCH 08/12] Revert "Implement predictive back animation (#10273)" This reverts commit 9c120e623193271971448fb03665a73dff4f85cb. Potentially too buggy for a stable release for now. --- .../kanade/presentation/manga/MangaScreen.kt | 10 +- .../manga/components/MangaCoverDialog.kt | 33 +- .../eu/kanade/presentation/util/Navigator.kt | 354 +----------------- .../eu/kanade/tachiyomi/ui/home/HomeScreen.kt | 49 +-- .../kanade/tachiyomi/ui/main/MainActivity.kt | 10 +- .../tachiyomi/ui/setting/SettingsScreen.kt | 2 - .../tachiyomi/util/view/ViewExtensions.kt | 21 -- .../core/components/AdaptiveSheet.kt | 63 +--- 8 files changed, 32 insertions(+), 510 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 444af9aec..9a6e3f6fd 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -273,10 +273,7 @@ private fun MangaScreenSmallImpl( onBackClicked() } } - BackHandler( - enabled = isAnySelected, - onBack = { onAllChapterSelected(false) }, - ) + BackHandler(onBack = internalOnBackPressed) Scaffold( topBar = { @@ -530,10 +527,7 @@ fun MangaScreenLargeImpl( onBackClicked() } } - BackHandler( - enabled = isAnySelected, - onBack = { onAllChapterSelected(false) }, - ) + BackHandler(onBack = internalOnBackPressed) Scaffold( topBar = { diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index 3b5d7e558..b3c06a979 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -3,10 +3,6 @@ package eu.kanade.presentation.manga.components import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build -import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -29,18 +25,15 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -55,13 +48,11 @@ import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import kotlinx.collections.immutable.persistentListOf -import soup.compose.material.motion.MotionConstants import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication -import kotlin.coroutines.cancellation.CancellationException @Composable fun MangaCoverDialog( @@ -160,32 +151,10 @@ fun MangaCoverDialog( val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } - var scale by remember { mutableFloatStateOf(1f) } - PredictiveBackHandler { progress -> - try { - progress.collect { backEvent -> - scale = lerp(1f, 0.8f, LinearOutSlowInEasing.transform(backEvent.progress)) - } - onDismissRequest() - } catch (e: CancellationException) { - animate( - initialValue = scale, - targetValue = 1f, - animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), - ) { value, _ -> - scale = value - } - } - } - Box( modifier = Modifier .fillMaxSize() - .clickableNoIndication(onClick = onDismissRequest) - .graphicsLayer { - scaleX = scale - scaleY = scale - }, + .clickableNoIndication(onClick = onDismissRequest), ) { AndroidView( factory = { diff --git a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt index 8083bc0af..86311d8e0 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -1,54 +1,13 @@ package eu.kanade.presentation.util import android.annotation.SuppressLint -import androidx.activity.BackEventCompat -import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toSize -import androidx.compose.ui.util.lerp -import androidx.compose.ui.zIndex import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.screen.Screen @@ -57,25 +16,14 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.ScreenTransitionContent -import eu.kanade.tachiyomi.util.view.getWindowRadius import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialSharedAxisX import soup.compose.material.motion.animation.rememberSlideDistance -import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.PI -import kotlin.math.sin /** * For invoking back press to the parent activity @@ -109,299 +57,17 @@ interface AssistContentScreen { } @Composable -fun DefaultNavigatorScreenTransition( - navigator: Navigator, - modifier: Modifier = Modifier, -) { - val scope = rememberCoroutineScope() - val view = LocalView.current - val handler = remember { - OnBackHandler( - scope = scope, - windowCornerRadius = view.getWindowRadius(), - onBackPressed = navigator::pop, - ) - } - PredictiveBackHandler(enabled = navigator.canPop) { progress -> - progress - .onStart { handler.reset() } - .onCompletion { e -> - if (e == null) { - handler.onBackConfirmed() - } else { - handler.onBackCancelled() - } - } - .collect(handler::onBackEvent) - } - - Box(modifier = modifier.onSizeChanged { handler.updateContainerSize(it.toSize()) }) { - val currentSceneEntry = navigator.lastItem - val showPrev by remember { - derivedStateOf { handler.scale < 1f || handler.translationY != 0f } - } - val visibleItems = remember(currentSceneEntry, showPrev) { - if (showPrev) { - val prevSceneEntry = navigator.items.getOrNull(navigator.size - 2) - listOfNotNull(currentSceneEntry, prevSceneEntry) - } else { - listOfNotNull(currentSceneEntry) - } - } - - val slideDistance = rememberSlideDistance() - - val screenContent = remember { - movableContentOf { screen -> - navigator.saveableState("transition", screen) { - screen.Content() - } - } - } - - visibleItems.forEachIndexed { index, backStackEntry -> - val isPrev = index == 1 && visibleItems.size > 1 - if (!isPrev) { - AnimatedContent( - targetState = backStackEntry, - transitionSpec = { - val forward = navigator.lastEvent != StackEvent.Pop - if (!forward && !handler.isReady) { - // Pop screen without animation when predictive back is in use - EnterTransition.None togetherWith ExitTransition.None - } else { - materialSharedAxisX( - forward = forward, - slideDistance = slideDistance, - ) - } - }, - modifier = Modifier - .zIndex(1f) - .graphicsLayer { - this.alpha = handler.alpha - this.transformOrigin = TransformOrigin( - pivotFractionX = if (handler.swipeEdge == BackEventCompat.EDGE_LEFT) 0.8f else 0.2f, - pivotFractionY = 0.5f, - ) - this.scaleX = handler.scale - this.scaleY = handler.scale - this.translationY = handler.translationY - this.clip = true - this.shape = if (showPrev) { - RoundedCornerShape(handler.windowCornerRadius.toFloat()) - } else { - RectangleShape - } - } - .then( - if (showPrev) { - Modifier.pointerInput(Unit) { - // Animated content should not be interactive - } - } else { - Modifier - }, - ), - content = { - if (visibleItems.size == 2 && visibleItems.getOrNull(1) == it) { - // Avoid drawing previous screen - return@AnimatedContent - } - screenContent(it) - }, - ) - } else { - Box( - modifier = Modifier - .zIndex(0f) - .drawWithCache { - val bounds = Rect(Offset.Zero, size) - val matrix = ColorMatrix().apply { - // Reduce saturation and brightness - setToSaturation(lerp(1f, 0.95f, handler.alpha)) - set(0, 4, lerp(0f, -25f, handler.alpha)) - set(1, 4, lerp(0f, -25f, handler.alpha)) - set(2, 4, lerp(0f, -25f, handler.alpha)) - } - val paint = Paint().apply { colorFilter = ColorFilter.colorMatrix(matrix) } - onDrawWithContent { - drawIntoCanvas { - it.saveLayer(bounds, paint) - drawContent() - it.restore() - } - } - } - .graphicsLayer { - val blurRadius = 5.dp.toPx() * handler.alpha - renderEffect = if (blurRadius > 0f) { - BlurEffect(blurRadius, blurRadius) - } else { - null - } - } - .pointerInput(Unit) { - // bg content should not be interactive - }, - content = { screenContent(backStackEntry) }, - ) - } - } - - LaunchedEffect(currentSceneEntry) { - // Reset *after* the screen is popped successfully - // so that the correct transition is applied - handler.setReady() - } - } -} - -@Stable -private class OnBackHandler( - private val scope: CoroutineScope, - val windowCornerRadius: Int, - private val onBackPressed: () -> Unit, -) { - - var isReady = true - private set - - var alpha by mutableFloatStateOf(1f) - private set - - var scale by mutableFloatStateOf(1f) - private set - - var translationY by mutableFloatStateOf(0f) - private set - - var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT) - private set - - private var containerSize = Size.Zero - private var startPointY = Float.NaN - - var isPredictiveBack by mutableStateOf(false) - private set - - private var animationJob: Job? = null - set(value) { - isReady = false - field = value - } - - fun updateContainerSize(size: Size) { - containerSize = size - } - - fun setReady() { - reset() - animationJob?.cancel() - animationJob = null - isReady = true - isPredictiveBack = false - } - - fun reset() { - startPointY = Float.NaN - } - - fun onBackEvent(backEvent: BackEventCompat) { - if (!isReady) return - isPredictiveBack = true - swipeEdge = backEvent.swipeEdge - - val progress = LinearOutSlowInEasing.transform(backEvent.progress) - scale = lerp(1f, 0.85f, progress) - - if (startPointY.isNaN()) { - startPointY = backEvent.touchY - } - val deltaYRatio = (backEvent.touchY - startPointY) / containerSize.height - val translateYDistance = containerSize.height / 20 - translationY = sin(deltaYRatio * PI * 0.5).toFloat() * translateYDistance * progress - } - - fun onBackConfirmed() { - if (!isReady) return - if (isPredictiveBack) { - // Continue predictive animation and pop the screen - val animationSpec = tween( - durationMillis = MotionConstants.DefaultMotionDuration, - easing = FastOutSlowInEasing, +fun DefaultNavigatorScreenTransition(navigator: Navigator) { + val slideDistance = rememberSlideDistance() + ScreenTransition( + navigator = navigator, + transition = { + materialSharedAxisX( + forward = navigator.lastEvent != StackEvent.Pop, + slideDistance = slideDistance, ) - animationJob = scope.launch { - try { - listOf( - async { - animate( - initialValue = alpha, - targetValue = 0f, - animationSpec = animationSpec, - ) { value, _ -> - alpha = value - } - }, - async { - animate( - initialValue = scale, - targetValue = scale - 0.05f, - animationSpec = animationSpec, - ) { value, _ -> - scale = value - } - }, - ).awaitAll() - } catch (e: CancellationException) { - // no-op - } finally { - onBackPressed() - alpha = 1f - translationY = 0f - scale = 1f - } - } - } else { - // Pop right away and use default transition - onBackPressed() - } - } - - fun onBackCancelled() { - // Reset states - isPredictiveBack = false - animationJob = scope.launch { - listOf( - async { - animate( - initialValue = scale, - targetValue = 1f, - ) { value, _ -> - scale = value - } - }, - async { - animate( - initialValue = alpha, - targetValue = 1f, - ) { value, _ -> - alpha = value - } - }, - async { - animate( - initialValue = translationY, - targetValue = 0f, - ) { value, _ -> - translationY = value - } - }, - ).awaitAll() - - isReady = true - } - } + }, + ) } @Composable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index 7e61985d5..ff2cb7075 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -1,11 +1,8 @@ package eu.kanade.tachiyomi.ui.home -import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith @@ -26,20 +23,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.util.lerp import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.LocalTabNavigator @@ -59,7 +49,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughOut import tachiyomi.domain.library.service.LibraryPreferences @@ -70,7 +59,6 @@ import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.pluralStringResource import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import kotlin.coroutines.cancellation.CancellationException object HomeScreen : Screen() { @@ -92,8 +80,6 @@ object HomeScreen : Screen() { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow - var scale by remember { mutableFloatStateOf(1f) } - TabNavigator( tab = LibraryTab, key = TabNavigatorKey, @@ -132,11 +118,6 @@ object HomeScreen : Screen() { ) { contentPadding -> Box( modifier = Modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - transformOrigin = TransformOrigin(0.5f, 1f) - } .padding(contentPadding) .consumeWindowInsets(contentPadding), ) { @@ -157,30 +138,10 @@ object HomeScreen : Screen() { } val goToLibraryTab = { tabNavigator.current = LibraryTab } - - var handlingBack by remember { mutableStateOf(false) } - PredictiveBackHandler(enabled = handlingBack || tabNavigator.current != LibraryTab) { progress -> - handlingBack = true - val currentTab = tabNavigator.current - try { - progress.collect { backEvent -> - scale = lerp(1f, 0.92f, LinearOutSlowInEasing.transform(backEvent.progress)) - tabNavigator.current = if (backEvent.progress > 0.25f) tabs[0] else currentTab - } - goToLibraryTab() - } catch (e: CancellationException) { - tabNavigator.current = currentTab - } finally { - animate( - initialValue = scale, - targetValue = 1f, - animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), - ) { value, _ -> - scale = value - } - handlingBack = false - } - } + BackHandler( + enabled = tabNavigator.current != LibraryTab, + onBack = goToLibraryTab, + ) LaunchedEffect(Unit) { launch { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 7184388a8..78c688c2e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.view.View import androidx.activity.ComponentActivity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -222,13 +223,14 @@ class MainActivity : BaseActivity() { contentWindowInsets = scaffoldInsets, ) { contentPadding -> // Consume insets already used by app state banners - // Shows current screen - DefaultNavigatorScreenTransition( - navigator = navigator, + Box( modifier = Modifier .padding(contentPadding) .consumeWindowInsets(contentPadding), - ) + ) { + // Shows current screen + DefaultNavigatorScreenTransition(navigator = navigator) + } } // Pop source-related screens when incognito mode is turned off diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt index 3ba5f6dec..3d396ace9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt @@ -40,7 +40,6 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsMainScreen }, - onBackPressed = null, content = { val pop: () -> Unit = { if (it.canPop) { @@ -62,7 +61,6 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsAppearanceScreen }, - onBackPressed = null, ) { val insets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) TwoPanelBox( diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index 7e8651111..60d357a53 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -4,11 +4,9 @@ package eu.kanade.tachiyomi.util.view import android.content.res.Resources import android.graphics.Rect -import android.os.Build import android.view.Gravity import android.view.Menu import android.view.MenuItem -import android.view.RoundedCorner import android.view.View import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -97,22 +95,3 @@ fun View?.isVisibleOnScreen(): Boolean { Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels) return actualPosition.intersect(screen) } - -/** - * Returns window radius (in pixel) applied to this view - */ -fun View.getWindowRadius(): Int { - val rad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val windowInsets = rootWindowInsets - listOfNotNull( - windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT), - windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT), - windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT), - windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT), - ) - .minOfOrNull { it.radius } - } else { - null - } - return rad ?: 0 -} diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt index 7e770fb43..d36e2593f 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt @@ -1,11 +1,7 @@ package tachiyomi.presentation.core.components -import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animate +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.AnchoredDraggableState @@ -30,7 +26,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -39,11 +34,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -53,14 +45,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt +private val sheetAnimationSpec = tween(durationMillis = 350) + @Composable fun AdaptiveSheet( isTabletUi: Boolean, @@ -99,11 +91,6 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier - .predictiveBackAnimation( - enabled = remember { derivedStateOf { alpha > 0f } }.value, - transformOrigin = TransformOrigin.Center, - onBack = internalOnDismissRequest, - ) .requiredWidthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -116,6 +103,7 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { + BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest) content() }, ) @@ -157,11 +145,6 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier - .predictiveBackAnimation( - enabled = anchoredDraggableState.targetValue == 0, - transformOrigin = TransformOrigin(0.5f, 1f), - onBack = internalOnDismissRequest, - ) .widthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -201,6 +184,10 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { + BackHandler( + enabled = anchoredDraggableState.targetValue == 0, + onBack = internalOnDismissRequest, + ) content() }, ) @@ -270,37 +257,3 @@ private fun AnchoredDraggableState.preUpPostDownNestedScrollConnection() @JvmName("offsetToFloat") private fun Offset.toFloat(): Float = this.y } - -private fun Modifier.predictiveBackAnimation( - enabled: Boolean, - transformOrigin: TransformOrigin, - onBack: () -> Unit, -) = composed { - var scale by remember { mutableFloatStateOf(1f) } - PredictiveBackHandler(enabled = enabled) { progress -> - try { - progress.collect { backEvent -> - scale = lerp(1f, 0.85f, LinearOutSlowInEasing.transform(backEvent.progress)) - } - // Completion - onBack() - } catch (e: CancellationException) { - // Cancellation - } finally { - animate( - initialValue = scale, - targetValue = 1f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - ) { value, _ -> - scale = value - } - } - } - Modifier.graphicsLayer { - this.scaleX = scale - this.scaleY = scale - this.transformOrigin = transformOrigin - } -} - -private val sheetAnimationSpec = tween(durationMillis = 350) From 850813820c44b1a15484ed9a39ec4feaf5f990d7 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 17:38:34 -0500 Subject: [PATCH 09/12] Disable customized fetch intervals for stable builds for now Until some of the issues get ironed out. --- .../kanade/presentation/manga/components/MangaDialogs.kt | 8 +++++--- .../presentation/manga/components/MangaInfoHeader.kt | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt index ace822cd1..655601a0a 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.util.system.isDevFlavor +import eu.kanade.tachiyomi.util.system.isPreviewBuildType import kotlinx.collections.immutable.toImmutableList import tachiyomi.domain.manga.interactor.FetchInterval import tachiyomi.i18n.MR @@ -72,13 +74,12 @@ fun SetIntervalDialog( val nextUpdateDays = remember(nextUpdate) { return@remember if (nextUpdate != null) { val now = Instant.now() - now.until(nextUpdate, ChronoUnit.DAYS).toInt() + now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0) } else { null } } - // TODO: selecting "1" then doesn't allow for future changes unless defaulting first? AlertDialog( onDismissRequest = onDismissRequest, title = { Text(stringResource(MR.strings.pref_library_update_smart_update)) }, @@ -104,7 +105,8 @@ fun SetIntervalDialog( Spacer(Modifier.height(MaterialTheme.padding.small)) } - if (onValueChanged != null) { + // TODO: selecting "1" then doesn't allow for future changes unless defaulting first? + if (onValueChanged != null && (isDevFlavor || isPreviewBuildType)) { Text(stringResource(MR.strings.manga_interval_custom_amount)) BoxWithConstraints( diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt index 283f71bbb..f1d228534 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt @@ -182,7 +182,7 @@ fun MangaActionRow( val nextUpdateDays = remember(nextUpdate) { return@remember if (nextUpdate != null) { val now = Instant.now() - now.until(nextUpdate, ChronoUnit.DAYS).toInt() + now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0) } else { null } From 556f5a42a77fc355819919f91a9436902b25c789 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 17:49:19 -0500 Subject: [PATCH 10/12] Fix lint error --- .../eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index 184f4ef45..2bf6a708e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -86,7 +86,10 @@ internal class ExtensionGithubApi { } } - suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List? { + suspend fun checkForUpdates( + context: Context, + fromAvailableExtensionList: Boolean = false, + ): List? { // Limit checks to once a day at most if (!fromAvailableExtensionList && Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds From 9c899e97a97480545d022974ffd3ea1248634155 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 23:13:16 -0500 Subject: [PATCH 11/12] Clean up external repos - Accept full URL as input instead, which allows for non-GitHub - Remove automatic CDN fallback in favor of adding that as an external repo if needed --- app/build.gradle.kts | 2 +- .../java/eu/kanade/domain/DomainModule.kt | 4 +- .../source/interactor/CreateSourceRepo.kt | 26 ++-- .../source/interactor/DeleteSourceRepo.kt | 11 ++ .../source/interactor/DeleteSourceRepos.kt | 12 -- .../source/interactor/GetSourceRepos.kt | 3 +- .../category/components/CategoryDialogs.kt | 58 ++++----- .../category/components/CategoryListItem.kt | 6 +- .../settings/screen/SettingsBrowseScreen.kt | 4 +- .../screen/browse/ExtensionReposScreen.kt} | 28 ++--- .../browse/ExtensionReposScreenModel.kt} | 21 ++-- .../components/ExtensionReposContent.kt} | 20 +-- .../components/ExtensionReposDialogs.kt | 117 ++++++++++++++++++ .../components/ExtensionReposScreen.kt} | 11 +- .../java/eu/kanade/tachiyomi/Migrations.kt | 5 + .../tachiyomi/extension/ExtensionManager.kt | 4 +- ...{ExtensionGithubApi.kt => ExtensionApi.kt} | 88 +++++-------- .../tachiyomi/ui/category/CategoryScreen.kt | 6 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 5 +- 20 files changed, 252 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt delete mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt rename app/src/main/java/eu/kanade/presentation/{category/repos/RepoScreen.kt => more/settings/screen/browse/ExtensionReposScreen.kt} (65%) rename app/src/main/java/eu/kanade/presentation/{category/repos/RepoScreenModel.kt => more/settings/screen/browse/ExtensionReposScreenModel.kt} (81%) rename app/src/main/java/eu/kanade/presentation/{category/components/repo/SourceRepoContent.kt => more/settings/screen/browse/components/ExtensionReposContent.kt} (87%) create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt rename app/src/main/java/eu/kanade/presentation/{category/SourceRepoScreen.kt => more/settings/screen/browse/components/ExtensionReposScreen.kt} (89%) rename app/src/main/java/eu/kanade/tachiyomi/extension/api/{ExtensionGithubApi.kt => ExtensionApi.kt} (69%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96c7ac385..b9daa2cf7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 113 + versionCode = 114 versionName = "0.14.7" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 6ef3a4446..5bcaf48e6 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -12,7 +12,7 @@ import eu.kanade.domain.manga.interactor.SetExcludedScanlators import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.source.interactor.CreateSourceRepo -import eu.kanade.domain.source.interactor.DeleteSourceRepos +import eu.kanade.domain.source.interactor.DeleteSourceRepo import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetLanguagesWithSources import eu.kanade.domain.source.interactor.GetSourceRepos @@ -172,7 +172,7 @@ class DomainModule : InjektModule { addFactory { ToggleSourcePin(get()) } addFactory { CreateSourceRepo(get()) } - addFactory { DeleteSourceRepos(get()) } + addFactory { DeleteSourceRepo(get()) } addFactory { GetSourceRepos(get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt index 1140b4eb9..e22d8980f 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt @@ -7,28 +7,20 @@ class CreateSourceRepo(private val preferences: SourcePreferences) { fun await(name: String): Result { // Do not allow invalid formats - if (!name.matches(repoRegex)) { - return Result.InvalidName + if (!name.matches(repoRegex) || name.startsWith(OFFICIAL_REPO_BASE_URL)) { + return Result.InvalidUrl } - preferences.extensionRepos() += name + preferences.extensionRepos() += name.substringBeforeLast("/index.min.json") return Result.Success } - sealed class Result { - data object InvalidName : Result() - data object Success : Result() - } - - /** - * Returns true if a repo with the given name already exists. - */ - private fun repoExists(name: String): Boolean { - return preferences.extensionRepos().get().any { it.equals(name, true) } - } - - companion object { - val repoRegex = """^[a-zA-Z0-9-_.]*?\/[a-zA-Z0-9-_.]*?$""".toRegex() + sealed interface Result { + data object InvalidUrl : Result + data object Success : Result } } + +const val OFFICIAL_REPO_BASE_URL = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo" +private val repoRegex = """^https://.*/index\.min\.json$""".toRegex() diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt new file mode 100644 index 000000000..1bf109895 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt @@ -0,0 +1,11 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import tachiyomi.core.preference.minusAssign + +class DeleteSourceRepo(private val preferences: SourcePreferences) { + + fun await(repo: String) { + preferences.extensionRepos() -= repo + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt deleted file mode 100644 index e8cd4721a..000000000 --- a/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt +++ /dev/null @@ -1,12 +0,0 @@ -package eu.kanade.domain.source.interactor - -import eu.kanade.domain.source.service.SourcePreferences - -class DeleteSourceRepos(private val preferences: SourcePreferences) { - - fun await(repos: List) { - preferences.extensionRepos().set( - preferences.extensionRepos().get().filterNot { it in repos }.toSet(), - ) - } -} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt index 25e3b3a21..fdebe8147 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.map class GetSourceRepos(private val preferences: SourcePreferences) { fun subscribe(): Flow> { - return preferences.extensionRepos().changes().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) } + return preferences.extensionRepos().changes() + .map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) } } } diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt index 676a5b195..fd6396b95 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import dev.icerock.moko.resources.StringResource import eu.kanade.core.preference.asToggleableState import eu.kanade.presentation.category.visualName import kotlinx.collections.immutable.ImmutableList @@ -43,9 +42,6 @@ fun CategoryCreateDialog( onDismissRequest: () -> Unit, onCreate: (String) -> Unit, categories: ImmutableList, - title: String, - extraMessage: String? = null, - alreadyExistsError: StringResource = MR.strings.error_category_exists, ) { var name by remember { mutableStateOf("") } @@ -71,32 +67,28 @@ fun CategoryCreateDialog( } }, title = { - Text(text = title) + Text(text = stringResource(MR.strings.action_add_category)) }, text = { - Column { - extraMessage?.let { Text(it) } - - OutlinedTextField( - modifier = Modifier - .focusRequester(focusRequester), - value = name, - onValueChange = { name = it }, - label = { - Text(text = stringResource(MR.strings.name)) - }, - supportingText = { - val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { - alreadyExistsError - } else { - MR.strings.information_required_plain - } - Text(text = stringResource(msgRes)) - }, - isError = name.isNotEmpty() && nameAlreadyExists, - singleLine = true, - ) - } + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = name, + onValueChange = { name = it }, + label = { + Text(text = stringResource(MR.strings.name)) + }, + supportingText = { + val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { + MR.strings.error_category_exists + } else { + MR.strings.information_required_plain + } + Text(text = stringResource(msgRes)) + }, + isError = name.isNotEmpty() && nameAlreadyExists, + singleLine = true, + ) }, ) @@ -113,7 +105,6 @@ fun CategoryRenameDialog( onRename: (String) -> Unit, categories: ImmutableList, category: String, - alreadyExistsError: StringResource = MR.strings.error_category_exists, ) { var name by remember { mutableStateOf(category) } var valueHasChanged by remember { mutableStateOf(false) } @@ -153,7 +144,7 @@ fun CategoryRenameDialog( label = { Text(text = stringResource(MR.strings.name)) }, supportingText = { val msgRes = if (valueHasChanged && nameAlreadyExists) { - alreadyExistsError + MR.strings.error_category_exists } else { MR.strings.information_required_plain } @@ -176,8 +167,7 @@ fun CategoryRenameDialog( fun CategoryDeleteDialog( onDismissRequest: () -> Unit, onDelete: () -> Unit, - title: String, - text: String, + category: String, ) { AlertDialog( onDismissRequest = onDismissRequest, @@ -195,10 +185,10 @@ fun CategoryDeleteDialog( } }, title = { - Text(text = title) + Text(text = stringResource(MR.strings.delete_category)) }, text = { - Text(text = text) + Text(text = stringResource(MR.strings.delete_category_confirmation, category)) }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt index e2d5c4261..5c387e542 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt @@ -49,7 +49,7 @@ fun CategoryListItem( ), verticalAlignment = Alignment.CenterVertically, ) { - Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "") + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) Text( text = category.name, modifier = Modifier @@ -61,13 +61,13 @@ fun CategoryListItem( onClick = { onMoveUp(category) }, enabled = canMoveUp, ) { - Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "") + Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null) } IconButton( onClick = { onMoveDown(category) }, enabled = canMoveDown, ) { - Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "") + Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null) } Spacer(modifier = Modifier.weight(1f)) IconButton(onClick = onRename) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt index 9fb467075..57508ecbd 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -9,8 +9,8 @@ import androidx.fragment.app.FragmentActivity import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.presentation.category.repos.RepoScreen import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import kotlinx.collections.immutable.persistentListOf import tachiyomi.core.i18n.stringResource @@ -47,7 +47,7 @@ object SettingsBrowseScreen : SearchableSettings { title = stringResource(MR.strings.label_extension_repos), subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size), onClick = { - navigator.push(RepoScreen()) + navigator.push(ExtensionReposScreen()) }, ), ), diff --git a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt similarity index 65% rename from app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt rename to app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt index 7a0d089e6..0cf4f027d 100644 --- a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.category.repos +package eu.kanade.presentation.more.settings.screen.browse import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -8,23 +8,21 @@ import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import eu.kanade.presentation.category.SourceRepoScreen -import eu.kanade.presentation.category.components.CategoryCreateDialog -import eu.kanade.presentation.category.components.CategoryDeleteDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.flow.collectLatest -import tachiyomi.i18n.MR -import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen -class RepoScreen : Screen() { +class ExtensionReposScreen : Screen() { @Composable override fun Content() { val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val screenModel = rememberScreenModel { RepoScreenModel() } + val screenModel = rememberScreenModel { ExtensionReposScreenModel() } val state by screenModel.state.collectAsState() @@ -35,7 +33,7 @@ class RepoScreen : Screen() { val successState = state as RepoScreenState.Success - SourceRepoScreen( + ExtensionReposScreen( state = successState, onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, @@ -45,21 +43,17 @@ class RepoScreen : Screen() { when (val dialog = successState.dialog) { null -> {} RepoDialog.Create -> { - CategoryCreateDialog( + ExtensionRepoCreateDialog( onDismissRequest = screenModel::dismissDialog, onCreate = { screenModel.createRepo(it) }, categories = successState.repos, - title = stringResource(MR.strings.action_add_repo), - extraMessage = stringResource(MR.strings.action_add_repo_message), - alreadyExistsError = MR.strings.error_repo_exists, ) } is RepoDialog.Delete -> { - CategoryDeleteDialog( + ExtensionRepoDeleteDialog( onDismissRequest = screenModel::dismissDialog, - onDelete = { screenModel.deleteRepos(listOf(dialog.repo)) }, - title = stringResource(MR.strings.action_delete_repo), - text = stringResource(MR.strings.delete_repo_confirmation, dialog.repo), + onDelete = { screenModel.deleteRepo(dialog.repo) }, + repo = dialog.repo, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt similarity index 81% rename from app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt rename to app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt index 039990b20..fc1d3893c 100644 --- a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt @@ -1,11 +1,11 @@ -package eu.kanade.presentation.category.repos +package eu.kanade.presentation.more.settings.screen.browse import androidx.compose.runtime.Immutable import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import dev.icerock.moko.resources.StringResource import eu.kanade.domain.source.interactor.CreateSourceRepo -import eu.kanade.domain.source.interactor.DeleteSourceRepos +import eu.kanade.domain.source.interactor.DeleteSourceRepo import eu.kanade.domain.source.interactor.GetSourceRepos import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -18,10 +18,10 @@ import tachiyomi.i18n.MR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class RepoScreenModel( +class ExtensionReposScreenModel( private val getSourceRepos: GetSourceRepos = Injekt.get(), private val createSourceRepo: CreateSourceRepo = Injekt.get(), - private val deleteSourceRepos: DeleteSourceRepos = Injekt.get(), + private val deleteSourceRepo: DeleteSourceRepo = Injekt.get(), ) : StateScreenModel(RepoScreenState.Loading) { private val _events: Channel = Channel(Int.MAX_VALUE) @@ -48,20 +48,20 @@ class RepoScreenModel( fun createRepo(name: String) { screenModelScope.launchIO { when (createSourceRepo.await(name)) { - is CreateSourceRepo.Result.InvalidName -> _events.send(RepoEvent.InvalidName) + is CreateSourceRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl) else -> {} } } } /** - * Deletes the given repos from the database. + * Deletes the given repo from the database. * - * @param repos The list of repos to delete. + * @param repo The repo to delete. */ - fun deleteRepos(repos: List) { + fun deleteRepo(repo: String) { screenModelScope.launchIO { - deleteSourceRepos.await(repos) + deleteSourceRepo.await(repo) } } @@ -86,8 +86,7 @@ class RepoScreenModel( sealed class RepoEvent { sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent() - data object InvalidName : LocalizedMessage(MR.strings.invalid_repo_name) - data object InternalError : LocalizedMessage(MR.strings.internal_error) + data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name) } sealed class RepoDialog { diff --git a/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt similarity index 87% rename from app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt rename to app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt index b3cda3fb1..8281f5874 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt @@ -1,9 +1,8 @@ -package eu.kanade.presentation.category.components.repo +package eu.kanade.presentation.more.settings.screen.browse.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -24,7 +23,7 @@ import kotlinx.collections.immutable.ImmutableList import tachiyomi.presentation.core.components.material.padding @Composable -fun SourceRepoContent( +fun ExtensionReposContent( repos: ImmutableList, lazyListState: LazyListState, paddingValues: PaddingValues, @@ -38,7 +37,7 @@ fun SourceRepoContent( modifier = modifier, ) { items(repos) { repo -> - SourceRepoListItem( + ExtensionRepoListItem( modifier = Modifier.animateItemPlacement(), repo = repo, onDelete = { onClickDelete(repo) }, @@ -48,7 +47,7 @@ fun SourceRepoContent( } @Composable -private fun SourceRepoListItem( +private fun ExtensionRepoListItem( repo: String, onDelete: () -> Unit, modifier: Modifier = Modifier, @@ -66,13 +65,16 @@ private fun SourceRepoListItem( ), verticalAlignment = Alignment.CenterVertically, ) { - Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "") + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium)) } - Row { - Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { IconButton(onClick = onDelete) { - Icon(imageVector = Icons.Outlined.Delete, contentDescription = "") + Icon(imageVector = Icons.Outlined.Delete, contentDescription = null) } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt new file mode 100644 index 000000000..9f20b196d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt @@ -0,0 +1,117 @@ +package eu.kanade.presentation.more.settings.screen.browse.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import kotlin.time.Duration.Companion.seconds + +@Composable +fun ExtensionRepoCreateDialog( + onDismissRequest: () -> Unit, + onCreate: (String) -> Unit, + categories: ImmutableList, +) { + var name by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val nameAlreadyExists = remember(name) { categories.contains(name) } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + enabled = name.isNotEmpty() && !nameAlreadyExists, + onClick = { + onCreate(name) + onDismissRequest() + }, + ) { + Text(text = stringResource(MR.strings.action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_cancel)) + } + }, + title = { + Text(text = stringResource(MR.strings.action_add_repo)) + }, + text = { + Column { + Text(text = stringResource(MR.strings.action_add_repo_message)) + + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = name, + onValueChange = { name = it }, + label = { + Text(text = stringResource(MR.strings.label_add_repo_input)) + }, + supportingText = { + val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { + MR.strings.error_repo_exists + } else { + MR.strings.information_required_plain + } + Text(text = stringResource(msgRes)) + }, + isError = name.isNotEmpty() && nameAlreadyExists, + singleLine = true, + ) + } + }, + ) + + LaunchedEffect(focusRequester) { + // TODO: https://issuetracker.google.com/issues/204502668 + delay(0.1.seconds) + focusRequester.requestFocus() + } +} + +@Composable +fun ExtensionRepoDeleteDialog( + onDismissRequest: () -> Unit, + onDelete: () -> Unit, + repo: String, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = { + onDelete() + onDismissRequest() + }) { + Text(text = stringResource(MR.strings.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_cancel)) + } + }, + title = { + Text(text = stringResource(MR.strings.action_delete_repo)) + }, + text = { + Text(text = stringResource(MR.strings.delete_repo_confirmation, repo)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt similarity index 89% rename from app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt rename to app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt index 780ad0f0a..1bd680d06 100644 --- a/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt @@ -1,4 +1,6 @@ -package eu.kanade.presentation.category +@file:JvmName("ExtensionReposScreenKt") + +package eu.kanade.presentation.more.settings.screen.browse.components import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -7,9 +9,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import eu.kanade.presentation.category.components.CategoryFloatingActionButton -import eu.kanade.presentation.category.components.repo.SourceRepoContent -import eu.kanade.presentation.category.repos.RepoScreenState import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.padding @@ -19,7 +20,7 @@ import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.util.plus @Composable -fun SourceRepoScreen( +fun ExtensionReposScreen( state: RepoScreenState.Success, onClickCreate: () -> Unit, onClickDelete: (String) -> Unit, @@ -49,7 +50,7 @@ fun SourceRepoScreen( return@Scaffold } - SourceRepoContent( + ExtensionReposContent( repos = state.repos, lazyListState = lazyListState, paddingValues = paddingValues + topSmallPaddingValues + diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 775bfe78d..46f7e3812 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -405,6 +405,11 @@ object Migrations { // Deleting old download cache index files, but might as well clear it all out context.cacheDir.deleteRecursively() } + if (oldVersion < 114) { + sourcePreferences.extensionRepos().getAndSet { + it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet() + } + } return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 4b43d11ad..4c342dd3d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.extension import android.content.Context import android.graphics.drawable.Drawable import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep @@ -49,7 +49,7 @@ class ExtensionManager( /** * API where all the available extensions can be found. */ - private val api = ExtensionGithubApi() + private val api = ExtensionApi() /** * The installer which installs, updates and uninstalls the extensions. diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt similarity index 69% rename from app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt rename to app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index 2bf6a708e..e885652b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context +import eu.kanade.domain.source.interactor.OFFICIAL_REPO_BASE_URL import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension @@ -21,7 +22,7 @@ import uy.kohesive.injekt.injectLazy import java.time.Instant import kotlin.time.Duration.Companion.days -internal class ExtensionGithubApi { +internal class ExtensionApi { private val networkService: NetworkHelper by injectLazy() private val preferenceStore: PreferenceStore by injectLazy() @@ -33,52 +34,16 @@ internal class ExtensionGithubApi { preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0) } - private var requiresFallbackSource = false - suspend fun findExtensions(): List { return withIOContext { - val githubResponse = if (requiresFallbackSource) { - null - } else { - try { - networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() - } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } - requiresFallbackSource = true - null - } - } - - val response = githubResponse ?: run { - networkService.client - .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() - } - - val extensions = with(json) { - response - .parseAs>() - .toExtensions() + sourcePreferences.extensionRepos() - .get() - .flatMap { repoPath -> - val url = if (requiresFallbackSource) { - "$FALLBACK_BASE_URL$repoPath@repo/" - } else { - "$BASE_URL$repoPath/repo/" - } - networkService.client - .newCall(GET("${url}index.min.json")) - .awaitSuccess() - .parseAs>() - .toExtensions(url, repoSource = true) - } + val extensions = buildList { + addAll(getExtensions(OFFICIAL_REPO_BASE_URL, true)) + sourcePreferences.extensionRepos().get().map { addAll(getExtensions(it, false)) } } // Sanity check - a small number of extensions probably means something broke // with the repo generator - if (extensions.size < 100) { + if (extensions.size < 50) { throw Exception() } @@ -86,6 +51,26 @@ internal class ExtensionGithubApi { } } + private suspend fun getExtensions( + repoBaseUrl: String, + isOfficialRepo: Boolean, + ): List { + return try { + val response = networkService.client + .newCall(GET("$repoBaseUrl/index.min.json")) + .awaitSuccess() + + with(json) { + response + .parseAs>() + .toExtensions(repoBaseUrl, isRepoSource = !isOfficialRepo) + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" } + emptyList() + } + } + suspend fun checkForUpdates( context: Context, fromAvailableExtensionList: Boolean = false, @@ -127,8 +112,8 @@ internal class ExtensionGithubApi { } private fun List.toExtensions( - repoUrl: String = getUrlPrefix(), - repoSource: Boolean = false, + repoUrl: String, + isRepoSource: Boolean, ): List { return this .filter { @@ -146,9 +131,9 @@ internal class ExtensionGithubApi { isNsfw = it.nsfw == 1, sources = it.sources?.map(extensionSourceMapper).orEmpty(), apkName = it.apk, - iconUrl = "${repoUrl}icon/${it.pkg}.png", + iconUrl = "$repoUrl/icon/${it.pkg}.png", repoUrl = repoUrl, - isRepoSource = repoSource, + isRepoSource = isRepoSource, ) } } @@ -157,24 +142,11 @@ internal class ExtensionGithubApi { return "${extension.repoUrl}/apk/${extension.apkName}" } - private fun getUrlPrefix(): String { - return if (requiresFallbackSource) { - FALLBACK_REPO_URL_PREFIX - } else { - REPO_URL_PREFIX - } - } - private fun ExtensionJsonObject.extractLibVersion(): Double { return version.substringBeforeLast('.').toDouble() } } -private const val BASE_URL = "https://raw.githubusercontent.com/" -private const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/" -private const val FALLBACK_BASE_URL = "https://gcore.jsdelivr.net/gh/" -private const val FALLBACK_REPO_URL_PREFIX = "${FALLBACK_BASE_URL}tachiyomiorg/tachiyomi-extensions@repo/" - @Serializable private data class ExtensionJsonObject( val name: String, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt index dcd0246bb..20ebdba0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt @@ -18,8 +18,6 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.util.system.toast import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.collectLatest -import tachiyomi.i18n.MR -import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen class CategoryScreen : Screen() { @@ -57,7 +55,6 @@ class CategoryScreen : Screen() { onDismissRequest = screenModel::dismissDialog, onCreate = screenModel::createCategory, categories = successState.categories.fastMap { it.name }.toImmutableList(), - title = stringResource(MR.strings.action_add_category), ) } is CategoryDialog.Rename -> { @@ -72,8 +69,7 @@ class CategoryScreen : Screen() { CategoryDeleteDialog( onDismissRequest = screenModel::dismissDialog, onDelete = { screenModel.deleteCategory(dialog.category.id) }, - title = stringResource(MR.strings.delete_category), - text = stringResource(MR.strings.delete_category_confirmation, dialog.category.name), + category = dialog.category.name, ) } is CategoryDialog.SortAlphabetically -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 78c688c2e..3ad313c4f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -65,7 +65,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.updater.AppUpdateChecker import eu.kanade.tachiyomi.data.updater.RELEASE_URL -import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen @@ -337,7 +337,7 @@ class MainActivity : BaseActivity() { // Extensions updates LaunchedEffect(Unit) { try { - ExtensionGithubApi().checkForUpdates(context) + ExtensionApi().checkForUpdates(context) } catch (e: Exception) { logcat(LogPriority.ERROR, e) } diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index a724bac5e..e4aa74e4b 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -340,10 +340,11 @@ Extension repos You have no repos set. Add repo - Add additional repos to Tachiyomi, the format of a repo is \"username/repo\", with username being the repo owner, and repo being the repo name. + Repo URL + Add additional repos to Tachiyomi. This should be a URL that ends with \"index.min.json\". This repo already exists! Delete repo - Invalid repo name + Invalid repo URL Do you wish to delete the repo \"%s\"? This extension is from an external repo. Tap to view the repo. From 8321ff60005892e3ba333caafd1e88c1fb6d6660 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 23:21:19 -0500 Subject: [PATCH 12/12] Bump dependencies --- .../main/java/eu/kanade/presentation/more/NewUpdateScreen.kt | 4 ++-- gradle/androidx.versions.toml | 2 +- gradle/libs.versions.toml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt b/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt index f3c8a433d..96fd421dd 100644 --- a/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.ui.RichTextStyle -import com.halilibo.richtext.ui.material3.Material3RichText +import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.string.RichTextStringStyle import eu.kanade.presentation.theme.TachiyomiTheme import tachiyomi.i18n.MR @@ -42,7 +42,7 @@ fun NewUpdateScreen( rejectText = stringResource(MR.strings.action_not_now), onRejectClick = onRejectUpdate, ) { - Material3RichText( + RichText( modifier = Modifier .fillMaxWidth() .padding(vertical = MaterialTheme.padding.large), diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index 52dfafde6..1465a7c75 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -1,5 +1,5 @@ [versions] -agp_version = "8.2.0" +agp_version = "8.2.1" lifecycle_version = "2.6.2" paging_version = "3.2.1" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b04b8cd4b..8f9e421fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] aboutlib_version = "10.10.0" acra = "5.11.3" -leakcanary = "2.12" +leakcanary = "2.13" moko = "0.23.0" okhttp_version = "5.0.0-alpha.12" -richtext = "0.17.0" +richtext = "0.20.0" shizuku_version = "12.2.0" sqldelight = "2.0.0" sqlite = "2.4.0"