From 2c4230376c09c80969b48dc9dee9b78b9b7ddb2b Mon Sep 17 00:00:00 2001 From: LuftVerbot <97435834+LuftVerbot@users.noreply.github.com> Date: Sun, 19 Nov 2023 13:18:41 +0100 Subject: [PATCH] merge28 Last commit merged: https://github.com/tachiyomiorg/tachiyomi/commit/6922792ad110ce9194aa963674bbf2c5c1351ea1 --- .github/workflows/issue_moderator.yml | 7 + app/build.gradle.kts | 2 +- app/proguard-android-optimize.txt | 2 +- app/proguard-rules.pro | 2 +- app/src/main/AndroidManifest.xml | 8 +- .../eu/kanade/core/util/CollectionUtils.kt | 10 - .../java/eu/kanade/domain/DomainModule.kt | 4 +- .../eu/kanade/domain/base/BasePreferences.kt | 1 + .../interactor/SyncChaptersWithSource.kt | 15 +- .../SyncChaptersWithTrackServiceTwoWay.kt | 5 +- .../interactor/SyncEpisodesWithSource.kt | 15 +- .../SyncEpisodesWithTrackServiceTwoWay.kt | 5 +- .../anime/AnimeExtensionDetailsScreen.kt | 28 +- .../browse/anime/AnimeExtensionsScreen.kt | 2 +- .../browse/anime/AnimeSourcesScreen.kt | 2 +- .../browse/anime/MigrateAnimeSourceScreen.kt | 2 +- .../anime/components/BrowseAnimeIcons.kt | 4 +- .../manga/MangaExtensionDetailsScreen.kt | 28 +- .../browse/manga/MangaExtensionsScreen.kt | 2 +- .../browse/manga/MangaSourcesScreen.kt | 2 +- .../browse/manga/MigrateMangaSourceScreen.kt | 2 +- .../manga/components/BrowseMangaIcons.kt | 4 +- .../history/anime/AnimeHistoryScreen.kt | 2 +- .../history/manga/MangaHistoryScreen.kt | 2 +- .../anime/AnimeLibrarySettingsDialog.kt | 2 +- .../manga/MangaLibrarySettingsDialog.kt | 2 +- .../settings/screen/SettingsLibraryScreen.kt | 50 ++-- .../updates/anime/AnimeUpdatesScreen.kt | 2 +- .../updates/manga/MangaUpdatesScreen.kt | 2 +- .../presentation/util/ExceptionFormatter.kt | 13 +- .../java/eu/kanade/tachiyomi/Migrations.kt | 53 ++-- .../tachiyomi/data/backup/BackupCreateJob.kt | 6 + .../tachiyomi/data/backup/BackupManager.kt | 4 +- .../data/download/anime/AnimeDownloadCache.kt | 21 +- .../data/download/manga/MangaDownloadCache.kt | 15 +- .../library/anime/AnimeLibraryUpdateJob.kt | 17 +- .../library/manga/MangaLibraryUpdateJob.kt | 17 +- .../tachiyomi/data/track/AnimeTrackService.kt | 10 +- .../tachiyomi/data/track/MangaTrackService.kt | 10 +- .../tachiyomi/data/track/TrackService.kt | 2 - .../extension/anime/AnimeExtensionManager.kt | 6 +- .../extension/anime/model/AnimeExtension.kt | 1 + .../util/AnimeExtensionInstallReceiver.kt | 40 ++- .../anime/util/AnimeExtensionInstaller.kt | 44 ++- .../anime/util/AnimeExtensionLoader.kt | 253 ++++++++++++++--- .../extension/manga/MangaExtensionManager.kt | 6 +- .../extension/manga/model/MangaExtension.kt | 1 + .../util/MangaExtensionInstallReceiver.kt | 40 ++- .../manga/util/MangaExtensionInstaller.kt | 44 ++- .../manga/util/MangaExtensionLoader.kt | 268 +++++++++++++++--- .../details/SourcePreferencesScreen.kt | 2 +- .../details/MangaSourcePreferencesScreen.kt | 2 +- .../anime}/DeepLinkAnimeActivity.kt | 3 +- .../ui/deeplink/anime/DeepLinkAnimeScreen.kt | 59 ++++ .../anime/DeepLinkAnimeScreenModel.kt | 47 +++ .../manga}/DeepLinkMangaActivity.kt | 3 +- .../ui/deeplink/manga/DeepLinkMangaScreen.kt | 59 ++++ .../manga/DeepLinkMangaScreenModel.kt | 47 +++ .../ui/entries/anime/AnimeScreenModel.kt | 2 +- .../ui/entries/manga/MangaScreenModel.kt | 2 +- .../library/anime/AnimeLibraryScreenModel.kt | 2 +- .../ui/library/anime/AnimeLibraryTab.kt | 2 +- .../library/manga/MangaLibraryScreenModel.kt | 2 +- .../ui/library/manga/MangaLibraryTab.kt | 2 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 2 + .../tachiyomi/ui/reader/ReaderActivity.kt | 33 +-- .../ui/reader/setting/OrientationType.kt | 16 +- .../ui/reader/setting/ReadingModeType.kt | 14 +- .../ui/stats/anime/AnimeStatsScreenModel.kt | 6 +- .../ui/stats/manga/MangaStatsScreenModel.kt | 6 +- .../updates/anime/AnimeUpdatesScreenModel.kt | 2 +- .../updates/manga/MangaUpdatesScreenModel.kt | 2 +- app/src/main/res/values/arrays.xml | 19 -- .../tachiyomi/network/OkHttpExtensions.kt | 7 + .../items/chapter/ChapterRepositoryImpl.kt | 2 +- .../items/episode/EpisodeRepositoryImpl.kt | 2 +- .../tachiyomi/data/release/GithubRelease.kt | 17 +- .../interactor/CreateAnimeCategoryWithName.kt | 2 +- .../interactor/ResetAnimeCategoryFlags.kt | 2 +- .../anime/interactor/SetAnimeDisplayMode.kt | 2 +- .../interactor/SetSortModeForAnimeCategory.kt | 2 +- .../interactor/CreateMangaCategoryWithName.kt | 2 +- .../interactor/ResetMangaCategoryFlags.kt | 2 +- .../manga/interactor/SetMangaDisplayMode.kt | 2 +- .../interactor/SetSortModeForMangaCategory.kt | 2 +- .../library/service/LibraryPreferences.kt | 24 +- .../source/anime/model/StubAnimeSource.kt | 2 +- .../source/manga/model/StubMangaSource.kt | 8 +- .../interactor/SetAnimeFetchIntervalTest.kt | 78 ++--- .../interactor/SetMangaFetchIntervalTest.kt | 78 ++--- gradle.properties | 4 +- gradle/androidx.versions.toml | 8 +- gradle/compose.versions.toml | 6 +- gradle/libs.versions.toml | 10 +- gradle/wrapper/gradle-wrapper.properties | 2 +- i18n/src/main/res/values/strings-aniyomi.xml | 1 + i18n/src/main/res/values/strings.xml | 11 +- .../core/components/SettingsItems.kt | 5 +- .../tachiyomi/animesource/AnimeSource.kt | 62 ++-- .../animesource/online/AnimeHttpSource.kt | 43 ++- .../online/ResolvableAnimeSource.kt | 26 ++ .../eu/kanade/tachiyomi/source/MangaSource.kt | 62 ++-- .../tachiyomi/source/online/HttpSource.kt | 43 ++- .../source/online/ResolvableMangaSource.kt | 26 ++ 104 files changed, 1370 insertions(+), 592 deletions(-) rename app/src/main/java/eu/kanade/tachiyomi/ui/{main => deeplink/anime}/DeepLinkAnimeActivity.kt (83%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreenModel.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/{main => deeplink/manga}/DeepLinkMangaActivity.kt (83%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreenModel.kt create mode 100644 source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/animesource/online/ResolvableAnimeSource.kt create mode 100644 source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/ResolvableMangaSource.kt diff --git a/.github/workflows/issue_moderator.yml b/.github/workflows/issue_moderator.yml index 1bfe6239a..0961cdd8f 100644 --- a/.github/workflows/issue_moderator.yml +++ b/.github/workflows/issue_moderator.yml @@ -27,6 +27,13 @@ jobs: "type": "body", "regex": ".*\\* (Aniyomi version|Android version|Device): \\?.*", "message": "Requested information in the template was not filled out." + }, + { + "type": "both", + "regex": ".*(?:fail(?:ed|ure|s)?|can\\s*(?:no|')?t|(?:not|un).*able|(? @@ -90,10 +90,10 @@ diff --git a/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt b/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt index 501763150..8dab8a054 100644 --- a/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt +++ b/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt @@ -1,7 +1,6 @@ package eu.kanade.core.util import androidx.compose.ui.util.fastForEach -import java.util.concurrent.ConcurrentHashMap import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -20,15 +19,6 @@ fun List.insertSeparators( return newList } -/** - * Returns a new map containing only the key entries of [transform] that are not null. - */ -inline fun Map.mapNotNullKeys(transform: (Map.Entry) -> R?): ConcurrentHashMap { - val mutableMap = ConcurrentHashMap() - forEach { element -> transform(element)?.let { mutableMap[it] = element.value } } - return mutableMap -} - fun HashSet.addOrRemove(value: E, shouldAdd: Boolean) { if (shouldAdd) { add(value) diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 0389cf7a3..985759965 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -234,7 +234,7 @@ class DomainModule : InjektModule { addFactory { UpdateEpisode(get()) } addFactory { SetSeenStatus(get(), get(), get(), get()) } addFactory { ShouldUpdateDbEpisode() } - addFactory { SyncEpisodesWithSource(get(), get(), get(), get()) } + addFactory { SyncEpisodesWithSource(get(), get(), get(), get(), get(), get(), get()) } addFactory { SyncEpisodesWithTrackServiceTwoWay(get(), get()) } addSingletonFactory { ChapterRepositoryImpl(get()) } @@ -243,7 +243,7 @@ class DomainModule : InjektModule { addFactory { UpdateChapter(get()) } addFactory { SetReadStatus(get(), get(), get(), get()) } addFactory { ShouldUpdateDbChapter() } - addFactory { SyncChaptersWithSource(get(), get(), get(), get()) } + addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) } addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) } addSingletonFactory { AnimeHistoryRepositoryImpl(get()) } diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index ecc02b3e4..028d2a509 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -28,5 +28,6 @@ class BasePreferences( LEGACY(R.string.ext_installer_legacy), PACKAGEINSTALLER(R.string.ext_installer_packageinstaller), SHIZUKU(R.string.ext_installer_shizuku), + PRIVATE(R.string.ext_installer_private), } } diff --git a/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt index 420e47ee9..f5a87bede 100644 --- a/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithSource.kt @@ -20,7 +20,6 @@ import tachiyomi.domain.items.chapter.model.toChapterUpdate import tachiyomi.domain.items.chapter.repository.ChapterRepository import tachiyomi.domain.items.chapter.service.ChapterRecognition import tachiyomi.source.local.entries.manga.isLocal -import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.lang.Long.max import java.time.ZonedDateTime @@ -28,13 +27,13 @@ import java.util.Date import java.util.TreeSet class SyncChaptersWithSource( - private val downloadManager: MangaDownloadManager = Injekt.get(), - private val downloadProvider: MangaDownloadProvider = Injekt.get(), - private val chapterRepository: ChapterRepository = Injekt.get(), - private val shouldUpdateDbChapter: ShouldUpdateDbChapter = Injekt.get(), - private val updateManga: UpdateManga = Injekt.get(), - private val updateChapter: UpdateChapter = Injekt.get(), - private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(), + private val downloadManager: MangaDownloadManager, + private val downloadProvider: MangaDownloadProvider, + private val chapterRepository: ChapterRepository, + private val shouldUpdateDbChapter: ShouldUpdateDbChapter, + private val updateManga: UpdateManga, + private val updateChapter: UpdateChapter, + private val getChapterByMangaId: GetChapterByMangaId, ) { /** diff --git a/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithTrackServiceTwoWay.kt b/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithTrackServiceTwoWay.kt index b0c6c2b07..2281f6be2 100644 --- a/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithTrackServiceTwoWay.kt +++ b/app/src/main/java/eu/kanade/domain/items/chapter/interactor/SyncChaptersWithTrackServiceTwoWay.kt @@ -9,12 +9,11 @@ import tachiyomi.domain.items.chapter.model.Chapter import tachiyomi.domain.items.chapter.model.toChapterUpdate import tachiyomi.domain.track.manga.interactor.InsertMangaTrack import tachiyomi.domain.track.manga.model.MangaTrack -import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class SyncChaptersWithTrackServiceTwoWay( - private val updateChapter: UpdateChapter = Injekt.get(), - private val insertTrack: InsertMangaTrack = Injekt.get(), + private val updateChapter: UpdateChapter, + private val insertTrack: InsertMangaTrack, ) { suspend fun await( diff --git a/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt b/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt index 4195322e8..a558647ff 100644 --- a/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithSource.kt @@ -20,7 +20,6 @@ import tachiyomi.domain.items.episode.model.toEpisodeUpdate import tachiyomi.domain.items.episode.repository.EpisodeRepository import tachiyomi.domain.items.episode.service.EpisodeRecognition import tachiyomi.source.local.entries.anime.isLocal -import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.lang.Long.max import java.time.ZonedDateTime @@ -28,13 +27,13 @@ import java.util.Date import java.util.TreeSet class SyncEpisodesWithSource( - private val downloadManager: AnimeDownloadManager = Injekt.get(), - private val downloadProvider: AnimeDownloadProvider = Injekt.get(), - private val episodeRepository: EpisodeRepository = Injekt.get(), - private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode = Injekt.get(), - private val updateAnime: UpdateAnime = Injekt.get(), - private val updateEpisode: UpdateEpisode = Injekt.get(), - private val getEpisodeByAnimeId: GetEpisodeByAnimeId = Injekt.get(), + private val downloadManager: AnimeDownloadManager, + private val downloadProvider: AnimeDownloadProvider, + private val episodeRepository: EpisodeRepository, + private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode, + private val updateAnime: UpdateAnime, + private val updateEpisode: UpdateEpisode, + private val getEpisodeByAnimeId: GetEpisodeByAnimeId, ) { /** diff --git a/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithTrackServiceTwoWay.kt b/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithTrackServiceTwoWay.kt index 2b5151dfc..c4846ec24 100644 --- a/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithTrackServiceTwoWay.kt +++ b/app/src/main/java/eu/kanade/domain/items/episode/interactor/SyncEpisodesWithTrackServiceTwoWay.kt @@ -9,12 +9,11 @@ import tachiyomi.domain.items.episode.model.Episode import tachiyomi.domain.items.episode.model.toEpisodeUpdate import tachiyomi.domain.track.anime.interactor.InsertAnimeTrack import tachiyomi.domain.track.anime.model.AnimeTrack -import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class SyncEpisodesWithTrackServiceTwoWay( - private val updateEpisode: UpdateEpisode = Injekt.get(), - private val insertTrack: InsertAnimeTrack = Injekt.get(), + private val updateEpisode: UpdateEpisode, + private val insertTrack: InsertAnimeTrack, ) { suspend fun await( diff --git a/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionDetailsScreen.kt index 08fe51005..8446cbc2e 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionDetailsScreen.kt @@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.HelpOutline @@ -175,7 +173,8 @@ private fun AnimeExtensionDetails( data = Uri.fromParts("package", extension.pkgName, null) context.startActivity(this) } - }, + Unit + }.takeIf { extension.isShared }, onClickAgeRating = { showNsfwWarning = true }, @@ -208,7 +207,7 @@ private fun DetailsHeader( extension: AnimeExtension, onClickAgeRating: () -> Unit, onClickUninstall: () -> Unit, - onClickAppInfo: () -> Unit, + onClickAppInfo: (() -> Unit)?, ) { val context = LocalContext.current @@ -292,6 +291,7 @@ private fun DetailsHeader( top = MaterialTheme.padding.small, bottom = MaterialTheme.padding.medium, ), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedButton( modifier = Modifier.weight(1f), @@ -300,16 +300,16 @@ private fun DetailsHeader( Text(stringResource(R.string.ext_uninstall)) } - Spacer(Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = onClickAppInfo, - ) { - Text( - text = stringResource(R.string.ext_app_info), - color = MaterialTheme.colorScheme.onPrimary, - ) + if (onClickAppInfo != null) { + Button( + modifier = Modifier.weight(1f), + onClick = onClickAppInfo, + ) { + Text( + text = stringResource(R.string.ext_app_info), + color = MaterialTheme.colorScheme.onPrimary, + ) + } } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionsScreen.kt index 7e30fe9aa..34ee8ed49 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeExtensionsScreen.kt @@ -75,7 +75,7 @@ fun AnimeExtensionScreen( enabled = !state.isLoading, ) { when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isEmpty -> { val msg = if (!searchQuery.isNullOrEmpty()) { R.string.no_results_found diff --git a/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeSourcesScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeSourcesScreen.kt index 3078c0617..2e1f8a705 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeSourcesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/anime/AnimeSourcesScreen.kt @@ -47,7 +47,7 @@ fun AnimeSourcesScreen( onLongClickItem: (AnimeSource) -> Unit, ) { when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isEmpty -> EmptyScreen( textResource = R.string.source_empty_screen, modifier = Modifier.padding(contentPadding), diff --git a/app/src/main/java/eu/kanade/presentation/browse/anime/MigrateAnimeSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/anime/MigrateAnimeSourceScreen.kt index 76b10ba97..5bd1c8a67 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/anime/MigrateAnimeSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/anime/MigrateAnimeSourceScreen.kt @@ -51,7 +51,7 @@ fun MigrateAnimeSourceScreen( ) { val context = LocalContext.current when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isEmpty -> EmptyScreen( textResource = R.string.information_empty_library, modifier = Modifier.padding(contentPadding), diff --git a/app/src/main/java/eu/kanade/presentation/browse/anime/components/BrowseAnimeIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/anime/components/BrowseAnimeIcons.kt index 81ea0a2fd..af457964d 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/anime/components/BrowseAnimeIcons.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/anime/components/BrowseAnimeIcons.kt @@ -1,6 +1,5 @@ package eu.kanade.presentation.browse.anime.components -import android.content.pm.PackageManager import android.util.DisplayMetrics import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -31,6 +30,7 @@ import eu.kanade.domain.source.anime.model.icon import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension +import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader import tachiyomi.core.util.lang.withIOContext import tachiyomi.domain.source.anime.model.AnimeSource import tachiyomi.source.local.entries.anime.LocalAnimeSource @@ -127,7 +127,7 @@ private fun AnimeExtension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT return produceState>(initialValue = Result.Loading, this) { withIOContext { value = try { - val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) + val appInfo = AnimeExtensionLoader.getAnimeExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo val appResources = context.packageManager.getResourcesForApplication(appInfo) Result.Success( appResources.getDrawableForDensity(appInfo.icon, density, null)!! diff --git a/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionDetailsScreen.kt index 6beafc22f..9bd22b61d 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionDetailsScreen.kt @@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.HelpOutline @@ -176,7 +174,8 @@ private fun ExtensionDetails( data = Uri.fromParts("package", extension.pkgName, null) context.startActivity(this) } - }, + Unit + }.takeIf { extension.isShared }, onClickAgeRating = { showNsfwWarning = true }, @@ -209,7 +208,7 @@ private fun DetailsHeader( extension: MangaExtension, onClickAgeRating: () -> Unit, onClickUninstall: () -> Unit, - onClickAppInfo: () -> Unit, + onClickAppInfo: (() -> Unit)?, ) { val context = LocalContext.current @@ -293,6 +292,7 @@ private fun DetailsHeader( top = MaterialTheme.padding.small, bottom = MaterialTheme.padding.medium, ), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedButton( modifier = Modifier.weight(1f), @@ -301,16 +301,16 @@ private fun DetailsHeader( Text(stringResource(R.string.ext_uninstall)) } - Spacer(Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = onClickAppInfo, - ) { - Text( - text = stringResource(R.string.ext_app_info), - color = MaterialTheme.colorScheme.onPrimary, - ) + if (onClickAppInfo != null) { + Button( + modifier = Modifier.weight(1f), + onClick = onClickAppInfo, + ) { + Text( + text = stringResource(R.string.ext_app_info), + color = MaterialTheme.colorScheme.onPrimary, + ) + } } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionsScreen.kt index 6728e830b..dd33b6d21 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/manga/MangaExtensionsScreen.kt @@ -76,7 +76,7 @@ fun MangaExtensionScreen( enabled = !state.isLoading, ) { when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isEmpty -> { val msg = if (!searchQuery.isNullOrEmpty()) { R.string.no_results_found diff --git a/app/src/main/java/eu/kanade/presentation/browse/manga/MangaSourcesScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/manga/MangaSourcesScreen.kt index b0cacc91a..60d75aac8 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/manga/MangaSourcesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/manga/MangaSourcesScreen.kt @@ -47,7 +47,7 @@ fun MangaSourcesScreen( onLongClickItem: (Source) -> Unit, ) { when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isEmpty -> EmptyScreen( textResource = R.string.source_empty_screen, modifier = Modifier.padding(contentPadding), diff --git a/app/src/main/java/eu/kanade/presentation/browse/manga/MigrateMangaSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/manga/MigrateMangaSourceScreen.kt index d3453a23c..7d91678d1 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/manga/MigrateMangaSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/manga/MigrateMangaSourceScreen.kt @@ -51,7 +51,7 @@ fun MigrateMangaSourceScreen( ) { val context = LocalContext.current when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isEmpty -> EmptyScreen( textResource = R.string.information_empty_library, modifier = Modifier.padding(contentPadding), diff --git a/app/src/main/java/eu/kanade/presentation/browse/manga/components/BrowseMangaIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/manga/components/BrowseMangaIcons.kt index ecc1e111f..fe163fd23 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/manga/components/BrowseMangaIcons.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/manga/components/BrowseMangaIcons.kt @@ -1,6 +1,5 @@ package eu.kanade.presentation.browse.manga.components -import android.content.pm.PackageManager import android.util.DisplayMetrics import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -31,6 +30,7 @@ import eu.kanade.domain.source.manga.model.icon import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.extension.manga.model.MangaExtension +import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionLoader import tachiyomi.core.util.lang.withIOContext import tachiyomi.domain.source.manga.model.Source import tachiyomi.source.local.entries.manga.LocalMangaSource @@ -127,7 +127,7 @@ private fun MangaExtension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT return produceState>(initialValue = Result.Loading, this) { withIOContext { value = try { - val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) + val appInfo = MangaExtensionLoader.getMangaExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo val appResources = context.packageManager.getResourcesForApplication(appInfo) Result.Success( appResources.getDrawableForDensity(appInfo.icon, density, null)!! diff --git a/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryScreen.kt index 1a65c63cf..e14985a7d 100644 --- a/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/anime/AnimeHistoryScreen.kt @@ -30,7 +30,7 @@ fun AnimeHistoryScreen( ) { _ -> state.list.let { if (it == null) { - LoadingScreen(modifier = Modifier.padding(contentPadding)) + LoadingScreen(Modifier.padding(contentPadding)) } else if (it.isEmpty()) { val msg = if (!searchQuery.isNullOrEmpty()) { R.string.no_results_found diff --git a/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryScreen.kt index 64aad4e69..21c0601fb 100644 --- a/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/manga/MangaHistoryScreen.kt @@ -29,7 +29,7 @@ fun MangaHistoryScreen( ) { _ -> state.list.let { if (it == null) { - LoadingScreen(modifier = Modifier.padding(contentPadding)) + LoadingScreen(Modifier.padding(contentPadding)) } else if (it.isEmpty()) { val msg = if (!searchQuery.isNullOrEmpty()) { R.string.no_results_found diff --git a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt index c105b48a5..d690fb7d6 100644 --- a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt @@ -181,7 +181,7 @@ private val displayModes = listOf( private fun ColumnScope.DisplayPage( screenModel: AnimeLibrarySettingsScreenModel, ) { - val displayMode by screenModel.libraryPreferences.libraryDisplayMode().collectAsState() + val displayMode by screenModel.libraryPreferences.displayMode().collectAsState() SettingsChipRow(R.string.action_display_mode) { displayModes.map { (titleRes, mode) -> FilterChip( diff --git a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt index 30f0f6adc..d4c38313c 100644 --- a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt @@ -180,7 +180,7 @@ private val displayModes = listOf( private fun ColumnScope.DisplayPage( screenModel: MangaLibrarySettingsScreenModel, ) { - val displayMode by screenModel.libraryPreferences.libraryDisplayMode().collectAsState() + val displayMode by screenModel.libraryPreferences.displayMode().collectAsState() SettingsChipRow(R.string.action_display_mode) { displayModes.map { (titleRes, mode) -> FilterChip( 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 b98c2fc48..deab75fd8 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 @@ -33,6 +33,9 @@ import tachiyomi.domain.category.manga.interactor.GetMangaCategories import tachiyomi.domain.category.manga.interactor.ResetMangaCategoryFlags import tachiyomi.domain.category.model.Category import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING +import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED +import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY_ON_WIFI import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_HAS_UNVIEWED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_COMPLETED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_VIEWED @@ -163,15 +166,15 @@ object SettingsLibraryScreen : SearchableSettings { ): Preference.PreferenceGroup { val context = LocalContext.current - val libraryUpdateIntervalPref = libraryPreferences.libraryUpdateInterval() - val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState() + val autoUpdateIntervalPref = libraryPreferences.autoUpdateInterval() + val autoUpdateInterval by autoUpdateIntervalPref.collectAsState() - val animelibUpdateCategoriesPref = libraryPreferences.animeLibraryUpdateCategories() - val animelibUpdateCategoriesExcludePref = - libraryPreferences.animeLibraryUpdateCategoriesExclude() + val animeAutoUpdateCategoriesPref = libraryPreferences.animeUpdateCategories() + val animeAutoUpdateCategoriesExcludePref = + libraryPreferences.animeUpdateCategoriesExclude() - val includedAnime by animelibUpdateCategoriesPref.collectAsState() - val excludedAnime by animelibUpdateCategoriesExcludePref.collectAsState() + val includedAnime by animeAutoUpdateCategoriesPref.collectAsState() + val excludedAnime by animeAutoUpdateCategoriesExcludePref.collectAsState() var showAnimeCategoriesDialog by rememberSaveable { mutableStateOf(false) } if (showAnimeCategoriesDialog) { TriStateListDialog( @@ -183,8 +186,8 @@ object SettingsLibraryScreen : SearchableSettings { itemLabel = { it.visualName }, onDismissRequest = { showAnimeCategoriesDialog = false }, onValueChanged = { newIncluded, newExcluded -> - animelibUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) - animelibUpdateCategoriesExcludePref.set( + animeAutoUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) + animeAutoUpdateCategoriesExcludePref.set( newExcluded.map { it.id.toString() } .toSet(), ) @@ -193,12 +196,12 @@ object SettingsLibraryScreen : SearchableSettings { ) } - val libraryUpdateCategoriesPref = libraryPreferences.mangaLibraryUpdateCategories() - val libraryUpdateCategoriesExcludePref = - libraryPreferences.mangaLibraryUpdateCategoriesExclude() + val autoUpdateCategoriesPref = libraryPreferences.mangaUpdateCategories() + val autoUpdateCategoriesExcludePref = + libraryPreferences.mangaUpdateCategoriesExclude() - val includedManga by libraryUpdateCategoriesPref.collectAsState() - val excludedManga by libraryUpdateCategoriesExcludePref.collectAsState() + val includedManga by autoUpdateCategoriesPref.collectAsState() + val excludedManga by autoUpdateCategoriesExcludePref.collectAsState() var showMangaCategoriesDialog by rememberSaveable { mutableStateOf(false) } if (showMangaCategoriesDialog) { TriStateListDialog( @@ -210,8 +213,8 @@ object SettingsLibraryScreen : SearchableSettings { itemLabel = { it.visualName }, onDismissRequest = { showMangaCategoriesDialog = false }, onValueChanged = { newIncluded, newExcluded -> - libraryUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) - libraryUpdateCategoriesExcludePref.set( + autoUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) + autoUpdateCategoriesExcludePref.set( newExcluded.map { it.id.toString() } .toSet(), ) @@ -224,7 +227,7 @@ object SettingsLibraryScreen : SearchableSettings { title = stringResource(R.string.pref_category_library_update), preferenceItems = listOf( Preference.PreferenceItem.ListPreference( - pref = libraryUpdateIntervalPref, + pref = autoUpdateIntervalPref, title = stringResource(R.string.pref_library_update_interval), entries = mapOf( 0 to stringResource(R.string.update_never), @@ -241,15 +244,14 @@ object SettingsLibraryScreen : SearchableSettings { }, ), Preference.PreferenceItem.MultiSelectListPreference( - pref = libraryPreferences.libraryUpdateDeviceRestriction(), - enabled = libraryUpdateInterval > 0, + pref = libraryPreferences.autoUpdateDeviceRestrictions(), + enabled = autoUpdateInterval > 0, title = stringResource(R.string.pref_library_update_restriction), subtitle = stringResource(R.string.restrictions), entries = mapOf( - ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read), - ENTRY_NON_VIEWED to stringResource(R.string.pref_update_only_started), - ENTRY_NON_COMPLETED to stringResource(R.string.pref_update_only_non_completed), - ENTRY_OUTSIDE_RELEASE_PERIOD to stringResource(R.string.pref_update_only_in_release_period), + DEVICE_ONLY_ON_WIFI to stringResource(R.string.connected_to_wifi), + DEVICE_NETWORK_NOT_METERED to stringResource(R.string.network_not_metered), + DEVICE_CHARGING to stringResource(R.string.charging), ), onValueChanged = { // Post to event looper to allow the preference to be updated. @@ -290,7 +292,7 @@ object SettingsLibraryScreen : SearchableSettings { subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary), ), Preference.PreferenceItem.MultiSelectListPreference( - pref = libraryPreferences.libraryUpdateItemRestriction(), + pref = libraryPreferences.autoUpdateItemRestrictions(), title = stringResource(R.string.pref_library_update_manga_restriction), entries = mapOf( ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read), diff --git a/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt index 9d39940bd..f699f34a6 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/anime/AnimeUpdatesScreen.kt @@ -69,7 +69,7 @@ fun AnimeUpdateScreen( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.items.isEmpty() -> EmptyScreen( textResource = R.string.information_no_recent, modifier = Modifier.padding(contentPadding), diff --git a/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt index c79498a27..b68084aab 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/manga/MangaUpdatesScreen.kt @@ -65,7 +65,7 @@ fun MangaUpdateScreen( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.items.isEmpty() -> EmptyScreen( textResource = R.string.information_no_recent, modifier = Modifier.padding(contentPadding), diff --git a/app/src/main/java/eu/kanade/presentation/util/ExceptionFormatter.kt b/app/src/main/java/eu/kanade/presentation/util/ExceptionFormatter.kt index 04e5835dc..62bf8b146 100644 --- a/app/src/main/java/eu/kanade/presentation/util/ExceptionFormatter.kt +++ b/app/src/main/java/eu/kanade/presentation/util/ExceptionFormatter.kt @@ -2,19 +2,30 @@ package eu.kanade.presentation.util import android.content.Context import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.online.LicensedEntryItemsException import eu.kanade.tachiyomi.network.HttpException +import eu.kanade.tachiyomi.util.system.isOnline import tachiyomi.domain.items.chapter.model.NoChaptersException import tachiyomi.domain.items.episode.model.NoEpisodesException import tachiyomi.domain.source.anime.model.AnimeSourceNotInstalledException import tachiyomi.domain.source.manga.model.SourceNotInstalledException +import java.net.UnknownHostException context(Context) val Throwable.formattedMessage: String get() { when (this) { + is HttpException -> return getString(R.string.exception_http, code) + is UnknownHostException -> { + return if (!isOnline()) { + getString(R.string.exception_offline) + } else { + getString(R.string.exception_unknown_host, message) + } + } is NoChaptersException, is NoEpisodesException -> return getString(R.string.no_results_found) is SourceNotInstalledException, is AnimeSourceNotInstalledException -> return getString(R.string.loader_not_implemented_error) - is HttpException -> return "$message: ${getString(R.string.http_error_hint)}" + is LicensedEntryItemsException -> return getString(R.string.licensed_manga_chapters_error) } return when (val className = this::class.simpleName) { "Exception", "IOException" -> message ?: className diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 8d584e375..d5491dd33 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.workManager import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.TriState +import tachiyomi.core.preference.getAndSet import tachiyomi.core.preference.getEnum import tachiyomi.core.preference.minusAssign import tachiyomi.core.preference.plusAssign @@ -107,19 +108,19 @@ object Migrations { } if (oldVersion < 44) { // Reset sorting preference if using removed sort by source - val oldMangaSortingMode = prefs.getInt(libraryPreferences.libraryMangaSortingMode().key(), 0) + val oldMangaSortingMode = prefs.getInt(libraryPreferences.mangaSortingMode().key(), 0) if (oldMangaSortingMode == 5) { // SOURCE = 5 prefs.edit { - putInt(libraryPreferences.libraryMangaSortingMode().key(), 0) // ALPHABETICAL = 0 + putInt(libraryPreferences.mangaSortingMode().key(), 0) // ALPHABETICAL = 0 } } - val oldAnimeSortingMode = prefs.getInt(libraryPreferences.libraryAnimeSortingMode().key(), 0) + val oldAnimeSortingMode = prefs.getInt(libraryPreferences.animeSortingMode().key(), 0) if (oldAnimeSortingMode == 5) { // SOURCE = 5 prefs.edit { - putInt(libraryPreferences.libraryAnimeSortingMode().key(), 0) // ALPHABETICAL = 0 + putInt(libraryPreferences.animeSortingMode().key(), 0) // ALPHABETICAL = 0 } } } @@ -194,9 +195,9 @@ object Migrations { } if (oldVersion < 61) { // Handle removed every 1 or 2 hour library updates - val updateInterval = libraryPreferences.libraryUpdateInterval().get() + val updateInterval = libraryPreferences.autoUpdateInterval().get() if (updateInterval == 1 || updateInterval == 2) { - libraryPreferences.libraryUpdateInterval().set(3) + libraryPreferences.autoUpdateInterval().set(3) MangaLibraryUpdateJob.setupTask(context, 3) AnimeLibraryUpdateJob.setupTask(context, 3) } @@ -207,8 +208,8 @@ object Migrations { AnimeLibraryUpdateJob.setupTask(context) } if (oldVersion < 64) { - val oldMangaSortingMode = prefs.getInt(libraryPreferences.libraryMangaSortingMode().key(), 0) - val oldAnimeSortingMode = prefs.getInt(libraryPreferences.libraryAnimeSortingMode().key(), 0) + val oldMangaSortingMode = prefs.getInt(libraryPreferences.mangaSortingMode().key(), 0) + val oldAnimeSortingMode = prefs.getInt(libraryPreferences.animeSortingMode().key(), 0) val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true) val newMangaSortingMode = when (oldMangaSortingMode) { @@ -241,14 +242,14 @@ object Migrations { } prefs.edit(commit = true) { - remove(libraryPreferences.libraryMangaSortingMode().key()) - remove(libraryPreferences.libraryAnimeSortingMode().key()) + remove(libraryPreferences.mangaSortingMode().key()) + remove(libraryPreferences.animeSortingMode().key()) remove("library_sorting_ascending") } prefs.edit { - putString(libraryPreferences.libraryMangaSortingMode().key(), newMangaSortingMode) - putString(libraryPreferences.libraryAnimeSortingMode().key(), newAnimeSortingMode) + putString(libraryPreferences.mangaSortingMode().key(), newMangaSortingMode) + putString(libraryPreferences.animeSortingMode().key(), newAnimeSortingMode) putString("library_sorting_ascending", newSortingDirection) } } @@ -259,9 +260,9 @@ object Migrations { } if (oldVersion < 71) { // Handle removed every 3, 4, 6, and 8 hour library updates - val updateInterval = libraryPreferences.libraryUpdateInterval().get() + val updateInterval = libraryPreferences.autoUpdateInterval().get() if (updateInterval in listOf(3, 4, 6, 8)) { - libraryPreferences.libraryUpdateInterval().set(12) + libraryPreferences.autoUpdateInterval().set(12) MangaLibraryUpdateJob.setupTask(context, 12) AnimeLibraryUpdateJob.setupTask(context, 12) } @@ -269,7 +270,7 @@ object Migrations { if (oldVersion < 72) { val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true) if (!oldUpdateOngoingOnly) { - libraryPreferences.libraryUpdateItemRestriction() -= ENTRY_NON_COMPLETED + libraryPreferences.autoUpdateItemRestrictions() -= ENTRY_NON_COMPLETED } } if (oldVersion < 75) { @@ -294,29 +295,29 @@ object Migrations { if (oldVersion < 81) { // Handle renamed enum values prefs.edit { - val newMangaSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.libraryMangaSortingMode().key(), "ALPHABETICAL")) { + val newMangaSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.mangaSortingMode().key(), "ALPHABETICAL")) { "LAST_CHECKED" -> "LAST_MANGA_UPDATE" "UNREAD" -> "UNREAD_COUNT" "DATE_FETCHED" -> "CHAPTER_FETCH_DATE" else -> oldSortingMode } - val newAnimeSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.libraryAnimeSortingMode().key(), "ALPHABETICAL")) { + val newAnimeSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.animeSortingMode().key(), "ALPHABETICAL")) { "LAST_CHECKED" -> "LAST_MANGA_UPDATE" "UNREAD" -> "UNREAD_COUNT" "DATE_FETCHED" -> "CHAPTER_FETCH_DATE" else -> oldSortingMode } - putString(libraryPreferences.libraryMangaSortingMode().key(), newMangaSortingMode) - putString(libraryPreferences.libraryAnimeSortingMode().key(), newAnimeSortingMode) + putString(libraryPreferences.mangaSortingMode().key(), newMangaSortingMode) + putString(libraryPreferences.animeSortingMode().key(), newAnimeSortingMode) } } if (oldVersion < 82) { prefs.edit { - val mangasort = prefs.getString(libraryPreferences.libraryMangaSortingMode().key(), null) ?: return@edit - val animesort = prefs.getString(libraryPreferences.libraryAnimeSortingMode().key(), null) ?: return@edit + val mangasort = prefs.getString(libraryPreferences.mangaSortingMode().key(), null) ?: return@edit + val animesort = prefs.getString(libraryPreferences.animeSortingMode().key(), null) ?: return@edit val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!! - putString(libraryPreferences.libraryMangaSortingMode().key(), "$mangasort,$direction") - putString(libraryPreferences.libraryAnimeSortingMode().key(), "$animesort,$direction") + putString(libraryPreferences.mangaSortingMode().key(), "$mangasort,$direction") + putString(libraryPreferences.animeSortingMode().key(), "$animesort,$direction") remove("library_sorting_ascending") } } @@ -452,6 +453,12 @@ object Migrations { readerPreferences.longStripSplitWebtoon().set(false) } } + if (oldVersion < 105) { + val pref = libraryPreferences.autoUpdateDeviceRestrictions() + if (pref.isSet() && "battery_not_low" in pref.get()) { + pref.getAndSet { it - "battery_not_low" } + } + } return true } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt index 074abc6b4..fa7f769e4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import androidx.work.BackoffPolicy +import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy @@ -78,6 +79,10 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete s.toInt(16) } if (interval > 0) { + val constraints = Constraints( + requiresBatteryNotLow = true, + ) + val request = PeriodicWorkRequestBuilder( interval.toLong(), TimeUnit.HOURS, @@ -86,6 +91,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete ) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10.minutes.toJavaDuration()) .addTag(TAG_AUTO) + .setConstraints(constraints) .setInputData( workDataOf( IS_AUTO_BACKUP_KEY to true, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt index 25f0efafa..198a81b44 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt @@ -162,10 +162,10 @@ class BackupManager( UniFile.fromUri(context, uri) } ) - ?: throw Exception("Couldn't create backup file") + ?: throw Exception(context.getString(R.string.create_backup_file_error)) if (!file.isFile) { - throw IllegalStateException("Failed to get handle on file") + throw IllegalStateException("Failed to get handle on a backup file") } val byteArray = parser.encodeToByteArray(BackupSerializer, backup) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadCache.kt index ae2128504..3cb9f9078 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadCache.kt @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.download.anime import android.content.Context import androidx.core.net.toUri import com.hippo.unifile.UniFile -import eu.kanade.core.util.mapNotNullKeys import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager import eu.kanade.tachiyomi.util.size @@ -334,21 +333,23 @@ class AnimeDownloadCache( } } - val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty() - .associate { it.name to SourceDirectory(it) } - .mapNotNullKeys { entry -> - sources.find { - provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) - }?.id - } + val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } - rootDownloadsDir.sourceDirs = sourceDirs + val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty() + .filter { it.isDirectory && !it.name.isNullOrBlank() } + .mapNotNull { dir -> + val sourceId = sourceMap[dir.name!!.lowercase()] + sourceId?.let { it to SourceDirectory(dir) } + } + .toMap() + + rootDownloadsDir.sourceDirs = sourceDirs as ConcurrentHashMap sourceDirs.values .map { sourceDir -> async { val animeDirs = sourceDir.dir.listFiles().orEmpty() - .filterNot { it.name.isNullOrBlank() } + .filter { it.isDirectory && !it.name.isNullOrBlank() } .associate { it.name!! to AnimeDirectory(it) } sourceDir.animeDirs = ConcurrentHashMap(animeDirs) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloadCache.kt index 974698cb9..83e6ccedd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloadCache.kt @@ -5,7 +5,6 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import com.hippo.unifile.UniFile -import eu.kanade.core.util.mapNotNullKeys import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager import eu.kanade.tachiyomi.source.MangaSource import eu.kanade.tachiyomi.util.size @@ -361,14 +360,16 @@ class MangaDownloadCache( } } + val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } + rootDownloadsDirLock.withLock { val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty() - .associate { it.name to SourceDirectory(it) } - .mapNotNullKeys { entry -> - sources.find { - provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) - }?.id + .filter { it.isDirectory && !it.name.isNullOrBlank() } + .mapNotNull { dir -> + val sourceId = sourceMap[dir.name!!.lowercase()] + sourceId?.let { it to SourceDirectory(dir) } } + .toMap() rootDownloadsDir.sourceDirs = sourceDirs @@ -376,7 +377,7 @@ class MangaDownloadCache( .map { sourceDir -> async { val mangaDirs = sourceDir.dir.listFiles().orEmpty() - .filterNot { it.name.isNullOrBlank() } + .filter { it.isDirectory && !it.name.isNullOrBlank() } .associate { it.name!! to MangaDirectory(it) } sourceDir.mangaDirs = ConcurrentHashMap(mangaDirs) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt index 252ac3247..88e836da4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/anime/AnimeLibraryUpdateJob.kt @@ -64,7 +64,6 @@ import tachiyomi.domain.items.episode.model.Episode import tachiyomi.domain.items.episode.model.NoEpisodesException import tachiyomi.domain.library.anime.LibraryAnime import tachiyomi.domain.library.service.LibraryPreferences -import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_BATTERY_NOT_LOW import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY_ON_WIFI @@ -113,7 +112,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa override suspend fun doWork(): Result { if (tags.contains(WORK_NAME_AUTO)) { val preferences = Injekt.get() - val restrictions = preferences.libraryUpdateDeviceRestriction().get() + val restrictions = preferences.autoUpdateDeviceRestrictions().get() if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { return Result.failure() } @@ -134,7 +133,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa // If this is a chapter update, set the last update time to now if (target == Target.EPISODES) { - libraryPreferences.libraryUpdateLastTimestamp().set(Date().time) + libraryPreferences.lastUpdatedTimestamp().set(Date().time) } val categoryId = inputData.getLong(KEY_CATEGORY, -1L) @@ -181,14 +180,14 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa val listToUpdate = if (categoryId != -1L) { libraryAnime.filter { it.category == categoryId } } else { - val categoriesToUpdate = libraryPreferences.animeLibraryUpdateCategories().get().map { it.toLong() } + val categoriesToUpdate = libraryPreferences.animeUpdateCategories().get().map { it.toLong() } val includedAnime = if (categoriesToUpdate.isNotEmpty()) { libraryAnime.filter { it.category in categoriesToUpdate } } else { libraryAnime } - val categoriesToExclude = libraryPreferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() } + val categoriesToExclude = libraryPreferences.animeUpdateCategoriesExclude().get().map { it.toLong() } val excludedAnimeIds = if (categoriesToExclude.isNotEmpty()) { libraryAnime.filter { it.category in categoriesToExclude }.map { it.anime.id } } else { @@ -229,7 +228,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa val skippedUpdates = CopyOnWriteArrayList>() val failedUpdates = CopyOnWriteArrayList>() val hasDownloads = AtomicBoolean(false) - val restrictions = libraryPreferences.libraryUpdateItemRestriction().get() + val restrictions = libraryPreferences.autoUpdateItemRestrictions().get() val fetchWindow = setAnimeFetchInterval.getWindow(ZonedDateTime.now()) coroutineScope { @@ -558,13 +557,13 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa prefInterval: Int? = null, ) { val preferences = Injekt.get() - val interval = prefInterval ?: preferences.libraryUpdateInterval().get() + val interval = prefInterval ?: preferences.autoUpdateInterval().get() if (interval > 0) { - val restrictions = preferences.libraryUpdateDeviceRestriction().get() + val restrictions = preferences.autoUpdateDeviceRestrictions().get() val constraints = Constraints( requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED }, requiresCharging = DEVICE_CHARGING in restrictions, - requiresBatteryNotLow = DEVICE_BATTERY_NOT_LOW in restrictions, + requiresBatteryNotLow = true, ) val request = PeriodicWorkRequestBuilder( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt index a5460a146..876244417 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/manga/MangaLibraryUpdateJob.kt @@ -64,7 +64,6 @@ import tachiyomi.domain.items.chapter.model.Chapter import tachiyomi.domain.items.chapter.model.NoChaptersException import tachiyomi.domain.library.manga.LibraryManga import tachiyomi.domain.library.service.LibraryPreferences -import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_BATTERY_NOT_LOW import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY_ON_WIFI @@ -113,7 +112,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa override suspend fun doWork(): Result { if (tags.contains(WORK_NAME_AUTO)) { val preferences = Injekt.get() - val restrictions = preferences.libraryUpdateDeviceRestriction().get() + val restrictions = preferences.autoUpdateDeviceRestrictions().get() if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { return Result.retry() } @@ -134,7 +133,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa // If this is a chapter update, set the last update time to now if (target == Target.CHAPTERS) { - libraryPreferences.libraryUpdateLastTimestamp().set(Date().time) + libraryPreferences.lastUpdatedTimestamp().set(Date().time) } val categoryId = inputData.getLong(KEY_CATEGORY, -1L) @@ -181,14 +180,14 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa val listToUpdate = if (categoryId != -1L) { libraryManga.filter { it.category == categoryId } } else { - val categoriesToUpdate = libraryPreferences.mangaLibraryUpdateCategories().get().map { it.toLong() } + val categoriesToUpdate = libraryPreferences.mangaUpdateCategories().get().map { it.toLong() } val includedManga = if (categoriesToUpdate.isNotEmpty()) { libraryManga.filter { it.category in categoriesToUpdate } } else { libraryManga } - val categoriesToExclude = libraryPreferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() } + val categoriesToExclude = libraryPreferences.mangaUpdateCategoriesExclude().get().map { it.toLong() } val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) { libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id } } else { @@ -229,7 +228,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa val skippedUpdates = CopyOnWriteArrayList>() val failedUpdates = CopyOnWriteArrayList>() val hasDownloads = AtomicBoolean(false) - val restrictions = libraryPreferences.libraryUpdateItemRestriction().get() + val restrictions = libraryPreferences.autoUpdateItemRestrictions().get() val fetchWindow = setMangaFetchInterval.getWindow(ZonedDateTime.now()) coroutineScope { @@ -557,13 +556,13 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa prefInterval: Int? = null, ) { val preferences = Injekt.get() - val interval = prefInterval ?: preferences.libraryUpdateInterval().get() + val interval = prefInterval ?: preferences.autoUpdateInterval().get() if (interval > 0) { - val restrictions = preferences.libraryUpdateDeviceRestriction().get() + val restrictions = preferences.autoUpdateDeviceRestrictions().get() val constraints = Constraints( requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED }, requiresCharging = DEVICE_CHARGING in restrictions, - requiresBatteryNotLow = DEVICE_BATTERY_NOT_LOW in restrictions, + requiresBatteryNotLow = true, ) val request = PeriodicWorkRequestBuilder( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/AnimeTrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/AnimeTrackService.kt index 1a5ec57c2..af48e5624 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/AnimeTrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/AnimeTrackService.kt @@ -17,9 +17,12 @@ import tachiyomi.domain.items.episode.interactor.GetEpisodeByAnimeId import tachiyomi.domain.track.anime.interactor.InsertAnimeTrack import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.time.ZoneOffset import tachiyomi.domain.track.anime.model.AnimeTrack as DomainAnimeTrack +private val insertTrack: InsertAnimeTrack by injectLazy() + interface AnimeTrackService { // Common functions @@ -63,7 +66,7 @@ interface AnimeTrackService { var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext - Injekt.get().await(track) + insertTrack.await(track) // Update episode progress if newer episodes marked seen locally if (hasSeenEpisodes) { @@ -71,7 +74,7 @@ interface AnimeTrackService { .sortedBy { it.episodeNumber } .takeWhile { it.seen } .lastOrNull() - ?.episodeNumber?.toDouble() ?: -1.0 + ?.episodeNumber ?: -1.0 if (latestLocalSeenEpisodeNumber > track.lastEpisodeSeen) { track = track.copy( @@ -123,6 +126,7 @@ interface AnimeTrackService { track.last_episode_seen = episodeNumber.toFloat() if (track.total_episodes != 0 && track.last_episode_seen.toInt() == track.total_episodes) { track.status = getCompletionStatus() + track.finished_watching_date = System.currentTimeMillis() } withIOContext { updateRemote(track) } } @@ -147,7 +151,7 @@ interface AnimeTrackService { try { update(track) track.toDomainTrack(idRequired = false)?.let { - Injekt.get().await(it) + insertTrack.await(it) } } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/MangaTrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/MangaTrackService.kt index 900d6bbd2..19f45d7c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/MangaTrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/MangaTrackService.kt @@ -17,9 +17,12 @@ import tachiyomi.domain.items.chapter.interactor.GetChapterByMangaId import tachiyomi.domain.track.manga.interactor.InsertMangaTrack import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.time.ZoneOffset import tachiyomi.domain.track.manga.model.MangaTrack as DomainTrack +private val insertTrack: InsertMangaTrack by injectLazy() + interface MangaTrackService { // Common functions @@ -63,7 +66,7 @@ interface MangaTrackService { var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext - Injekt.get().await(track) + insertTrack.await(track) // Update chapter progress if newer chapters marked read locally if (hasReadChapters) { @@ -71,7 +74,7 @@ interface MangaTrackService { .sortedBy { it.chapterNumber } .takeWhile { it.read } .lastOrNull() - ?.chapterNumber?.toDouble() ?: -1.0 + ?.chapterNumber ?: -1.0 if (latestLocalReadChapterNumber > track.lastChapterRead) { track = track.copy( @@ -123,6 +126,7 @@ interface MangaTrackService { track.last_chapter_read = chapterNumber.toFloat() if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) { track.status = getCompletionStatus() + track.finished_reading_date = System.currentTimeMillis() } withIOContext { updateRemote(track) } } @@ -147,7 +151,7 @@ interface MangaTrackService { try { update(track) track.toDomainTrack(idRequired = false)?.let { - Injekt.get().await(it) + insertTrack.await(it) } } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index e8add8ccb..66e756612 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -4,7 +4,6 @@ import androidx.annotation.CallSuper import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.tachiyomi.network.NetworkHelper import okhttp3.OkHttpClient @@ -12,7 +11,6 @@ import uy.kohesive.injekt.injectLazy abstract class TrackService(val id: Long) { - val preferences: BasePreferences by injectLazy() val trackPreferences: TrackPreferences by injectLazy() val networkService: NetworkHelper by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt index 6a6843010..1b4cbe9c1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt @@ -66,7 +66,10 @@ class AnimeExtensionManager( fun getAppIconForSource(sourceId: Long): Drawable? { val pkgName = _installedAnimeExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName if (pkgName != null) { - return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) } + return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { + AnimeExtensionLoader.getAnimeExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo + .loadIcon(context.packageManager) + } } return null } @@ -333,6 +336,7 @@ class AnimeExtensionManager( } override fun onPackageUninstalled(pkgName: String) { + AnimeExtensionLoader.uninstallPrivateExtension(context, pkgName) unregisterAnimeExtension(pkgName) updatePendingUpdatesCount() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt index 48cb4b3f7..83c3e63e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt @@ -32,6 +32,7 @@ sealed class AnimeExtension { val hasUpdate: Boolean = false, val isObsolete: Boolean = false, val isUnofficial: Boolean = false, + val isShared: Boolean, ) : AnimeExtension() data class Available( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt index 3f1ef0ea0..7afb11807 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt @@ -4,6 +4,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.net.Uri +import androidx.core.content.ContextCompat +import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult import kotlinx.coroutines.CoroutineStart @@ -27,7 +30,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : * Registers this broadcast receiver */ fun register(context: Context) { - context.registerReceiver(this, filter) + ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) } /** @@ -38,6 +41,9 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(ACTION_EXTENSION_ADDED) + addAction(ACTION_EXTENSION_REPLACED) + addAction(ACTION_EXTENSION_REMOVED) addDataScheme("package") } @@ -49,7 +55,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : if (intent == null) return when (intent.action) { - Intent.ACTION_PACKAGE_ADDED -> { + Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> { if (isReplacing(intent)) return launchNow { @@ -61,7 +67,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : } } } - Intent.ACTION_PACKAGE_REPLACED -> { + Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> { launchNow { when (val result = getExtensionFromIntent(context, intent)) { is AnimeLoadResult.Success -> listener.onExtensionUpdated(result.extension) @@ -71,7 +77,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : } } } - Intent.ACTION_PACKAGE_REMOVED -> { + Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> { if (isReplacing(intent)) return val pkgName = getPackageNameFromIntent(intent) @@ -127,4 +133,30 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : fun onExtensionUntrusted(extension: AnimeExtension.Untrusted) fun onPackageUninstalled(pkgName: String) } + + companion object { + private const val ACTION_EXTENSION_ADDED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_ADDED" + private const val ACTION_EXTENSION_REPLACED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REPLACED" + private const val ACTION_EXTENSION_REMOVED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REMOVED" + + fun notifyAdded(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_EXTENSION_ADDED) + } + + fun notifyReplaced(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_EXTENSION_REPLACED) + } + + fun notifyRemoved(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_EXTENSION_REMOVED) + } + + private fun notify(context: Context, pkgName: String, action: String) { + Intent(action).apply { + data = Uri.parse("package:$pkgName") + `package` = context.packageName + context.sendBroadcast(this) + } + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt index fb2e41b18..19437d615 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt @@ -12,9 +12,11 @@ import androidx.core.content.getSystemService import androidx.core.net.toUri import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.extension.InstallStep +import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager import eu.kanade.tachiyomi.extension.anime.installer.InstallerAnime import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.isPackageInstalled import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -157,6 +159,35 @@ internal class AnimeExtensionInstaller(private val context: Context) { context.startActivity(intent) } + BasePreferences.ExtensionInstaller.PRIVATE -> { + val extensionManager = Injekt.get() + val tempFile = File(context.cacheDir, "temp_$downloadId") + + if (tempFile.exists() && !tempFile.delete()) { + // Unlikely but just in case + extensionManager.updateInstallStep(downloadId, InstallStep.Error) + return + } + + try { + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + if (AnimeExtensionLoader.installPrivateExtensionFile(context, tempFile)) { + extensionManager.updateInstallStep(downloadId, InstallStep.Installed) + } else { + extensionManager.updateInstallStep(downloadId, InstallStep.Error) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." } + extensionManager.updateInstallStep(downloadId, InstallStep.Error) + } + + tempFile.delete() + } else -> { val intent = AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer) @@ -180,10 +211,15 @@ internal class AnimeExtensionInstaller(private val context: Context) { * @param pkgName The package name of the extension to uninstall */ fun uninstallApk(pkgName: String) { - val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - context.startActivity(intent) + if (context.isPackageInstalled(pkgName)) { + @Suppress("DEPRECATION") + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + AnimeExtensionLoader.uninstallPrivateExtension(context, pkgName) + AnimeExtensionInstallReceiver.notifyRemoved(context, pkgName) + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt index 83c81622f..3e826945b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.anime.util import android.annotation.SuppressLint import android.content.Context +import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build @@ -14,12 +15,13 @@ import eu.kanade.tachiyomi.animesource.AnimeSourceFactory import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult import eu.kanade.tachiyomi.util.lang.Hash -import eu.kanade.tachiyomi.util.system.getApplicationIcon import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import logcat.LogPriority import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.injectLazy +import java.io.File /** * Class that handles the loading of the extensions installed in the system. @@ -41,12 +43,11 @@ internal object AnimeExtensionLoader { const val LIB_VERSION_MIN = 12 const val LIB_VERSION_MAX = 15 - private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES - } else { - @Suppress("DEPRECATION") - PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES - } + @Suppress("DEPRECATION") + private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or + PackageManager.GET_META_DATA or + PackageManager.GET_SIGNATURES or + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0) // jmir1's key private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" @@ -56,8 +57,57 @@ internal object AnimeExtensionLoader { */ var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() + private const val PRIVATE_EXTENSION_EXTENSION = "ext" + + private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") + + fun installPrivateExtensionFile(context: Context, file: File): Boolean { + val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS) + ?.takeIf { isPackageAnExtension(it) } ?: return false + val currentExtension = getAnimeExtensionPackageInfoFromPkgName(context, extension.packageName) + + if (currentExtension != null) { + if (PackageInfoCompat.getLongVersionCode(extension) < + PackageInfoCompat.getLongVersionCode(currentExtension) + ) { + logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." } + return false + } + + val extensionSignatures = getSignatures(extension) + if (extensionSignatures.isNullOrEmpty()) { + logcat(LogPriority.ERROR) { "Extension to be installed is not signed." } + return false + } + + if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) { + logcat(LogPriority.ERROR) { "Installed extension signature is not matched." } + return false + } + } + + val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION") + return try { + file.copyTo(target, overwrite = true) + if (currentExtension != null) { + AnimeExtensionInstallReceiver.notifyReplaced(context, extension.packageName) + } else { + AnimeExtensionInstallReceiver.notifyAdded(context, extension.packageName) + } + true + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to copy extension file." } + target.delete() + false + } + } + + fun uninstallPrivateExtension(context: Context, pkgName: String) { + File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete() + } + /** - * Return a list of all the installed extensions initialized concurrently. + * Return a list of all the available extensions initialized concurrently. * * @param context The application context. */ @@ -70,16 +120,43 @@ internal object AnimeExtensionLoader { pkgManager.getInstalledPackages(PACKAGE_FLAGS) } - val extPkgs = installedPkgs.filter { isPackageAnExtension(it) } + val sharedExtPkgs = installedPkgs + .asSequence() + .filter { isPackageAnExtension(it) } + .map { AnimeExtensionInfo(packageInfo = it, isShared = true) } + + val privateExtPkgs = getPrivateExtensionDir(context) + .listFiles() + ?.asSequence() + ?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION } + ?.mapNotNull { + val path = it.absolutePath + pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS) + ?.apply { applicationInfo.fixBasePaths(path) } + } + ?.filter { isPackageAnExtension(it) } + ?.map { AnimeExtensionInfo(packageInfo = it, isShared = false) } + ?: emptySequence() + + val extPkgs = (sharedExtPkgs + privateExtPkgs) + // Remove duplicates. Shared takes priority than private by default + .distinctBy { it.packageInfo.packageName } + // Compare version number + .mapNotNull { sharedPkg -> + val privatePkg = privateExtPkgs + .singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName } + selectExtensionPackage(sharedPkg, privatePkg) + } + .toList() if (extPkgs.isEmpty()) return emptyList() // Load each extension concurrently and wait for completion return runBlocking { val deferred = extPkgs.map { - async { loadExtension(context, it.packageName, it) } + async { loadExtension(context, it) } } - deferred.map { it.await() } + deferred.awaitAll() } } @@ -88,37 +165,62 @@ internal object AnimeExtensionLoader { * contains the required feature flag before trying to load it. */ fun loadExtensionFromPkgName(context: Context, pkgName: String): AnimeLoadResult { - val pkgInfo = try { + val extensionPackage = getAnimeExtensionInfoFromPkgName(context, pkgName) + if (extensionPackage == null) { + logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" } + return AnimeLoadResult.Error + } + return loadExtension(context, extensionPackage) + } + + fun getAnimeExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? { + return getAnimeExtensionInfoFromPkgName(context, pkgName)?.packageInfo + } + + private fun getAnimeExtensionInfoFromPkgName(context: Context, pkgName: String): AnimeExtensionInfo? { + val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION") + val privatePkg = if (privateExtensionFile.isFile) { + context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS) + ?.takeIf { isPackageAnExtension(it) } + ?.let { + it.applicationInfo.fixBasePaths(privateExtensionFile.absolutePath) + AnimeExtensionInfo( + packageInfo = it, + isShared = false, + ) + } + } else { + null + } + + val sharedPkg = try { context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) + .takeIf { isPackageAnExtension(it) } + ?.let { + AnimeExtensionInfo( + packageInfo = it, + isShared = true, + ) + } } catch (error: PackageManager.NameNotFoundException) { - // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) - return AnimeLoadResult.Error + null } - if (!isPackageAnExtension(pkgInfo)) { - logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } - return AnimeLoadResult.Error - } - return loadExtension(context, pkgName, pkgInfo) + + return selectExtensionPackage(sharedPkg, privatePkg) } /** - * Loads an extension given its package name. + * Loads an extension * * @param context The application context. - * @param pkgName The package name of the extension to load. - * @param pkgInfo The package info of the extension. + * @param extensionInfo The extension to load. */ - private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): AnimeLoadResult { + private fun loadExtension(context: Context, extensionInfo: AnimeExtensionInfo): AnimeLoadResult { val pkgManager = context.packageManager - val appInfo = try { - pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) - } catch (error: PackageManager.NameNotFoundException) { - // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) - return AnimeLoadResult.Error - } + val pkgInfo = extensionInfo.packageInfo + val appInfo = pkgInfo.applicationInfo + val pkgName = pkgInfo.packageName val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Aniyomi: ") val versionName = pkgInfo.versionName @@ -139,13 +241,19 @@ internal object AnimeExtensionLoader { return AnimeLoadResult.Error } - val signatureHash = getSignatureHash(context, pkgInfo) - - if (signatureHash == null) { + val signatures = getSignatures(pkgInfo) + if (signatures.isNullOrEmpty()) { logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } return AnimeLoadResult.Error - } else if (signatureHash !in trustedSignatures) { - val extension = AnimeExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash) + } else if (!hasTrustedSignature(signatures)) { + val extension = AnimeExtension.Untrusted( + extName, + pkgName, + versionName, + versionCode, + libVersion, + signatures.last(), + ) logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" }) return AnimeLoadResult.Untrusted(extension) } @@ -205,12 +313,35 @@ internal object AnimeExtensionLoader { hasChangelog = hasChangelog, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), - isUnofficial = signatureHash != officialSignature, - icon = context.getApplicationIcon(pkgName), + isUnofficial = !isOfficiallySigned(signatures), + icon = appInfo.loadIcon(pkgManager), + isShared = extensionInfo.isShared, ) return AnimeLoadResult.Success(extension) } + /** + * Choose which extension package to use based on version code + * + * @param shared extension installed to system + * @param private extension installed to data directory + */ + private fun selectExtensionPackage(shared: AnimeExtensionInfo?, private: AnimeExtensionInfo?): AnimeExtensionInfo? { + when { + private == null && shared != null -> return shared + shared == null && private != null -> return private + shared == null && private == null -> return null + } + + return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >= + PackageInfoCompat.getLongVersionCode(private!!.packageInfo) + ) { + shared + } else { + private + } + } + /** * Returns true if the given package is an extension. * @@ -221,12 +352,50 @@ internal object AnimeExtensionLoader { } /** - * Returns the signature hash of the package or null if it's not signed. + * Returns the signatures of the package or null if it's not signed. * * @param pkgInfo The package info of the application. + * @return List SHA256 digest of the signatures */ - private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? { - val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName) - return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) } + private fun getSignatures(pkgInfo: PackageInfo): List? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = pkgInfo.signingInfo + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + pkgInfo.signatures + } + ?.map { Hash.sha256(it.toByteArray()) } + ?.toList() } + + private fun hasTrustedSignature(signatures: List): Boolean { + return trustedSignatures.any { signatures.contains(it) } + } + + private fun isOfficiallySigned(signatures: List): Boolean { + return signatures.all { it == officialSignature } + } + + /** + * On Android 13+ the ApplicationInfo generated by getPackageArchiveInfo doesn't + * have sourceDir which breaks assets loading (used for getting icon here). + */ + private fun ApplicationInfo.fixBasePaths(apkPath: String) { + if (sourceDir == null) { + sourceDir = apkPath + } + if (publicSourceDir == null) { + publicSourceDir = apkPath + } + } + + private data class AnimeExtensionInfo( + val packageInfo: PackageInfo, + val isShared: Boolean, + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt index 3ad9d34ec..3146c5755 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt @@ -66,7 +66,10 @@ class MangaExtensionManager( fun getAppIconForSource(sourceId: Long): Drawable? { val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName if (pkgName != null) { - return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) } + return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { + MangaExtensionLoader.getMangaExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo + .loadIcon(context.packageManager) + } } return null } @@ -333,6 +336,7 @@ class MangaExtensionManager( } override fun onPackageUninstalled(pkgName: String) { + MangaExtensionLoader.uninstallPrivateExtension(context, pkgName) unregisterExtension(pkgName) updatePendingUpdatesCount() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt index 5030c24ec..c3edd4f1b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt @@ -32,6 +32,7 @@ sealed class MangaExtension { val hasUpdate: Boolean = false, val isObsolete: Boolean = false, val isUnofficial: Boolean = false, + val isShared: Boolean, ) : MangaExtension() data class Available( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt index 17dac9b43..a930f4a16 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt @@ -4,6 +4,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.net.Uri +import androidx.core.content.ContextCompat +import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult import kotlinx.coroutines.CoroutineStart @@ -27,7 +30,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) : * Registers this broadcast receiver */ fun register(context: Context) { - context.registerReceiver(this, filter) + ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) } /** @@ -38,6 +41,9 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) : addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(ACTION_EXTENSION_ADDED) + addAction(ACTION_EXTENSION_REPLACED) + addAction(ACTION_EXTENSION_REMOVED) addDataScheme("package") } @@ -49,7 +55,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) : if (intent == null) return when (intent.action) { - Intent.ACTION_PACKAGE_ADDED -> { + Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> { if (isReplacing(intent)) return launchNow { @@ -61,7 +67,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) : } } } - Intent.ACTION_PACKAGE_REPLACED -> { + Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> { launchNow { when (val result = getExtensionFromIntent(context, intent)) { is MangaLoadResult.Success -> listener.onExtensionUpdated(result.extension) @@ -71,7 +77,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) : } } } - Intent.ACTION_PACKAGE_REMOVED -> { + Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> { if (isReplacing(intent)) return val pkgName = getPackageNameFromIntent(intent) @@ -127,4 +133,30 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) : fun onExtensionUntrusted(extension: MangaExtension.Untrusted) fun onPackageUninstalled(pkgName: String) } + + companion object { + private const val ACTION_EXTENSION_ADDED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_ADDED" + private const val ACTION_EXTENSION_REPLACED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REPLACED" + private const val ACTION_EXTENSION_REMOVED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REMOVED" + + fun notifyAdded(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_EXTENSION_ADDED) + } + + fun notifyReplaced(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_EXTENSION_REPLACED) + } + + fun notifyRemoved(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_EXTENSION_REMOVED) + } + + private fun notify(context: Context, pkgName: String, action: String) { + Intent(action).apply { + data = Uri.parse("package:$pkgName") + `package` = context.packageName + context.sendBroadcast(this) + } + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt index 64f496be0..ce8276942 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt @@ -12,9 +12,11 @@ import androidx.core.content.getSystemService import androidx.core.net.toUri import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.extension.InstallStep +import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager import eu.kanade.tachiyomi.extension.manga.installer.InstallerManga import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.isPackageInstalled import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -157,6 +159,35 @@ internal class MangaExtensionInstaller(private val context: Context) { context.startActivity(intent) } + BasePreferences.ExtensionInstaller.PRIVATE -> { + val extensionManager = Injekt.get() + val tempFile = File(context.cacheDir, "temp_$downloadId") + + if (tempFile.exists() && !tempFile.delete()) { + // Unlikely but just in case + extensionManager.updateInstallStep(downloadId, InstallStep.Error) + return + } + + try { + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + if (MangaExtensionLoader.installPrivateExtensionFile(context, tempFile)) { + extensionManager.updateInstallStep(downloadId, InstallStep.Installed) + } else { + extensionManager.updateInstallStep(downloadId, InstallStep.Error) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." } + extensionManager.updateInstallStep(downloadId, InstallStep.Error) + } + + tempFile.delete() + } else -> { val intent = MangaExtensionInstallService.getIntent(context, downloadId, uri, installer) @@ -180,10 +211,15 @@ internal class MangaExtensionInstaller(private val context: Context) { * @param pkgName The package name of the extension to uninstall */ fun uninstallApk(pkgName: String) { - val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - context.startActivity(intent) + if (context.isPackageInstalled(pkgName)) { + @Suppress("DEPRECATION") + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + MangaExtensionLoader.uninstallPrivateExtension(context, pkgName) + MangaExtensionInstallReceiver.notifyRemoved(context, pkgName) + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt index 83d7d1a99..2cb7d5216 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt @@ -2,10 +2,12 @@ package eu.kanade.tachiyomi.extension.manga.util import android.annotation.SuppressLint import android.content.Context +import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import androidx.core.content.pm.PackageInfoCompat +import androidx.core.content.pm.PackageInfoCompat.getSignatures import dalvik.system.PathClassLoader import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.manga.model.MangaExtension @@ -14,15 +16,27 @@ import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.MangaSource import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.util.lang.Hash -import eu.kanade.tachiyomi.util.system.getApplicationIcon import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import logcat.LogPriority import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.injectLazy +import java.io.File /** - * Class that handles the loading of the extensions installed in the system. + * Class that handles the loading of the extensions. Supports two kinds of extensions: + * + * 1. Shared extension: This extension is installed to the system with package + * installer, so other variants of Tachiyomi/Aniyomi and its forks can also use this extension. + * + * 2. Private extension: This extension is put inside private data directory of the + * running app, so this extension can only be used by the running app and not shared + * with other apps. + * + * When both kinds of extensions are installed with a same package name, shared + * extension will be used unless the version codes are different. In that case the + * one with higher version code will be used. */ @SuppressLint("PackageManagerGetSignatures") internal object MangaExtensionLoader { @@ -41,12 +55,11 @@ internal object MangaExtensionLoader { const val LIB_VERSION_MIN = 1.2 const val LIB_VERSION_MAX = 1.5 - private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES - } else { - @Suppress("DEPRECATION") - PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES - } + @Suppress("DEPRECATION") + private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or + PackageManager.GET_META_DATA or + PackageManager.GET_SIGNATURES or + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0) // inorichi's key private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" @@ -56,8 +69,57 @@ internal object MangaExtensionLoader { */ var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() + private const val PRIVATE_EXTENSION_EXTENSION = "ext" + + private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") + + fun installPrivateExtensionFile(context: Context, file: File): Boolean { + val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS) + ?.takeIf { isPackageAnExtension(it) } ?: return false + val currentExtension = getMangaExtensionPackageInfoFromPkgName(context, extension.packageName) + + if (currentExtension != null) { + if (PackageInfoCompat.getLongVersionCode(extension) < + PackageInfoCompat.getLongVersionCode(currentExtension) + ) { + logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." } + return false + } + + val extensionSignatures = getSignatures(extension) + if (extensionSignatures.isNullOrEmpty()) { + logcat(LogPriority.ERROR) { "Extension to be installed is not signed." } + return false + } + + if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) { + logcat(LogPriority.ERROR) { "Installed extension signature is not matched." } + return false + } + } + + val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION") + return try { + file.copyTo(target, overwrite = true) + if (currentExtension != null) { + MangaExtensionInstallReceiver.notifyReplaced(context, extension.packageName) + } else { + MangaExtensionInstallReceiver.notifyAdded(context, extension.packageName) + } + true + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to copy extension file." } + target.delete() + false + } + } + + fun uninstallPrivateExtension(context: Context, pkgName: String) { + File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete() + } + /** - * Return a list of all the installed extensions initialized concurrently. + * Return a list of all the available extensions initialized concurrently. * * @param context The application context. */ @@ -70,16 +132,43 @@ internal object MangaExtensionLoader { pkgManager.getInstalledPackages(PACKAGE_FLAGS) } - val extPkgs = installedPkgs.filter { isPackageAnExtension(it) } + val sharedExtPkgs = installedPkgs + .asSequence() + .filter { isPackageAnExtension(it) } + .map { MangaExtensionInfo(packageInfo = it, isShared = true) } + + val privateExtPkgs = getPrivateExtensionDir(context) + .listFiles() + ?.asSequence() + ?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION } + ?.mapNotNull { + val path = it.absolutePath + pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS) + ?.apply { applicationInfo.fixBasePaths(path) } + } + ?.filter { isPackageAnExtension(it) } + ?.map { MangaExtensionInfo(packageInfo = it, isShared = false) } + ?: emptySequence() + + val extPkgs = (sharedExtPkgs + privateExtPkgs) + // Remove duplicates. Shared takes priority than private by default + .distinctBy { it.packageInfo.packageName } + // Compare version number + .mapNotNull { sharedPkg -> + val privatePkg = privateExtPkgs + .singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName } + selectExtensionPackage(sharedPkg, privatePkg) + } + .toList() if (extPkgs.isEmpty()) return emptyList() // Load each extension concurrently and wait for completion return runBlocking { val deferred = extPkgs.map { - async { loadMangaExtension(context, it.packageName, it) } + async { loadMangaExtension(context, it) } } - deferred.map { it.await() } + deferred.awaitAll() } } @@ -88,37 +177,61 @@ internal object MangaExtensionLoader { * contains the required feature flag before trying to load it. */ fun loadMangaExtensionFromPkgName(context: Context, pkgName: String): MangaLoadResult { - val pkgInfo = try { + val extensionPackage = getMangaExtensionInfoFromPkgName(context, pkgName) + if (extensionPackage == null) { + logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" } + return MangaLoadResult.Error + } + return loadMangaExtension(context, extensionPackage) + } + + fun getMangaExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? { + return getMangaExtensionInfoFromPkgName(context, pkgName)?.packageInfo + } + + private fun getMangaExtensionInfoFromPkgName(context: Context, pkgName: String): MangaExtensionInfo? { + val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION") + val privatePkg = if (privateExtensionFile.isFile) { + context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS) + ?.takeIf { isPackageAnExtension(it) } + ?.let { + it.applicationInfo.fixBasePaths(privateExtensionFile.absolutePath) + MangaExtensionInfo( + packageInfo = it, + isShared = false, + ) + } + } else { + null + } + + val sharedPkg = try { context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) + .takeIf { isPackageAnExtension(it) } + ?.let { + MangaExtensionInfo( + packageInfo = it, + isShared = true, + ) + } } catch (error: PackageManager.NameNotFoundException) { - // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) - return MangaLoadResult.Error + null } - if (!isPackageAnExtension(pkgInfo)) { - logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } - return MangaLoadResult.Error - } - return loadMangaExtension(context, pkgName, pkgInfo) + + return selectExtensionPackage(sharedPkg, privatePkg) } /** - * Loads an extension given its package name. + * Loads an extension * * @param context The application context. - * @param pkgName The package name of the extension to load. - * @param pkgInfo The package info of the extension. + * @param extensionInfo The extension to load. */ - private fun loadMangaExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): MangaLoadResult { + private fun loadMangaExtension(context: Context, extensionInfo: MangaExtensionInfo): MangaLoadResult { val pkgManager = context.packageManager - - val appInfo = try { - pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) - } catch (error: PackageManager.NameNotFoundException) { - // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) - return MangaLoadResult.Error - } + val pkgInfo = extensionInfo.packageInfo + val appInfo = pkgInfo.applicationInfo + val pkgName = pkgInfo.packageName val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") val versionName = pkgInfo.versionName @@ -139,13 +252,19 @@ internal object MangaExtensionLoader { return MangaLoadResult.Error } - val signatureHash = getSignatureHash(context, pkgInfo) - - if (signatureHash == null) { + val signatures = getSignatures(pkgInfo) + if (signatures.isNullOrEmpty()) { logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } return MangaLoadResult.Error - } else if (signatureHash !in trustedSignatures) { - val extension = MangaExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash) + } else if (!hasTrustedSignature(signatures)) { + val extension = MangaExtension.Untrusted( + extName, + pkgName, + versionName, + versionCode, + libVersion, + signatures.last(), + ) logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" } return MangaLoadResult.Untrusted(extension) } @@ -205,12 +324,35 @@ internal object MangaExtensionLoader { hasChangelog = hasChangelog, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), - isUnofficial = signatureHash != officialSignature, - icon = context.getApplicationIcon(pkgName), + isUnofficial = !isOfficiallySigned(signatures), + icon = appInfo.loadIcon(pkgManager), + isShared = extensionInfo.isShared, ) return MangaLoadResult.Success(extension) } + /** + * Choose which extension package to use based on version code + * + * @param shared extension installed to system + * @param private extension installed to data directory + */ + private fun selectExtensionPackage(shared: MangaExtensionInfo?, private: MangaExtensionInfo?): MangaExtensionInfo? { + when { + private == null && shared != null -> return shared + shared == null && private != null -> return private + shared == null && private == null -> return null + } + + return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >= + PackageInfoCompat.getLongVersionCode(private!!.packageInfo) + ) { + shared + } else { + private + } + } + /** * Returns true if the given package is an extension. * @@ -221,12 +363,50 @@ internal object MangaExtensionLoader { } /** - * Returns the signature hash of the package or null if it's not signed. + * Returns the signatures of the package or null if it's not signed. * * @param pkgInfo The package info of the application. + * @return List SHA256 digest of the signatures */ - private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? { - val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName) - return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) } + private fun getSignatures(pkgInfo: PackageInfo): List? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = pkgInfo.signingInfo + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + pkgInfo.signatures + } + ?.map { Hash.sha256(it.toByteArray()) } + ?.toList() } + + private fun hasTrustedSignature(signatures: List): Boolean { + return trustedSignatures.any { signatures.contains(it) } + } + + private fun isOfficiallySigned(signatures: List): Boolean { + return signatures.all { it == officialSignature } + } + + /** + * On Android 13+ the ApplicationInfo generated by getPackageArchiveInfo doesn't + * have sourceDir which breaks assets loading (used for getting icon here). + */ + private fun ApplicationInfo.fixBasePaths(apkPath: String) { + if (sourceDir == null) { + sourceDir = apkPath + } + if (publicSourceDir == null) { + publicSourceDir = apkPath + } + } + + private data class MangaExtensionInfo( + val packageInfo: PackageInfo, + val isShared: Boolean, + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/extension/details/SourcePreferencesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/extension/details/SourcePreferencesScreen.kt index 6c6bb4810..faec7db75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/extension/details/SourcePreferencesScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/extension/details/SourcePreferencesScreen.kt @@ -147,7 +147,7 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() { sourceScreen.forEach { pref -> pref.isIconSpaceReserved = false pref.isSingleLineTitle = false - if (pref is DialogPreference) { + if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) { pref.dialogTitle = pref.title } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/extension/details/MangaSourcePreferencesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/extension/details/MangaSourcePreferencesScreen.kt index e883a7a0e..8abe666a0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/extension/details/MangaSourcePreferencesScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/extension/details/MangaSourcePreferencesScreen.kt @@ -147,7 +147,7 @@ class MangaSourcePreferencesFragment : PreferenceFragmentCompat() { sourceScreen.forEach { pref -> pref.isIconSpaceReserved = false pref.isSingleLineTitle = false - if (pref is DialogPreference) { + if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) { pref.dialogTitle = pref.title } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkAnimeActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeActivity.kt similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkAnimeActivity.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeActivity.kt index d9e7e4798..3c5e34104 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkAnimeActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeActivity.kt @@ -1,8 +1,9 @@ -package eu.kanade.tachiyomi.ui.main +package eu.kanade.tachiyomi.ui.deeplink.anime import android.app.Activity import android.content.Intent import android.os.Bundle +import eu.kanade.tachiyomi.ui.main.MainActivity class DeepLinkAnimeActivity : Activity() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreen.kt new file mode 100644 index 000000000..602c0eda9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreen.kt @@ -0,0 +1,59 @@ +package eu.kanade.tachiyomi.ui.deeplink.anime + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.anime.source.globalsearch.GlobalAnimeSearchScreen +import eu.kanade.tachiyomi.ui.entries.anime.AnimeScreen +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.screens.LoadingScreen + +class DeepLinkAnimeScreen( + val query: String = "", +) : Screen() { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + + val screenModel = rememberScreenModel { + DeepLinkAnimeScreenModel(query = query) + } + val state by screenModel.state.collectAsState() + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(R.string.action_search_hint), + navigateUp = navigator::pop, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + when (state) { + is DeepLinkAnimeScreenModel.State.Loading -> { + LoadingScreen(Modifier.padding(contentPadding)) + } + is DeepLinkAnimeScreenModel.State.NoResults -> { + navigator.replace(GlobalAnimeSearchScreen(query)) + } + is DeepLinkAnimeScreenModel.State.Result -> { + navigator.replace( + AnimeScreen( + (state as DeepLinkAnimeScreenModel.State.Result).anime.id, + true, + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreenModel.kt new file mode 100644 index 000000000..cb3c860fd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/anime/DeepLinkAnimeScreenModel.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.ui.deeplink.anime + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope +import eu.kanade.domain.entries.anime.model.toDomainAnime +import eu.kanade.tachiyomi.animesource.online.ResolvableAnimeSource +import kotlinx.coroutines.flow.update +import tachiyomi.core.util.lang.launchIO +import tachiyomi.domain.entries.anime.model.Anime +import tachiyomi.domain.source.anime.service.AnimeSourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class DeepLinkAnimeScreenModel( + query: String = "", + private val sourceManager: AnimeSourceManager = Injekt.get(), +) : StateScreenModel(State.Loading) { + + init { + coroutineScope.launchIO { + val anime = sourceManager.getCatalogueSources() + .filterIsInstance() + .filter { it.canResolveUri(query) } + .firstNotNullOfOrNull { it.getAnime(query)?.toDomainAnime(it.id) } + + mutableState.update { + if (anime == null) { + State.NoResults + } else { + State.Result(anime) + } + } + } + } + + sealed interface State { + @Immutable + data object Loading : State + + @Immutable + data object NoResults : State + + @Immutable + data class Result(val anime: Anime) : State + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkMangaActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaActivity.kt similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkMangaActivity.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaActivity.kt index db2dc12c2..fff2cffc6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkMangaActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaActivity.kt @@ -1,8 +1,9 @@ -package eu.kanade.tachiyomi.ui.main +package eu.kanade.tachiyomi.ui.deeplink.manga import android.app.Activity import android.content.Intent import android.os.Bundle +import eu.kanade.tachiyomi.ui.main.MainActivity class DeepLinkMangaActivity : Activity() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreen.kt new file mode 100644 index 000000000..3dfd10c8e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreen.kt @@ -0,0 +1,59 @@ +package eu.kanade.tachiyomi.ui.deeplink.manga + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.manga.source.globalsearch.GlobalMangaSearchScreen +import eu.kanade.tachiyomi.ui.entries.manga.MangaScreen +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.screens.LoadingScreen + +class DeepLinkMangaScreen( + val query: String = "", +) : Screen() { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + + val screenModel = rememberScreenModel { + DeepLinkMangaScreenModel(query = query) + } + val state by screenModel.state.collectAsState() + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(R.string.action_search_hint), + navigateUp = navigator::pop, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + when (state) { + is DeepLinkMangaScreenModel.State.Loading -> { + LoadingScreen(Modifier.padding(contentPadding)) + } + is DeepLinkMangaScreenModel.State.NoResults -> { + navigator.replace(GlobalMangaSearchScreen(query)) + } + is DeepLinkMangaScreenModel.State.Result -> { + navigator.replace( + MangaScreen( + (state as DeepLinkMangaScreenModel.State.Result).manga.id, + true, + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreenModel.kt new file mode 100644 index 000000000..6c17238e9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/manga/DeepLinkMangaScreenModel.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.ui.deeplink.manga + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope +import eu.kanade.domain.entries.manga.model.toDomainManga +import eu.kanade.tachiyomi.source.online.ResolvableMangaSource +import kotlinx.coroutines.flow.update +import tachiyomi.core.util.lang.launchIO +import tachiyomi.domain.entries.manga.model.Manga +import tachiyomi.domain.source.manga.service.MangaSourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class DeepLinkMangaScreenModel( + query: String = "", + private val sourceManager: MangaSourceManager = Injekt.get(), +) : StateScreenModel(State.Loading) { + + init { + coroutineScope.launchIO { + val manga = sourceManager.getCatalogueSources() + .filterIsInstance() + .filter { it.canResolveUri(query) } + .firstNotNullOfOrNull { it.getManga(query)?.toDomainManga(it.id) } + + mutableState.update { + if (manga == null) { + State.NoResults + } else { + State.Result(manga) + } + } + } + } + + sealed interface State { + @Immutable + data object Loading : State + + @Immutable + data object NoResults : State + + @Immutable + data class Result(val manga: Manga) : State + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt index 23cd6a21c..a4bbb8e0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt @@ -134,7 +134,7 @@ class AnimeScreenModel( val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) - val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get() + val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.autoUpdateItemRestrictions().get() private val selectedPositions: Array = arrayOf(-1, -1) // first and last selected index in list private val selectedEpisodeIds: HashSet = HashSet() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt index 853975f60..05743187e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/MangaScreenModel.kt @@ -130,7 +130,7 @@ class MangaScreenModel( val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) val skipFiltered by readerPreferences.skipFiltered().asState(coroutineScope) - val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get() + val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.autoUpdateItemRestrictions().get() private val selectedPositions: Array = arrayOf(-1, -1) // first and last selected index in list private val selectedChapterIds: HashSet = HashSet() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryScreenModel.kt index 49e0d8fee..4885f344f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryScreenModel.kt @@ -526,7 +526,7 @@ class AnimeLibraryScreenModel( } fun getDisplayMode(): PreferenceMutableState { - return libraryPreferences.libraryDisplayMode().asState(coroutineScope) + return libraryPreferences.displayMode().asState(coroutineScope) } fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryTab.kt index 7df6b1167..08213f64e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/anime/AnimeLibraryTab.kt @@ -168,7 +168,7 @@ object AnimeLibraryTab : Tab() { snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { contentPadding -> when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> { val handler = LocalUriHandler.current EmptyScreen( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryScreenModel.kt index 18464c71e..3a23675d4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryScreenModel.kt @@ -520,7 +520,7 @@ class MangaLibraryScreenModel( } fun getDisplayMode(): PreferenceMutableState { - return libraryPreferences.libraryDisplayMode().asState(coroutineScope) + return libraryPreferences.displayMode().asState(coroutineScope) } fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryTab.kt index d3573bdeb..cfe0743b0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/manga/MangaLibraryTab.kt @@ -165,7 +165,7 @@ object MangaLibraryTab : Tab() { snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { contentPadding -> when { - state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> { val handler = LocalUriHandler.current EmptyScreen( 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 68b7aef92..957ab2f6e 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 @@ -83,6 +83,7 @@ import eu.kanade.tachiyomi.ui.browse.anime.source.browse.BrowseAnimeSourceScreen import eu.kanade.tachiyomi.ui.browse.anime.source.globalsearch.GlobalAnimeSearchScreen import eu.kanade.tachiyomi.ui.browse.manga.source.browse.BrowseMangaSourceScreen import eu.kanade.tachiyomi.ui.browse.manga.source.globalsearch.GlobalMangaSearchScreen +import eu.kanade.tachiyomi.ui.deeplink.manga.DeepLinkMangaScreen import eu.kanade.tachiyomi.ui.entries.anime.AnimeScreen import eu.kanade.tachiyomi.ui.entries.manga.MangaScreen import eu.kanade.tachiyomi.ui.home.HomeScreen @@ -449,6 +450,7 @@ class MainActivity : BaseActivity() { if (!query.isNullOrEmpty()) { navigator.popUntilRoot() navigator.push(GlobalMangaSearchScreen(query)) + navigator.push(DeepLinkMangaScreen(query)) } null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 16360879f..b4da8a464 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -385,11 +385,14 @@ class ReaderActivity : BaseActivity() { binding.pageNumber.setComposeContent { val state by viewModel.state.collectAsState() + val showPageNumber by viewModel.readerPreferences.showPageNumber().collectAsState() - PageIndicatorText( - currentPage = state.currentPage, - totalPages = state.totalPages, - ) + if (!state.menuVisible && showPageNumber) { + PageIndicatorText( + currentPage = state.currentPage, + totalPages = state.totalPages, + ) + } } binding.readerMenuBottom.setComposeContent { @@ -557,10 +560,6 @@ class ReaderActivity : BaseActivity() { bottomAnimation.applySystemAnimatorScale(this) binding.readerMenuBottom.startAnimation(bottomAnimation) } - - if (readerPreferences.showPageNumber().get()) { - config?.setPageNumberVisibility(false) - } } else { if (readerPreferences.fullscreen().get()) { windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) @@ -583,10 +582,6 @@ class ReaderActivity : BaseActivity() { bottomAnimation.applySystemAnimatorScale(this) binding.readerMenuBottom.startAnimation(bottomAnimation) } - - if (readerPreferences.showPageNumber().get()) { - config?.setPageNumberVisibility(true) - } } } @@ -639,9 +634,8 @@ class ReaderActivity : BaseActivity() { private fun showReadingModeToast(mode: Int) { try { - val strings = resources.getStringArray(R.array.viewers_selector) readingModeToast?.cancel() - readingModeToast = toast(strings[mode]) + readingModeToast = toast(ReadingModeType.fromPreference(mode).stringRes) } catch (e: ArrayIndexOutOfBoundsException) { logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" } } @@ -895,10 +889,6 @@ class ReaderActivity : BaseActivity() { } .launchIn(lifecycleScope) - readerPreferences.showPageNumber().changes() - .onEach(::setPageNumberVisibility) - .launchIn(lifecycleScope) - readerPreferences.trueColor().changes() .onEach(::setTrueColor) .launchIn(lifecycleScope) @@ -948,13 +938,6 @@ class ReaderActivity : BaseActivity() { } } - /** - * Sets the visibility of the bottom page indicator according to [visible]. - */ - fun setPageNumberVisibility(visible: Boolean) { - binding.pageNumber.isVisible = visible - } - /** * Sets the 32-bit color mode according to [enabled]. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/OrientationType.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/OrientationType.kt index bf5135153..3650038f5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/OrientationType.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/OrientationType.kt @@ -5,14 +5,14 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import eu.kanade.tachiyomi.R -enum class OrientationType(val prefValue: Int, val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) { - DEFAULT(0, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.label_default, R.drawable.ic_screen_rotation_24dp, 0x00000000), - FREE(1, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp, 0x00000008), - PORTRAIT(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000010), - LANDSCAPE(3, ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_landscape, R.drawable.ic_stay_current_landscape_24dp, 0x00000018), - LOCKED_PORTRAIT(4, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp, 0x00000020), - LOCKED_LANDSCAPE(5, ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp, 0x00000028), - REVERSE_PORTRAIT(6, ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, R.string.rotation_reverse_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000030), +enum class OrientationType(val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) { + DEFAULT(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.label_default, R.drawable.ic_screen_rotation_24dp, 0x00000000), + FREE(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp, 0x00000008), + PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000010), + LANDSCAPE(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_landscape, R.drawable.ic_stay_current_landscape_24dp, 0x00000018), + LOCKED_PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp, 0x00000020), + LOCKED_LANDSCAPE(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp, 0x00000028), + REVERSE_PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, R.string.rotation_reverse_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000030), ; companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingModeType.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingModeType.kt index e3f3b38b7..2bce0735b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingModeType.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingModeType.kt @@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer -enum class ReadingModeType(val prefValue: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) { - DEFAULT(0, R.string.label_default, R.drawable.ic_reader_default_24dp, 0x00000000), - LEFT_TO_RIGHT(1, R.string.left_to_right_viewer, R.drawable.ic_reader_ltr_24dp, 0x00000001), - RIGHT_TO_LEFT(2, R.string.right_to_left_viewer, R.drawable.ic_reader_rtl_24dp, 0x00000002), - VERTICAL(3, R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp, 0x00000003), - WEBTOON(4, R.string.webtoon_viewer, R.drawable.ic_reader_webtoon_24dp, 0x00000004), - CONTINUOUS_VERTICAL(5, R.string.vertical_plus_viewer, R.drawable.ic_reader_continuous_vertical_24dp, 0x00000005), +enum class ReadingModeType(@StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) { + DEFAULT(R.string.label_default, R.drawable.ic_reader_default_24dp, 0x00000000), + LEFT_TO_RIGHT(R.string.left_to_right_viewer, R.drawable.ic_reader_ltr_24dp, 0x00000001), + RIGHT_TO_LEFT(R.string.right_to_left_viewer, R.drawable.ic_reader_rtl_24dp, 0x00000002), + VERTICAL(R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp, 0x00000003), + WEBTOON(R.string.webtoon_viewer, R.drawable.ic_reader_webtoon_24dp, 0x00000004), + CONTINUOUS_VERTICAL(R.string.vertical_plus_viewer, R.drawable.ic_reader_continuous_vertical_24dp, 0x00000005), ; companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/anime/AnimeStatsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/anime/AnimeStatsScreenModel.kt index 81f5c25f7..e34eccb70 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/anime/AnimeStatsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/anime/AnimeStatsScreenModel.kt @@ -88,14 +88,14 @@ class AnimeStatsScreenModel( } private fun getGlobalUpdateItemCount(libraryAnime: List): Int { - val includedCategories = preferences.animeLibraryUpdateCategories().get().map { it.toLong() } + val includedCategories = preferences.animeUpdateCategories().get().map { it.toLong() } val includedAnime = if (includedCategories.isNotEmpty()) { libraryAnime.filter { it.category in includedCategories } } else { libraryAnime } - val excludedCategories = preferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() } + val excludedCategories = preferences.animeUpdateCategoriesExclude().get().map { it.toLong() } val excludedMangaIds = if (excludedCategories.isNotEmpty()) { libraryAnime.fastMapNotNull { anime -> anime.id.takeIf { anime.category in excludedCategories } @@ -104,7 +104,7 @@ class AnimeStatsScreenModel( emptyList() } - val updateRestrictions = preferences.libraryUpdateItemRestriction().get() + val updateRestrictions = preferences.autoUpdateItemRestrictions().get() return includedAnime .fastFilterNot { it.anime.id in excludedMangaIds } .fastDistinctBy { it.anime.id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/manga/MangaStatsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/manga/MangaStatsScreenModel.kt index 0b68d9954..e7887ec05 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/manga/MangaStatsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/manga/MangaStatsScreenModel.kt @@ -88,14 +88,14 @@ class MangaStatsScreenModel( } private fun getGlobalUpdateItemCount(libraryManga: List): Int { - val includedCategories = preferences.mangaLibraryUpdateCategories().get().map { it.toLong() } + val includedCategories = preferences.mangaUpdateCategories().get().map { it.toLong() } val includedManga = if (includedCategories.isNotEmpty()) { libraryManga.filter { it.category in includedCategories } } else { libraryManga } - val excludedCategories = preferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() } + val excludedCategories = preferences.mangaUpdateCategoriesExclude().get().map { it.toLong() } val excludedMangaIds = if (excludedCategories.isNotEmpty()) { libraryManga.fastMapNotNull { manga -> manga.id.takeIf { manga.category in excludedCategories } @@ -104,7 +104,7 @@ class MangaStatsScreenModel( emptyList() } - val updateRestrictions = preferences.libraryUpdateItemRestriction().get() + val updateRestrictions = preferences.autoUpdateItemRestrictions().get() return includedManga .fastFilterNot { it.manga.id in excludedMangaIds } .fastDistinctBy { it.manga.id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt index 12a066d00..549de3448 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/anime/AnimeUpdatesScreenModel.kt @@ -66,7 +66,7 @@ class AnimeUpdatesScreenModel( private val _events: Channel = Channel(Int.MAX_VALUE) val events: Flow = _events.receiveAsFlow() - val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope) + val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope) val useExternalDownloader = downloadPreferences.useExternalDownloader().get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt index 3aabe1474..340d40e20 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/manga/MangaUpdatesScreenModel.kt @@ -64,7 +64,7 @@ class MangaUpdatesScreenModel( private val _events: Channel = Channel(Int.MAX_VALUE) val events: Flow = _events.receiveAsFlow() - val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope) + val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope) // First and last selected index in list private val selectedPositions: Array = arrayOf(-1, -1) diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index d19dcd207..fa5080474 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,24 +1,5 @@ - - @string/label_default - @string/left_to_right_viewer - @string/right_to_left_viewer - @string/vertical_viewer - @string/webtoon_viewer - @string/vertical_plus_viewer - - - - @string/label_default - @string/rotation_free - @string/rotation_portrait - @string/rotation_landscape - @string/rotation_force_portrait - @string/rotation_force_landscape - @string/rotation_reverse_portrait - - @string/playback_options_speed @string/playback_options_quality diff --git a/core/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/core/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 240f64a92..4cbb34812 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -143,4 +143,11 @@ fun decodeFromJsonResponse( } } +/** + * Exception that handles HTTP codes considered not successful by OkHttp. + * Use it to have a standardized error message in the app across the extensions. + * + * @since extensions-lib 1.5 + * @param code [Int] the HTTP status code + */ class HttpException(val code: Int) : IllegalStateException("HTTP error $code") diff --git a/data/src/main/java/tachiyomi/data/items/chapter/ChapterRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/items/chapter/ChapterRepositoryImpl.kt index 988e12c78..97244ee5f 100644 --- a/data/src/main/java/tachiyomi/data/items/chapter/ChapterRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/items/chapter/ChapterRepositoryImpl.kt @@ -58,7 +58,7 @@ class ChapterRepositoryImpl( read = chapterUpdate.read, bookmark = chapterUpdate.bookmark, lastPageRead = chapterUpdate.lastPageRead, - chapterNumber = chapterUpdate.chapterNumber?.toDouble(), + chapterNumber = chapterUpdate.chapterNumber, sourceOrder = chapterUpdate.sourceOrder, dateFetch = chapterUpdate.dateFetch, dateUpload = chapterUpdate.dateUpload, diff --git a/data/src/main/java/tachiyomi/data/items/episode/EpisodeRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/items/episode/EpisodeRepositoryImpl.kt index 6c2800e59..889339a6f 100644 --- a/data/src/main/java/tachiyomi/data/items/episode/EpisodeRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/items/episode/EpisodeRepositoryImpl.kt @@ -60,7 +60,7 @@ class EpisodeRepositoryImpl( bookmark = episodeUpdate.bookmark, lastSecondSeen = episodeUpdate.lastSecondSeen, totalSeconds = episodeUpdate.totalSeconds, - episodeNumber = episodeUpdate.episodeNumber?.toDouble(), + episodeNumber = episodeUpdate.episodeNumber, sourceOrder = episodeUpdate.sourceOrder, dateFetch = episodeUpdate.dateFetch, dateUpload = episodeUpdate.dateUpload, diff --git a/data/src/main/java/tachiyomi/data/release/GithubRelease.kt b/data/src/main/java/tachiyomi/data/release/GithubRelease.kt index 3677dc122..94394cef0 100644 --- a/data/src/main/java/tachiyomi/data/release/GithubRelease.kt +++ b/data/src/main/java/tachiyomi/data/release/GithubRelease.kt @@ -21,10 +21,25 @@ data class GithubRelease( @Serializable data class GitHubAssets(@SerialName("browser_download_url") val downloadLink: String) +/** + * Regular expression that matches a mention to a valid GitHub username, like it's + * done in GitHub Flavored Markdown. It follows these constraints: + * + * - Alphanumeric with single hyphens (no consecutive hyphens) + * - Cannot begin or end with a hyphen + * - Max length of 39 characters + * + * Reference: https://stackoverflow.com/a/30281147 + */ +val gitHubUsernameMentionRegex = + """\B@([a-z0-9](?:-(?=[a-z0-9])|[a-z0-9]){0,38}(?<=[a-z0-9]))""".toRegex(RegexOption.IGNORE_CASE) + val releaseMapper: (GithubRelease) -> Release = { Release( it.version, - it.info, + it.info.replace(gitHubUsernameMentionRegex) { mention -> + "[${mention.value}](https://github.com/${mention.value.substring(1)})" + }, it.releaseLink, it.assets.map(GitHubAssets::downloadLink), ) diff --git a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/CreateAnimeCategoryWithName.kt b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/CreateAnimeCategoryWithName.kt index e1d7b219d..cfbd7a327 100644 --- a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/CreateAnimeCategoryWithName.kt +++ b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/CreateAnimeCategoryWithName.kt @@ -14,7 +14,7 @@ class CreateAnimeCategoryWithName( private val initialFlags: Long get() { - val sort = preferences.libraryAnimeSortingMode().get() + val sort = preferences.animeSortingMode().get() return sort.type.flag or sort.direction.flag } diff --git a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/ResetAnimeCategoryFlags.kt b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/ResetAnimeCategoryFlags.kt index 210117827..4a983daf9 100644 --- a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/ResetAnimeCategoryFlags.kt +++ b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/ResetAnimeCategoryFlags.kt @@ -10,7 +10,7 @@ class ResetAnimeCategoryFlags( ) { suspend fun await() { - val sort = preferences.libraryAnimeSortingMode().get() + val sort = preferences.animeSortingMode().get() categoryRepository.updateAllAnimeCategoryFlags(sort.type + sort.direction) } } diff --git a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetAnimeDisplayMode.kt b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetAnimeDisplayMode.kt index 1e041fc03..f3671e6cc 100644 --- a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetAnimeDisplayMode.kt +++ b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetAnimeDisplayMode.kt @@ -8,6 +8,6 @@ class SetAnimeDisplayMode( ) { fun await(display: LibraryDisplayMode) { - preferences.libraryDisplayMode().set(display) + preferences.displayMode().set(display) } } diff --git a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetSortModeForAnimeCategory.kt b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetSortModeForAnimeCategory.kt index c7f6a7672..fc81a1460 100644 --- a/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetSortModeForAnimeCategory.kt +++ b/domain/src/main/java/tachiyomi/domain/category/anime/interactor/SetSortModeForAnimeCategory.kt @@ -23,7 +23,7 @@ class SetSortModeForAnimeCategory( ), ) } else { - preferences.libraryAnimeSortingMode().set(AnimeLibrarySort(type, direction)) + preferences.animeSortingMode().set(AnimeLibrarySort(type, direction)) categoryRepository.updateAllAnimeCategoryFlags(flags) } } diff --git a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/CreateMangaCategoryWithName.kt b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/CreateMangaCategoryWithName.kt index cb5138e5e..b9ce4278a 100644 --- a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/CreateMangaCategoryWithName.kt +++ b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/CreateMangaCategoryWithName.kt @@ -14,7 +14,7 @@ class CreateMangaCategoryWithName( private val initialFlags: Long get() { - val sort = preferences.libraryMangaSortingMode().get() + val sort = preferences.mangaSortingMode().get() return sort.type.flag or sort.direction.flag } diff --git a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/ResetMangaCategoryFlags.kt b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/ResetMangaCategoryFlags.kt index 33152d1ee..267257359 100644 --- a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/ResetMangaCategoryFlags.kt +++ b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/ResetMangaCategoryFlags.kt @@ -10,7 +10,7 @@ class ResetMangaCategoryFlags( ) { suspend fun await() { - val sort = preferences.libraryMangaSortingMode().get() + val sort = preferences.mangaSortingMode().get() categoryRepository.updateAllMangaCategoryFlags(sort.type + sort.direction) } } diff --git a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetMangaDisplayMode.kt b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetMangaDisplayMode.kt index 262332839..41e315789 100644 --- a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetMangaDisplayMode.kt +++ b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetMangaDisplayMode.kt @@ -8,6 +8,6 @@ class SetMangaDisplayMode( ) { fun await(display: LibraryDisplayMode) { - preferences.libraryDisplayMode().set(display) + preferences.displayMode().set(display) } } diff --git a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetSortModeForMangaCategory.kt b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetSortModeForMangaCategory.kt index 90446dde4..7fc033fcd 100644 --- a/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetSortModeForMangaCategory.kt +++ b/domain/src/main/java/tachiyomi/domain/category/manga/interactor/SetSortModeForMangaCategory.kt @@ -23,7 +23,7 @@ class SetSortModeForMangaCategory( ), ) } else { - preferences.libraryMangaSortingMode().set(MangaLibrarySort(type, direction)) + preferences.mangaSortingMode().set(MangaLibrarySort(type, direction)) categoryRepository.updateAllMangaCategoryFlags(flags) } } diff --git a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt index 53f4adf0d..3116c1c8d 100644 --- a/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/library/service/LibraryPreferences.kt @@ -20,39 +20,38 @@ class LibraryPreferences( fun isDefaultHomeTabLibraryManga() = preferenceStore.getBoolean("default_home_tab_library", false) - fun libraryDisplayMode() = preferenceStore.getObject( + fun displayMode() = preferenceStore.getObject( "pref_display_mode_library", LibraryDisplayMode.default, LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::deserialize, ) - fun libraryMangaSortingMode() = preferenceStore.getObject( + fun mangaSortingMode() = preferenceStore.getObject( "library_sorting_mode", MangaLibrarySort.default, MangaLibrarySort.Serializer::serialize, MangaLibrarySort.Serializer::deserialize, ) - fun libraryAnimeSortingMode() = preferenceStore.getObject( + fun animeSortingMode() = preferenceStore.getObject( "animelib_sorting_mode", AnimeLibrarySort.default, AnimeLibrarySort.Serializer::serialize, AnimeLibrarySort.Serializer::deserialize, ) - fun libraryUpdateInterval() = preferenceStore.getInt("pref_library_update_interval_key", 0) + fun lastUpdatedTimestamp() = preferenceStore.getLong("library_update_last_timestamp", 0L) + fun autoUpdateInterval() = preferenceStore.getInt("pref_library_update_interval_key", 0) - fun libraryUpdateLastTimestamp() = preferenceStore.getLong("library_update_last_timestamp", 0L) - - fun libraryUpdateDeviceRestriction() = preferenceStore.getStringSet( + fun autoUpdateDeviceRestrictions() = preferenceStore.getStringSet( "library_update_restriction", setOf( DEVICE_ONLY_ON_WIFI, ), ) - fun libraryUpdateItemRestriction() = preferenceStore.getStringSet( + fun autoUpdateItemRestrictions() = preferenceStore.getStringSet( "library_update_manga_restriction", setOf( ENTRY_HAS_UNVIEWED, @@ -172,16 +171,16 @@ class LibraryPreferences( fun lastUsedAnimeCategory() = preferenceStore.getInt("last_used_anime_category", 0) fun lastUsedMangaCategory() = preferenceStore.getInt("last_used_category", 0) - fun animeLibraryUpdateCategories() = + fun animeUpdateCategories() = preferenceStore.getStringSet("animelib_update_categories", emptySet()) - fun mangaLibraryUpdateCategories() = + fun mangaUpdateCategories() = preferenceStore.getStringSet("library_update_categories", emptySet()) - fun animeLibraryUpdateCategoriesExclude() = + fun animeUpdateCategoriesExclude() = preferenceStore.getStringSet("animelib_update_categories_exclude", emptySet()) - fun mangaLibraryUpdateCategoriesExclude() = + fun mangaUpdateCategoriesExclude() = preferenceStore.getStringSet("library_update_categories_exclude", emptySet()) // Mixture Item @@ -291,7 +290,6 @@ class LibraryPreferences( const val DEVICE_ONLY_ON_WIFI = "wifi" const val DEVICE_NETWORK_NOT_METERED = "network_not_metered" const val DEVICE_CHARGING = "ac" - const val DEVICE_BATTERY_NOT_LOW = "battery_not_low" const val ENTRY_NON_COMPLETED = "manga_ongoing" const val ENTRY_HAS_UNVIEWED = "manga_fully_read" diff --git a/domain/src/main/java/tachiyomi/domain/source/anime/model/StubAnimeSource.kt b/domain/src/main/java/tachiyomi/domain/source/anime/model/StubAnimeSource.kt index 33218ba12..f4dc0d06d 100644 --- a/domain/src/main/java/tachiyomi/domain/source/anime/model/StubAnimeSource.kt +++ b/domain/src/main/java/tachiyomi/domain/source/anime/model/StubAnimeSource.kt @@ -12,7 +12,7 @@ class StubAnimeSource( override val name: String, ) : AnimeSource { - val isInvalid: Boolean = name.isBlank() || lang.isBlank() + private val isInvalid: Boolean = name.isBlank() || lang.isBlank() override suspend fun getAnimeDetails(anime: SAnime): SAnime { throw AnimeSourceNotInstalledException() diff --git a/domain/src/main/java/tachiyomi/domain/source/manga/model/StubMangaSource.kt b/domain/src/main/java/tachiyomi/domain/source/manga/model/StubMangaSource.kt index 01f266c36..1c85ad6b2 100644 --- a/domain/src/main/java/tachiyomi/domain/source/manga/model/StubMangaSource.kt +++ b/domain/src/main/java/tachiyomi/domain/source/manga/model/StubMangaSource.kt @@ -13,13 +13,13 @@ class StubMangaSource( override val name: String, ) : MangaSource { - val isInvalid: Boolean = name.isBlank() || lang.isBlank() + private val isInvalid: Boolean = name.isBlank() || lang.isBlank() override suspend fun getMangaDetails(manga: SManga): SManga { throw SourceNotInstalledException() } - @Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails")) + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails")) override fun fetchMangaDetails(manga: SManga): Observable { return Observable.error(SourceNotInstalledException()) } @@ -28,7 +28,7 @@ class StubMangaSource( throw SourceNotInstalledException() } - @Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList")) + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList")) override fun fetchChapterList(manga: SManga): Observable> { return Observable.error(SourceNotInstalledException()) } @@ -37,7 +37,7 @@ class StubMangaSource( throw SourceNotInstalledException() } - @Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getPageList")) + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList")) override fun fetchPageList(chapter: SChapter): Observable> { return Observable.error(SourceNotInstalledException()) } diff --git a/domain/src/test/java/tachiyomi/domain/entries/anime/interactor/SetAnimeFetchIntervalTest.kt b/domain/src/test/java/tachiyomi/domain/entries/anime/interactor/SetAnimeFetchIntervalTest.kt index 33501e479..c2a77866e 100644 --- a/domain/src/test/java/tachiyomi/domain/entries/anime/interactor/SetAnimeFetchIntervalTest.kt +++ b/domain/src/test/java/tachiyomi/domain/entries/anime/interactor/SetAnimeFetchIntervalTest.kt @@ -6,8 +6,10 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode import tachiyomi.domain.items.episode.model.Episode -import java.time.Duration import java.time.ZonedDateTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.toJavaDuration @Execution(ExecutionMode.CONCURRENT) class SetAnimeFetchIntervalTest { @@ -22,49 +24,34 @@ class SetAnimeFetchIntervalTest { @Test fun `calculateInterval returns default of 7 days when less than 3 distinct days`() { - val episodes = mutableListOf() - (1..1).forEach { - val duration = Duration.ofHours(10) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..2).map { + episodeWithTime(episode, 10.hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7 } @Test fun `calculateInterval returns 7 when 5 episodes in 1 day`() { - val episodes = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(10) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..5).map { + episodeWithTime(episode, 10.hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7 } @Test fun `calculateInterval returns 7 when 7 episodes in 48 hours, 2 day`() { - val episodes = mutableListOf() - (1..2).forEach { - val duration = Duration.ofHours(24L) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) - } - (1..5).forEach { - val duration = Duration.ofHours(48L) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..2).map { + episodeWithTime(episode, 24.hours) + } + (1..5).map { + episodeWithTime(episode, 48.hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7 } @Test fun `calculateInterval returns default of 1 day when interval less than 1`() { - val episodes = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(15L * it) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..5).map { + episodeWithTime(episode, (15 * it).hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 } @@ -72,61 +59,46 @@ class SetAnimeFetchIntervalTest { // Normal interval calculation @Test fun `calculateInterval returns 1 when 5 episodes in 120 hours, 5 days`() { - val episodes = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(24L * it) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..5).map { + episodeWithTime(episode, (24 * it).hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 } @Test fun `calculateInterval returns 2 when 5 episodes in 240 hours, 10 days`() { - val episodes = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(48L * it) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..5).map { + episodeWithTime(episode, (48 * it).hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 2 } @Test fun `calculateInterval returns floored value when interval is decimal`() { - val episodes = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(25L * it) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..5).map { + episodeWithTime(episode, (25 * it).hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 } @Test fun `calculateInterval returns 1 when 5 episodes in 215 hours, 5 days`() { - val episodes = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(43L * it) - val newEpisode = episodeAddTime(episode, duration) - episodes.add(newEpisode) + val episodes = (1..5).map { + episodeWithTime(episode, (43 * it).hours) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 } @Test fun `calculateInterval returns interval based on fetch time if upload time not available`() { - val episodes = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(25L * it) - val newEpisode = episodeAddTime(episode, duration).copy(dateUpload = 0L) - episodes.add(newEpisode) + val episodes = (1..5).map { + episodeWithTime(episode, (25 * it).hours).copy(dateUpload = 0L) } setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 } - private fun episodeAddTime(episode: Episode, duration: Duration): Episode { - val newTime = testTime.plus(duration).toEpochSecond() * 1000 + private fun episodeWithTime(episode: Episode, duration: Duration): Episode { + val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000 return episode.copy(dateFetch = newTime, dateUpload = newTime) } } diff --git a/domain/src/test/java/tachiyomi/domain/entries/manga/interactor/SetMangaFetchIntervalTest.kt b/domain/src/test/java/tachiyomi/domain/entries/manga/interactor/SetMangaFetchIntervalTest.kt index 97469bb76..402d541f5 100644 --- a/domain/src/test/java/tachiyomi/domain/entries/manga/interactor/SetMangaFetchIntervalTest.kt +++ b/domain/src/test/java/tachiyomi/domain/entries/manga/interactor/SetMangaFetchIntervalTest.kt @@ -6,8 +6,10 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode import tachiyomi.domain.items.chapter.model.Chapter -import java.time.Duration import java.time.ZonedDateTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.toJavaDuration @Execution(ExecutionMode.CONCURRENT) class SetMangaFetchIntervalTest { @@ -22,49 +24,34 @@ class SetMangaFetchIntervalTest { @Test fun `calculateInterval returns default of 7 days when less than 3 distinct days`() { - val chapters = mutableListOf() - (1..1).forEach { - val duration = Duration.ofHours(10) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..2).map { + chapterWithTime(chapter, 10.hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7 } @Test fun `calculateInterval returns 7 when 5 chapters in 1 day`() { - val chapters = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(10) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..5).map { + chapterWithTime(chapter, 10.hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7 } @Test fun `calculateInterval returns 7 when 7 chapters in 48 hours, 2 day`() { - val chapters = mutableListOf() - (1..2).forEach { - val duration = Duration.ofHours(24L) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) - } - (1..5).forEach { - val duration = Duration.ofHours(48L) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..2).map { + chapterWithTime(chapter, 24.hours) + } + (1..5).map { + chapterWithTime(chapter, 48.hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7 } @Test fun `calculateInterval returns default of 1 day when interval less than 1`() { - val chapters = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(15L * it) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..5).map { + chapterWithTime(chapter, (15 * it).hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 } @@ -72,61 +59,46 @@ class SetMangaFetchIntervalTest { // Normal interval calculation @Test fun `calculateInterval returns 1 when 5 chapters in 120 hours, 5 days`() { - val chapters = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(24L * it) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..5).map { + chapterWithTime(chapter, (24 * it).hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 } @Test fun `calculateInterval returns 2 when 5 chapters in 240 hours, 10 days`() { - val chapters = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(48L * it) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..5).map { + chapterWithTime(chapter, (48 * it).hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 2 } @Test fun `calculateInterval returns floored value when interval is decimal`() { - val chapters = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(25L * it) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..5).map { + chapterWithTime(chapter, (25 * it).hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 } @Test fun `calculateInterval returns 1 when 5 chapters in 215 hours, 5 days`() { - val chapters = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(43L * it) - val newChapter = chapterAddTime(chapter, duration) - chapters.add(newChapter) + val chapters = (1..5).map { + chapterWithTime(chapter, (43 * it).hours) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 } @Test fun `calculateInterval returns interval based on fetch time if upload time not available`() { - val chapters = mutableListOf() - (1..5).forEach { - val duration = Duration.ofHours(25L * it) - val newChapter = chapterAddTime(chapter, duration).copy(dateUpload = 0L) - chapters.add(newChapter) + val chapters = (1..5).map { + chapterWithTime(chapter, (25 * it).hours).copy(dateUpload = 0L) } setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 } - private fun chapterAddTime(chapter: Chapter, duration: Duration): Chapter { - val newTime = testTime.plus(duration).toEpochSecond() * 1000 + private fun chapterWithTime(chapter: Chapter, duration: Duration): Chapter { + val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000 return chapter.copy(dateFetch = newTime, dateUpload = newTime) } } diff --git a/gradle.properties b/gradle.properties index 9eae3a7ec..3b0ca4bd9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,6 +25,4 @@ kotlin.mpp.androidSourceSetLayoutVersion=2 android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false -android.nonFinalResIds=false - -android.experimental.useDefaultDebugSigningConfigForProfileableBuildtypes=true +#android.nonFinalResIds=false diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index 39818b502..f9bad0b15 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -1,16 +1,16 @@ [versions] -agp_version = "8.0.2" +agp_version = "8.1.1" lifecycle_version = "2.6.1" paging_version = "3.2.0" [libraries] gradle = { module = "com.android.tools.build:gradle", version.ref = "agp_version" } -annotation = "androidx.annotation:annotation:1.7.0-alpha03" +annotation = "androidx.annotation:annotation:1.7.0-rc01" appcompat = "androidx.appcompat:appcompat:1.6.1" biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" -corektx = "androidx.core:core-ktx:1.12.0-beta01" +corektx = "androidx.core:core-ktx:1.12.0-rc01" splashscreen = "androidx.core:core-splashscreen:1.0.1" recyclerview = "androidx.recyclerview:recyclerview:1.3.1" viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01" @@ -28,7 +28,7 @@ guava = "com.google.guava:guava:32.1.2-android" paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" } paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" } -benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-beta02" +benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-beta04" test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha01" test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01" test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04" diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index 1bb8d0e44..e21536263 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -1,7 +1,7 @@ [versions] -compiler = "1.5.1" -compose-bom = "2023.07.00-alpha02" -accompanist = "0.31.5-beta" +compiler = "1.5.2" +compose-bom = "2023.09.00-alpha02" +accompanist = "0.33.1-alpha" [libraries] activity = "androidx.activity:activity-compose:1.7.2" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b71ac26a..02aa87daa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,6 @@ richtext = "0.17.0" desugar = "com.android.tools:desugar_jdk_libs:2.0.3" android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2" -rxandroid = "io.reactivex:rxandroid:1.2.1" rxjava = "io.reactivex:rxjava:1.3.8" flowreactivenetwork = "ru.beryukhov:flowreactivenetwork:1.0.4" @@ -35,7 +34,7 @@ sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" } sqlite-android = "com.github.requery:sqlite-android:3.42.0" -preferencektx = "androidx.preference:preference-ktx:1.2.0" +preferencektx = "androidx.preference:preference-ktx:1.2.1" injekt-core = "com.github.inorichi.injekt:injekt-core:65b0440" @@ -57,14 +56,14 @@ flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013 photoview = "com.github.chrisbanes:PhotoView:2.3.0" directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0" insetter = "dev.chrisbanes.insetter:insetter:0.6.1" -compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.0.4" +compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.0.6" compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0" swipe = "me.saket.swipe:swipe:1.2.0" logcat = "com.squareup.logcat:logcat:0.1" -acra-http = "ch.acra:acra-http:5.11.0" +acra-http = "ch.acra:acra-http:5.11.1" aboutLibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlib_version" } aboutLibraries-gradle = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutlib_version" } @@ -84,7 +83,7 @@ sqldelight-gradle = { module = "app.cash.sqldelight:gradle-plugin", version.ref junit = "org.junit.jupiter:junit-jupiter:5.10.0" kotest-assertions = "io.kotest:kotest-assertions-core:5.6.2" -mockk = "io.mockk:mockk:1.13.5" +mockk = "io.mockk:mockk:1.13.7" voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } @@ -101,7 +100,6 @@ seeker = "io.github.2307vivek:seeker:1.1.1" truetypeparser = "io.github.yubyf:truetypeparser-light:2.1.4" [bundles] -reactivex = ["rxandroid", "rxjava"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"] js-engine = ["quickjs-android"] sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9f4197d5f..ac72c34e8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/i18n/src/main/res/values/strings-aniyomi.xml b/i18n/src/main/res/values/strings-aniyomi.xml index c2b713006..7089a7bd5 100644 --- a/i18n/src/main/res/values/strings-aniyomi.xml +++ b/i18n/src/main/res/values/strings-aniyomi.xml @@ -252,6 +252,7 @@ %d second %d seconds + Licensed - No items to show Next Episode not found! Storage Manga diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index a892e3015..784a59ddb 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -253,7 +253,6 @@ Only on Wi-Fi Only on unmetered network When charging - When battery not low Restrictions: %s Skip updating entries @@ -313,6 +312,7 @@ PackageInstaller Shizuku Shizuku is not running + Private Install and start Shizuku to use Shizuku as extension installer. @@ -489,6 +489,7 @@ Backup failed Storage permissions not granted No library entries to back up + Couldn\'t create a backup file Backup/restore may not function properly if MIUI Optimization is disabled. Restore is already in progress Restoring backup @@ -594,8 +595,6 @@ No more results No results found - - Check website in WebView Local source Other Last used @@ -927,4 +926,10 @@ See your recently updated library entries Widget not available when app lock is enabled You are about to remove \"%s\" from your library + + + + HTTP %d, check website in WebView + No Internet connection + Couldn\'t reach %s diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/SettingsItems.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/SettingsItems.kt index 7e7cac40a..dededff79 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/SettingsItems.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/SettingsItems.kt @@ -243,7 +243,6 @@ fun SelectItem( label = { Text(text = label) }, value = options[selectedIndex].toString(), onValueChange = {}, - enabled = false, readOnly = true, singleLine = true, trailingIcon = { @@ -251,9 +250,7 @@ fun SelectItem( expanded = expanded, ) }, - colors = ExposedDropdownMenuDefaults.textFieldColors( - disabledTextColor = MaterialTheme.colorScheme.onSurface, - ), + colors = ExposedDropdownMenuDefaults.textFieldColors(), ) ExposedDropdownMenu( diff --git a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/animesource/AnimeSource.kt b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/animesource/AnimeSource.kt index ce81fefe8..1393fbbc4 100644 --- a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/animesource/AnimeSource.kt +++ b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/animesource/AnimeSource.kt @@ -24,13 +24,44 @@ interface AnimeSource { val lang: String get() = "" + /** + * Get the updated details for a anime. + * + * @param anime the anime to update. + */ + @Suppress("DEPRECATION") + suspend fun getAnimeDetails(anime: SAnime): SAnime { + return fetchAnimeDetails(anime).awaitSingle() + } + + /** + * Get all the available episodes for a anime. + * + * @param anime the anime to update. + */ + @Suppress("DEPRECATION") + suspend fun getEpisodeList(anime: SAnime): List { + return fetchEpisodeList(anime).awaitSingle() + } + + /** + * Get the list of videos a episode has. Pages should be returned + * in the expected order; the index is ignored. + * + * @param episode the episode. + */ + @Suppress("DEPRECATION") + suspend fun getVideoList(episode: SEpisode): List