Last commit merged: 6922792ad1
This commit is contained in:
LuftVerbot 2023-11-19 13:18:41 +01:00
parent fa7b8427a2
commit 2c4230376c
104 changed files with 1370 additions and 592 deletions

View file

@ -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|(?<!n[o']?t )blocked by|error) (?:to )?(?:get past|by ?pass|penetrate)?.*cloud ?fl?are.*",
"ignoreCase": true,
"labels": ["Cloudflare protected"],
"message": "Refer to the **Solving Cloudflare issues** section at https://aniyomi.org/help/guides/troubleshooting/#solving-cloudflare-issues. If it doesn't work, migrate to other sources or wait until they lower their protection."
}
]
auto-close-ignore-label: do-not-autoclose

View file

@ -202,7 +202,7 @@ dependencies {
implementation(androidx.bundles.workmanager)
// RxJava
implementation(libs.bundles.reactivex)
implementation(libs.rxjava)
implementation(libs.flowreactivenetwork)
// Networking

View file

@ -14,7 +14,7 @@
}
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
public static final ** CREATOR;
}
-keep class androidx.annotation.Keep

View file

@ -14,8 +14,8 @@
-keep,allowoptimization class kotlin.time.** { public protected *; }
-keep,allowoptimization class okhttp3.** { public protected *; }
-keep,allowoptimization class okio.** { public protected *; }
-keep,allowoptimization class rx.** { public protected *; }
-keep,allowoptimization class org.jsoup.** { public protected *; }
-keep,allowoptimization class rx.** { public protected *; }
-keep,allowoptimization class app.cash.quickjs.** { public protected *; }
-keep,allowoptimization class uy.kohesive.injekt.** { public protected *; }
-keep,allowoptimization class is.xyz.mpv.** { public protected *; }

View file

@ -63,10 +63,10 @@
<activity
android:name=".ui.main.DeepLinkAnimeActivity"
android:name=".ui.deeplink.anime.DeepLinkAnimeActivity"
android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay"
android:label="@string/action_global_anime_search"
android:label="@string/action_search"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
@ -90,10 +90,10 @@
</activity>
<activity
android:name=".ui.main.DeepLinkMangaActivity"
android:name=".ui.deeplink.manga.DeepLinkMangaActivity"
android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay"
android:label="@string/action_global_manga_search"
android:label="@string/action_search"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />

View file

@ -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 <T : R, R : Any> List<T>.insertSeparators(
return newList
}
/**
* Returns a new map containing only the key entries of [transform] that are not null.
*/
inline fun <K, V, R> Map<out K, V>.mapNotNullKeys(transform: (Map.Entry<K?, V>) -> R?): ConcurrentHashMap<R, V> {
val mutableMap = ConcurrentHashMap<R, V>()
forEach { element -> transform(element)?.let { mutableMap[it] = element.value } }
return mutableMap
}
fun <E> HashSet<E>.addOrRemove(value: E, shouldAdd: Boolean) {
if (shouldAdd) {
add(value)

View file

@ -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<ChapterRepository> { 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<AnimeHistoryRepository> { AnimeHistoryRepositoryImpl(get()) }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Result<ImageBitmap>>(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)!!

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Result<ImageBitmap>>(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)!!

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<BackupCreateJob>(
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,

View file

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

View file

@ -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<Long, SourceDirectory>
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)

View file

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

View file

@ -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<LibraryPreferences>()
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<Pair<Anime, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
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<LibraryPreferences>()
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<AnimeLibraryUpdateJob>(

View file

@ -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<LibraryPreferences>()
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<Pair<Manga, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
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<LibraryPreferences>()
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<MangaLibraryUpdateJob>(

View file

@ -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<InsertAnimeTrack>().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<InsertAnimeTrack>().await(it)
insertTrack.await(it)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" }

View file

@ -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<InsertMangaTrack>().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<InsertMangaTrack>().await(it)
insertTrack.await(it)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String>? {
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<String>): Boolean {
return trustedSignatures.any { signatures.contains(it) }
}
private fun isOfficiallySigned(signatures: List<String>): 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,
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String>? {
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<String>): Boolean {
return trustedSignatures.any { signatures.contains(it) }
}
private fun isOfficiallySigned(signatures: List<String>): 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,
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<DeepLinkAnimeScreenModel.State>(State.Loading) {
init {
coroutineScope.launchIO {
val anime = sourceManager.getCatalogueSources()
.filterIsInstance<ResolvableAnimeSource>()
.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
}
}

View file

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

View file

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

View file

@ -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<DeepLinkMangaScreenModel.State>(State.Loading) {
init {
coroutineScope.launchIO {
val manga = sourceManager.getCatalogueSources()
.filterIsInstance<ResolvableMangaSource>()
.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
}
}

View file

@ -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<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedEpisodeIds: HashSet<Long> = HashSet()

View file

@ -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<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedChapterIds: HashSet<Long> = HashSet()

View file

@ -526,7 +526,7 @@ class AnimeLibraryScreenModel(
}
fun getDisplayMode(): PreferenceMutableState<LibraryDisplayMode> {
return libraryPreferences.libraryDisplayMode().asState(coroutineScope)
return libraryPreferences.displayMode().asState(coroutineScope)
}
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {

View file

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

View file

@ -520,7 +520,7 @@ class MangaLibraryScreenModel(
}
fun getDisplayMode(): PreferenceMutableState<LibraryDisplayMode> {
return libraryPreferences.libraryDisplayMode().asState(coroutineScope)
return libraryPreferences.displayMode().asState(coroutineScope)
}
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {

View file

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

View file

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

View file

@ -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].
*/

View file

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

View file

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

View file

@ -88,14 +88,14 @@ class AnimeStatsScreenModel(
}
private fun getGlobalUpdateItemCount(libraryAnime: List<LibraryAnime>): 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 }

View file

@ -88,14 +88,14 @@ class MangaStatsScreenModel(
}
private fun getGlobalUpdateItemCount(libraryManga: List<LibraryManga>): 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 }

View file

@ -66,7 +66,7 @@ class AnimeUpdatesScreenModel(
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _events.receiveAsFlow()
val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope)
val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope)
val useExternalDownloader = downloadPreferences.useExternalDownloader().get()

View file

@ -64,7 +64,7 @@ class MangaUpdatesScreenModel(
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _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<Int> = arrayOf(-1, -1)

View file

@ -1,24 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="viewers_selector">
<item>@string/label_default</item>
<item>@string/left_to_right_viewer</item>
<item>@string/right_to_left_viewer</item>
<item>@string/vertical_viewer</item>
<item>@string/webtoon_viewer</item>
<item>@string/vertical_plus_viewer</item>
</string-array>
<string-array name="rotation_type">
<item>@string/label_default</item>
<item>@string/rotation_free</item>
<item>@string/rotation_portrait</item>
<item>@string/rotation_landscape</item>
<item>@string/rotation_force_portrait</item>
<item>@string/rotation_force_landscape</item>
<item>@string/rotation_reverse_portrait</item>
</string-array>
<string-array name="playback_options">
<item>@string/playback_options_speed</item>
<item>@string/playback_options_quality</item>

View file

@ -143,4 +143,11 @@ fun <T> 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")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,6 @@ class SetAnimeDisplayMode(
) {
fun await(display: LibraryDisplayMode) {
preferences.libraryDisplayMode().set(display)
preferences.displayMode().set(display)
}
}

View file

@ -23,7 +23,7 @@ class SetSortModeForAnimeCategory(
),
)
} else {
preferences.libraryAnimeSortingMode().set(AnimeLibrarySort(type, direction))
preferences.animeSortingMode().set(AnimeLibrarySort(type, direction))
categoryRepository.updateAllAnimeCategoryFlags(flags)
}
}

View file

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

View file

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

View file

@ -8,6 +8,6 @@ class SetMangaDisplayMode(
) {
fun await(display: LibraryDisplayMode) {
preferences.libraryDisplayMode().set(display)
preferences.displayMode().set(display)
}
}

View file

@ -23,7 +23,7 @@ class SetSortModeForMangaCategory(
),
)
} else {
preferences.libraryMangaSortingMode().set(MangaLibrarySort(type, direction))
preferences.mangaSortingMode().set(MangaLibrarySort(type, direction))
categoryRepository.updateAllMangaCategoryFlags(flags)
}
}

View file

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

View file

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

View file

@ -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<SManga> {
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<List<SChapter>> {
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<List<Page>> {
return Observable.error(SourceNotInstalledException())
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -252,6 +252,7 @@
<item quantity="one">%d second</item>
<item quantity="other">%d seconds</item>
</plurals>
<string name="licensed_manga_chapters_error">Licensed - No items to show</string>
<string name="no_next_episode">Next Episode not found!</string>
<string name="label_storage">Storage</string>
<string name="label_history">Manga</string>

View file

@ -253,7 +253,6 @@
<string name="connected_to_wifi">Only on Wi-Fi</string>
<string name="network_not_metered">Only on unmetered network</string>
<string name="charging">When charging</string>
<string name="battery_not_low">When battery not low</string>
<string name="restrictions">Restrictions: %s</string>
<string name="pref_library_update_manga_restriction">Skip updating entries</string>
@ -313,6 +312,7 @@
<string name="ext_installer_packageinstaller" translatable="false">PackageInstaller</string>
<string name="ext_installer_shizuku" translatable="false">Shizuku</string>
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
<string name="ext_installer_private" translatable="false">Private</string>
<string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
<!-- Reader section -->
@ -489,6 +489,7 @@
<string name="creating_backup_error">Backup failed</string>
<string name="missing_storage_permission">Storage permissions not granted</string>
<string name="empty_backup_error">No library entries to back up</string>
<string name="create_backup_file_error">Couldn\'t create a backup file</string>
<string name="restore_miui_warning">Backup/restore may not function properly if MIUI Optimization is disabled.</string>
<string name="restore_in_progress">Restore is already in progress</string>
<string name="restoring_backup">Restoring backup</string>
@ -594,8 +595,6 @@
<!-- missing prompt after Compose rewrite #7901 -->
<string name="no_more_results">No more results</string>
<string name="no_results_found">No results found</string>
<!-- Do not translate "WebView" -->
<string name="http_error_hint">Check website in WebView</string>
<string name="local_source">Local source</string>
<string name="other_source">Other</string>
<string name="last_used_source">Last used</string>
@ -927,4 +926,10 @@
<string name="appwidget_updates_description">See your recently updated library entries</string>
<string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string>
<string name="remove_manga">You are about to remove \"%s\" from your library</string>
<!-- Common exceptions -->
<!-- Do not translate "WebView" -->
<string name="exception_http">HTTP %d, check website in WebView</string>
<string name="exception_offline">No Internet connection</string>
<string name="exception_unknown_host">Couldn\'t reach %s</string>
</resources>

View file

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

View file

@ -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<SEpisode> {
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<Video> {
return fetchVideoList(episode).awaitSingle()
}
/**
* Returns an observable with the updated details for a anime.
*
* @param anime the anime to update.
*/
@Deprecated(
"Use the 1.x API instead",
"Use the non-RxJava API instead",
ReplaceWith("getAnimeDetails"),
)
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException("Not used")
@ -41,7 +72,7 @@ interface AnimeSource {
* @param anime the anime to update.
*/
@Deprecated(
"Use the 1.x API instead",
"Use the non-RxJava API instead",
ReplaceWith("getEpisodeList"),
)
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException("Not used")
@ -53,33 +84,8 @@ interface AnimeSource {
* @param episode the episode.
*/
@Deprecated(
"Use the 1.x API instead",
"Use the non-RxJava API instead",
ReplaceWith("getVideoList"),
)
fun fetchVideoList(episode: SEpisode): Observable<List<Video>> = Observable.empty()
/**
* [1.x API] Get the updated details for a anime.
*/
@Suppress("DEPRECATION")
suspend fun getAnimeDetails(anime: SAnime): SAnime {
return fetchAnimeDetails(anime).awaitSingle()
}
/**
* [1.x API] Get all the available episodes for a anime.
*/
@Suppress("DEPRECATION")
suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
return fetchEpisodeList(anime).awaitSingle()
}
/**
* [1.x API] Get the list of videos a episode has. Videos should be returned
* in the expected order; the index is ignored.
*/
@Suppress("DEPRECATION")
suspend fun getVideoList(episode: SEpisode): List<Video> {
return fetchVideoList(episode).awaitSingle()
}
}

View file

@ -50,15 +50,16 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
open val versionId = 1
/**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
* ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string `"${name.lowercase()}/$lang/$versionId"`.
*
* The ID is generated by the [generateId] function, which can be reused if needed
* to generate outdated IDs for cases where the source name or language needs to
* be changed but migrations can be avoided.
*
* Note: the generated ID sets the sign bit to `0`.
*/
override val id by lazy {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
override val id by lazy { generateId(name, lang, versionId) }
/**
* Headers used for requests.
@ -71,6 +72,28 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
open val client: OkHttpClient
get() = network.client
/**
* Generates a unique ID for the source based on the provided [name], [lang] and
* [versionId]. It will use the first 16 characters (64 bits) of the MD5 of the string
* `"${name.lowercase()}/$lang/$versionId"`.
*
* Note: the generated ID sets the sign bit to `0`.
*
* Can be used to generate outdated IDs, such as when the source name or language
* needs to be changed but migrations can be avoided.
*
* @since extensions-lib 1.5
* @param name [String] the name of the source
* @param lang [String] the language of the source
* @param versionId [Int] the version ID of the source
* @return a unique ID for the source
*/
protected fun generateId(name: String, lang: String, versionId: Int): Long {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
@ -222,7 +245,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
episodeListParse(response)
}
} else {
Observable.error(Exception("Licensed - No episodes to show"))
Observable.error(Exception(LicensedEntryItemsException()))
}
}
@ -432,3 +455,5 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
*/
override fun getFilterList() = AnimeFilterList()
}
class LicensedEntryItemsException : Exception("Licensed - No items to show")

Some files were not shown because too many files have changed in this diff Show more