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", "type": "body",
"regex": ".*\\* (Aniyomi version|Android version|Device): \\?.*", "regex": ".*\\* (Aniyomi version|Android version|Device): \\?.*",
"message": "Requested information in the template was not filled out." "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 auto-close-ignore-label: do-not-autoclose

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
package eu.kanade.core.util package eu.kanade.core.util
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import java.util.concurrent.ConcurrentHashMap
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract import kotlin.contracts.contract
@ -20,15 +19,6 @@ fun <T : R, R : Any> List<T>.insertSeparators(
return newList 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) { fun <E> HashSet<E>.addOrRemove(value: E, shouldAdd: Boolean) {
if (shouldAdd) { if (shouldAdd) {
add(value) add(value)

View file

@ -234,7 +234,7 @@ class DomainModule : InjektModule {
addFactory { UpdateEpisode(get()) } addFactory { UpdateEpisode(get()) }
addFactory { SetSeenStatus(get(), get(), get(), get()) } addFactory { SetSeenStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbEpisode() } addFactory { ShouldUpdateDbEpisode() }
addFactory { SyncEpisodesWithSource(get(), get(), get(), get()) } addFactory { SyncEpisodesWithSource(get(), get(), get(), get(), get(), get(), get()) }
addFactory { SyncEpisodesWithTrackServiceTwoWay(get(), get()) } addFactory { SyncEpisodesWithTrackServiceTwoWay(get(), get()) }
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) } addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
@ -243,7 +243,7 @@ class DomainModule : InjektModule {
addFactory { UpdateChapter(get()) } addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get()) } addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() } addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get()) } addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) }
addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) } addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) }
addSingletonFactory<AnimeHistoryRepository> { AnimeHistoryRepositoryImpl(get()) } addSingletonFactory<AnimeHistoryRepository> { AnimeHistoryRepositoryImpl(get()) }

View file

@ -28,5 +28,6 @@ class BasePreferences(
LEGACY(R.string.ext_installer_legacy), LEGACY(R.string.ext_installer_legacy),
PACKAGEINSTALLER(R.string.ext_installer_packageinstaller), PACKAGEINSTALLER(R.string.ext_installer_packageinstaller),
SHIZUKU(R.string.ext_installer_shizuku), 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.repository.ChapterRepository
import tachiyomi.domain.items.chapter.service.ChapterRecognition import tachiyomi.domain.items.chapter.service.ChapterRecognition
import tachiyomi.source.local.entries.manga.isLocal import tachiyomi.source.local.entries.manga.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.lang.Long.max import java.lang.Long.max
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ -28,13 +27,13 @@ import java.util.Date
import java.util.TreeSet import java.util.TreeSet
class SyncChaptersWithSource( class SyncChaptersWithSource(
private val downloadManager: MangaDownloadManager = Injekt.get(), private val downloadManager: MangaDownloadManager,
private val downloadProvider: MangaDownloadProvider = Injekt.get(), private val downloadProvider: MangaDownloadProvider,
private val chapterRepository: ChapterRepository = Injekt.get(), private val chapterRepository: ChapterRepository,
private val shouldUpdateDbChapter: ShouldUpdateDbChapter = Injekt.get(), private val shouldUpdateDbChapter: ShouldUpdateDbChapter,
private val updateManga: UpdateManga = Injekt.get(), private val updateManga: UpdateManga,
private val updateChapter: UpdateChapter = Injekt.get(), private val updateChapter: UpdateChapter,
private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(), 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.items.chapter.model.toChapterUpdate
import tachiyomi.domain.track.manga.interactor.InsertMangaTrack import tachiyomi.domain.track.manga.interactor.InsertMangaTrack
import tachiyomi.domain.track.manga.model.MangaTrack import tachiyomi.domain.track.manga.model.MangaTrack
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class SyncChaptersWithTrackServiceTwoWay( class SyncChaptersWithTrackServiceTwoWay(
private val updateChapter: UpdateChapter = Injekt.get(), private val updateChapter: UpdateChapter,
private val insertTrack: InsertMangaTrack = Injekt.get(), private val insertTrack: InsertMangaTrack,
) { ) {
suspend fun await( 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.repository.EpisodeRepository
import tachiyomi.domain.items.episode.service.EpisodeRecognition import tachiyomi.domain.items.episode.service.EpisodeRecognition
import tachiyomi.source.local.entries.anime.isLocal import tachiyomi.source.local.entries.anime.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.lang.Long.max import java.lang.Long.max
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ -28,13 +27,13 @@ import java.util.Date
import java.util.TreeSet import java.util.TreeSet
class SyncEpisodesWithSource( class SyncEpisodesWithSource(
private val downloadManager: AnimeDownloadManager = Injekt.get(), private val downloadManager: AnimeDownloadManager,
private val downloadProvider: AnimeDownloadProvider = Injekt.get(), private val downloadProvider: AnimeDownloadProvider,
private val episodeRepository: EpisodeRepository = Injekt.get(), private val episodeRepository: EpisodeRepository,
private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode = Injekt.get(), private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode,
private val updateAnime: UpdateAnime = Injekt.get(), private val updateAnime: UpdateAnime,
private val updateEpisode: UpdateEpisode = Injekt.get(), private val updateEpisode: UpdateEpisode,
private val getEpisodeByAnimeId: GetEpisodeByAnimeId = Injekt.get(), 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.items.episode.model.toEpisodeUpdate
import tachiyomi.domain.track.anime.interactor.InsertAnimeTrack import tachiyomi.domain.track.anime.interactor.InsertAnimeTrack
import tachiyomi.domain.track.anime.model.AnimeTrack import tachiyomi.domain.track.anime.model.AnimeTrack
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class SyncEpisodesWithTrackServiceTwoWay( class SyncEpisodesWithTrackServiceTwoWay(
private val updateEpisode: UpdateEpisode = Injekt.get(), private val updateEpisode: UpdateEpisode,
private val insertTrack: InsertAnimeTrack = Injekt.get(), private val insertTrack: InsertAnimeTrack,
) { ) {
suspend fun await( 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.HelpOutline
@ -175,7 +173,8 @@ private fun AnimeExtensionDetails(
data = Uri.fromParts("package", extension.pkgName, null) data = Uri.fromParts("package", extension.pkgName, null)
context.startActivity(this) context.startActivity(this)
} }
}, Unit
}.takeIf { extension.isShared },
onClickAgeRating = { onClickAgeRating = {
showNsfwWarning = true showNsfwWarning = true
}, },
@ -208,7 +207,7 @@ private fun DetailsHeader(
extension: AnimeExtension, extension: AnimeExtension,
onClickAgeRating: () -> Unit, onClickAgeRating: () -> Unit,
onClickUninstall: () -> Unit, onClickUninstall: () -> Unit,
onClickAppInfo: () -> Unit, onClickAppInfo: (() -> Unit)?,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -292,6 +291,7 @@ private fun DetailsHeader(
top = MaterialTheme.padding.small, top = MaterialTheme.padding.small,
bottom = MaterialTheme.padding.medium, bottom = MaterialTheme.padding.medium,
), ),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
OutlinedButton( OutlinedButton(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -300,16 +300,16 @@ private fun DetailsHeader(
Text(stringResource(R.string.ext_uninstall)) Text(stringResource(R.string.ext_uninstall))
} }
Spacer(Modifier.width(16.dp)) if (onClickAppInfo != null) {
Button(
Button( modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f), onClick = onClickAppInfo,
onClick = onClickAppInfo, ) {
) { Text(
Text( text = stringResource(R.string.ext_app_info),
text = stringResource(R.string.ext_app_info), color = MaterialTheme.colorScheme.onPrimary,
color = MaterialTheme.colorScheme.onPrimary, )
) }
} }
} }

View file

@ -75,7 +75,7 @@ fun AnimeExtensionScreen(
enabled = !state.isLoading, enabled = !state.isLoading,
) { ) {
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.isEmpty -> { state.isEmpty -> {
val msg = if (!searchQuery.isNullOrEmpty()) { val msg = if (!searchQuery.isNullOrEmpty()) {
R.string.no_results_found R.string.no_results_found

View file

@ -47,7 +47,7 @@ fun AnimeSourcesScreen(
onLongClickItem: (AnimeSource) -> Unit, onLongClickItem: (AnimeSource) -> Unit,
) { ) {
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.isEmpty -> EmptyScreen( state.isEmpty -> EmptyScreen(
textResource = R.string.source_empty_screen, textResource = R.string.source_empty_screen,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),

View file

@ -51,7 +51,7 @@ fun MigrateAnimeSourceScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.isEmpty -> EmptyScreen( state.isEmpty -> EmptyScreen(
textResource = R.string.information_empty_library, textResource = R.string.information_empty_library,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),

View file

@ -1,6 +1,5 @@
package eu.kanade.presentation.browse.anime.components package eu.kanade.presentation.browse.anime.components
import android.content.pm.PackageManager
import android.util.DisplayMetrics import android.util.DisplayMetrics
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box 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.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.domain.source.anime.model.AnimeSource import tachiyomi.domain.source.anime.model.AnimeSource
import tachiyomi.source.local.entries.anime.LocalAnimeSource 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) { return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
withIOContext { withIOContext {
value = try { 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) val appResources = context.packageManager.getResourcesForApplication(appInfo)
Result.Success( Result.Success(
appResources.getDrawableForDensity(appInfo.icon, density, null)!! 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.HelpOutline
@ -176,7 +174,8 @@ private fun ExtensionDetails(
data = Uri.fromParts("package", extension.pkgName, null) data = Uri.fromParts("package", extension.pkgName, null)
context.startActivity(this) context.startActivity(this)
} }
}, Unit
}.takeIf { extension.isShared },
onClickAgeRating = { onClickAgeRating = {
showNsfwWarning = true showNsfwWarning = true
}, },
@ -209,7 +208,7 @@ private fun DetailsHeader(
extension: MangaExtension, extension: MangaExtension,
onClickAgeRating: () -> Unit, onClickAgeRating: () -> Unit,
onClickUninstall: () -> Unit, onClickUninstall: () -> Unit,
onClickAppInfo: () -> Unit, onClickAppInfo: (() -> Unit)?,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -293,6 +292,7 @@ private fun DetailsHeader(
top = MaterialTheme.padding.small, top = MaterialTheme.padding.small,
bottom = MaterialTheme.padding.medium, bottom = MaterialTheme.padding.medium,
), ),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
OutlinedButton( OutlinedButton(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -301,16 +301,16 @@ private fun DetailsHeader(
Text(stringResource(R.string.ext_uninstall)) Text(stringResource(R.string.ext_uninstall))
} }
Spacer(Modifier.width(16.dp)) if (onClickAppInfo != null) {
Button(
Button( modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f), onClick = onClickAppInfo,
onClick = onClickAppInfo, ) {
) { Text(
Text( text = stringResource(R.string.ext_app_info),
text = stringResource(R.string.ext_app_info), color = MaterialTheme.colorScheme.onPrimary,
color = MaterialTheme.colorScheme.onPrimary, )
) }
} }
} }

View file

@ -76,7 +76,7 @@ fun MangaExtensionScreen(
enabled = !state.isLoading, enabled = !state.isLoading,
) { ) {
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.isEmpty -> { state.isEmpty -> {
val msg = if (!searchQuery.isNullOrEmpty()) { val msg = if (!searchQuery.isNullOrEmpty()) {
R.string.no_results_found R.string.no_results_found

View file

@ -47,7 +47,7 @@ fun MangaSourcesScreen(
onLongClickItem: (Source) -> Unit, onLongClickItem: (Source) -> Unit,
) { ) {
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.isEmpty -> EmptyScreen( state.isEmpty -> EmptyScreen(
textResource = R.string.source_empty_screen, textResource = R.string.source_empty_screen,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),

View file

@ -51,7 +51,7 @@ fun MigrateMangaSourceScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.isEmpty -> EmptyScreen( state.isEmpty -> EmptyScreen(
textResource = R.string.information_empty_library, textResource = R.string.information_empty_library,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),

View file

@ -1,6 +1,5 @@
package eu.kanade.presentation.browse.manga.components package eu.kanade.presentation.browse.manga.components
import android.content.pm.PackageManager
import android.util.DisplayMetrics import android.util.DisplayMetrics
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box 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.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionLoader
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.domain.source.manga.model.Source import tachiyomi.domain.source.manga.model.Source
import tachiyomi.source.local.entries.manga.LocalMangaSource 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) { return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
withIOContext { withIOContext {
value = try { 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) val appResources = context.packageManager.getResourcesForApplication(appInfo)
Result.Success( Result.Success(
appResources.getDrawableForDensity(appInfo.icon, density, null)!! appResources.getDrawableForDensity(appInfo.icon, density, null)!!

View file

@ -30,7 +30,7 @@ fun AnimeHistoryScreen(
) { _ -> ) { _ ->
state.list.let { state.list.let {
if (it == null) { if (it == null) {
LoadingScreen(modifier = Modifier.padding(contentPadding)) LoadingScreen(Modifier.padding(contentPadding))
} else if (it.isEmpty()) { } else if (it.isEmpty()) {
val msg = if (!searchQuery.isNullOrEmpty()) { val msg = if (!searchQuery.isNullOrEmpty()) {
R.string.no_results_found R.string.no_results_found

View file

@ -29,7 +29,7 @@ fun MangaHistoryScreen(
) { _ -> ) { _ ->
state.list.let { state.list.let {
if (it == null) { if (it == null) {
LoadingScreen(modifier = Modifier.padding(contentPadding)) LoadingScreen(Modifier.padding(contentPadding))
} else if (it.isEmpty()) { } else if (it.isEmpty()) {
val msg = if (!searchQuery.isNullOrEmpty()) { val msg = if (!searchQuery.isNullOrEmpty()) {
R.string.no_results_found R.string.no_results_found

View file

@ -181,7 +181,7 @@ private val displayModes = listOf(
private fun ColumnScope.DisplayPage( private fun ColumnScope.DisplayPage(
screenModel: AnimeLibrarySettingsScreenModel, screenModel: AnimeLibrarySettingsScreenModel,
) { ) {
val displayMode by screenModel.libraryPreferences.libraryDisplayMode().collectAsState() val displayMode by screenModel.libraryPreferences.displayMode().collectAsState()
SettingsChipRow(R.string.action_display_mode) { SettingsChipRow(R.string.action_display_mode) {
displayModes.map { (titleRes, mode) -> displayModes.map { (titleRes, mode) ->
FilterChip( FilterChip(

View file

@ -180,7 +180,7 @@ private val displayModes = listOf(
private fun ColumnScope.DisplayPage( private fun ColumnScope.DisplayPage(
screenModel: MangaLibrarySettingsScreenModel, screenModel: MangaLibrarySettingsScreenModel,
) { ) {
val displayMode by screenModel.libraryPreferences.libraryDisplayMode().collectAsState() val displayMode by screenModel.libraryPreferences.displayMode().collectAsState()
SettingsChipRow(R.string.action_display_mode) { SettingsChipRow(R.string.action_display_mode) {
displayModes.map { (titleRes, mode) -> displayModes.map { (titleRes, mode) ->
FilterChip( 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.manga.interactor.ResetMangaCategoryFlags
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.service.LibraryPreferences 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_HAS_UNVIEWED
import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_COMPLETED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_COMPLETED
import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_VIEWED import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_VIEWED
@ -163,15 +166,15 @@ object SettingsLibraryScreen : SearchableSettings {
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val libraryUpdateIntervalPref = libraryPreferences.libraryUpdateInterval() val autoUpdateIntervalPref = libraryPreferences.autoUpdateInterval()
val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState() val autoUpdateInterval by autoUpdateIntervalPref.collectAsState()
val animelibUpdateCategoriesPref = libraryPreferences.animeLibraryUpdateCategories() val animeAutoUpdateCategoriesPref = libraryPreferences.animeUpdateCategories()
val animelibUpdateCategoriesExcludePref = val animeAutoUpdateCategoriesExcludePref =
libraryPreferences.animeLibraryUpdateCategoriesExclude() libraryPreferences.animeUpdateCategoriesExclude()
val includedAnime by animelibUpdateCategoriesPref.collectAsState() val includedAnime by animeAutoUpdateCategoriesPref.collectAsState()
val excludedAnime by animelibUpdateCategoriesExcludePref.collectAsState() val excludedAnime by animeAutoUpdateCategoriesExcludePref.collectAsState()
var showAnimeCategoriesDialog by rememberSaveable { mutableStateOf(false) } var showAnimeCategoriesDialog by rememberSaveable { mutableStateOf(false) }
if (showAnimeCategoriesDialog) { if (showAnimeCategoriesDialog) {
TriStateListDialog( TriStateListDialog(
@ -183,8 +186,8 @@ object SettingsLibraryScreen : SearchableSettings {
itemLabel = { it.visualName }, itemLabel = { it.visualName },
onDismissRequest = { showAnimeCategoriesDialog = false }, onDismissRequest = { showAnimeCategoriesDialog = false },
onValueChanged = { newIncluded, newExcluded -> onValueChanged = { newIncluded, newExcluded ->
animelibUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) animeAutoUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
animelibUpdateCategoriesExcludePref.set( animeAutoUpdateCategoriesExcludePref.set(
newExcluded.map { it.id.toString() } newExcluded.map { it.id.toString() }
.toSet(), .toSet(),
) )
@ -193,12 +196,12 @@ object SettingsLibraryScreen : SearchableSettings {
) )
} }
val libraryUpdateCategoriesPref = libraryPreferences.mangaLibraryUpdateCategories() val autoUpdateCategoriesPref = libraryPreferences.mangaUpdateCategories()
val libraryUpdateCategoriesExcludePref = val autoUpdateCategoriesExcludePref =
libraryPreferences.mangaLibraryUpdateCategoriesExclude() libraryPreferences.mangaUpdateCategoriesExclude()
val includedManga by libraryUpdateCategoriesPref.collectAsState() val includedManga by autoUpdateCategoriesPref.collectAsState()
val excludedManga by libraryUpdateCategoriesExcludePref.collectAsState() val excludedManga by autoUpdateCategoriesExcludePref.collectAsState()
var showMangaCategoriesDialog by rememberSaveable { mutableStateOf(false) } var showMangaCategoriesDialog by rememberSaveable { mutableStateOf(false) }
if (showMangaCategoriesDialog) { if (showMangaCategoriesDialog) {
TriStateListDialog( TriStateListDialog(
@ -210,8 +213,8 @@ object SettingsLibraryScreen : SearchableSettings {
itemLabel = { it.visualName }, itemLabel = { it.visualName },
onDismissRequest = { showMangaCategoriesDialog = false }, onDismissRequest = { showMangaCategoriesDialog = false },
onValueChanged = { newIncluded, newExcluded -> onValueChanged = { newIncluded, newExcluded ->
libraryUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet()) autoUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
libraryUpdateCategoriesExcludePref.set( autoUpdateCategoriesExcludePref.set(
newExcluded.map { it.id.toString() } newExcluded.map { it.id.toString() }
.toSet(), .toSet(),
) )
@ -224,7 +227,7 @@ object SettingsLibraryScreen : SearchableSettings {
title = stringResource(R.string.pref_category_library_update), title = stringResource(R.string.pref_category_library_update),
preferenceItems = listOf( preferenceItems = listOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = libraryUpdateIntervalPref, pref = autoUpdateIntervalPref,
title = stringResource(R.string.pref_library_update_interval), title = stringResource(R.string.pref_library_update_interval),
entries = mapOf( entries = mapOf(
0 to stringResource(R.string.update_never), 0 to stringResource(R.string.update_never),
@ -241,15 +244,14 @@ object SettingsLibraryScreen : SearchableSettings {
}, },
), ),
Preference.PreferenceItem.MultiSelectListPreference( Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryPreferences.libraryUpdateDeviceRestriction(), pref = libraryPreferences.autoUpdateDeviceRestrictions(),
enabled = libraryUpdateInterval > 0, enabled = autoUpdateInterval > 0,
title = stringResource(R.string.pref_library_update_restriction), title = stringResource(R.string.pref_library_update_restriction),
subtitle = stringResource(R.string.restrictions), subtitle = stringResource(R.string.restrictions),
entries = mapOf( entries = mapOf(
ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read), DEVICE_ONLY_ON_WIFI to stringResource(R.string.connected_to_wifi),
ENTRY_NON_VIEWED to stringResource(R.string.pref_update_only_started), DEVICE_NETWORK_NOT_METERED to stringResource(R.string.network_not_metered),
ENTRY_NON_COMPLETED to stringResource(R.string.pref_update_only_non_completed), DEVICE_CHARGING to stringResource(R.string.charging),
ENTRY_OUTSIDE_RELEASE_PERIOD to stringResource(R.string.pref_update_only_in_release_period),
), ),
onValueChanged = { onValueChanged = {
// Post to event looper to allow the preference to be updated. // 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), subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary),
), ),
Preference.PreferenceItem.MultiSelectListPreference( Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryPreferences.libraryUpdateItemRestriction(), pref = libraryPreferences.autoUpdateItemRestrictions(),
title = stringResource(R.string.pref_library_update_manga_restriction), title = stringResource(R.string.pref_library_update_manga_restriction),
entries = mapOf( entries = mapOf(
ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read), ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read),

View file

@ -69,7 +69,7 @@ fun AnimeUpdateScreen(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { ) {
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.items.isEmpty() -> EmptyScreen( state.items.isEmpty() -> EmptyScreen(
textResource = R.string.information_no_recent, textResource = R.string.information_no_recent,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),

View file

@ -65,7 +65,7 @@ fun MangaUpdateScreen(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { ) {
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.items.isEmpty() -> EmptyScreen( state.items.isEmpty() -> EmptyScreen(
textResource = R.string.information_no_recent, textResource = R.string.information_no_recent,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),

View file

@ -2,19 +2,30 @@ package eu.kanade.presentation.util
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.online.LicensedEntryItemsException
import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.util.system.isOnline
import tachiyomi.domain.items.chapter.model.NoChaptersException import tachiyomi.domain.items.chapter.model.NoChaptersException
import tachiyomi.domain.items.episode.model.NoEpisodesException import tachiyomi.domain.items.episode.model.NoEpisodesException
import tachiyomi.domain.source.anime.model.AnimeSourceNotInstalledException import tachiyomi.domain.source.anime.model.AnimeSourceNotInstalledException
import tachiyomi.domain.source.manga.model.SourceNotInstalledException import tachiyomi.domain.source.manga.model.SourceNotInstalledException
import java.net.UnknownHostException
context(Context) context(Context)
val Throwable.formattedMessage: String val Throwable.formattedMessage: String
get() { get() {
when (this) { 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 NoChaptersException, is NoEpisodesException -> return getString(R.string.no_results_found)
is SourceNotInstalledException, is AnimeSourceNotInstalledException -> return getString(R.string.loader_not_implemented_error) 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) { return when (val className = this::class.simpleName) {
"Exception", "IOException" -> message ?: className "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 eu.kanade.tachiyomi.util.system.workManager
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.preference.TriState import tachiyomi.core.preference.TriState
import tachiyomi.core.preference.getAndSet
import tachiyomi.core.preference.getEnum import tachiyomi.core.preference.getEnum
import tachiyomi.core.preference.minusAssign import tachiyomi.core.preference.minusAssign
import tachiyomi.core.preference.plusAssign import tachiyomi.core.preference.plusAssign
@ -107,19 +108,19 @@ object Migrations {
} }
if (oldVersion < 44) { if (oldVersion < 44) {
// Reset sorting preference if using removed sort by source // 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 if (oldMangaSortingMode == 5) { // SOURCE = 5
prefs.edit { 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 if (oldAnimeSortingMode == 5) { // SOURCE = 5
prefs.edit { 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) { if (oldVersion < 61) {
// Handle removed every 1 or 2 hour library updates // Handle removed every 1 or 2 hour library updates
val updateInterval = libraryPreferences.libraryUpdateInterval().get() val updateInterval = libraryPreferences.autoUpdateInterval().get()
if (updateInterval == 1 || updateInterval == 2) { if (updateInterval == 1 || updateInterval == 2) {
libraryPreferences.libraryUpdateInterval().set(3) libraryPreferences.autoUpdateInterval().set(3)
MangaLibraryUpdateJob.setupTask(context, 3) MangaLibraryUpdateJob.setupTask(context, 3)
AnimeLibraryUpdateJob.setupTask(context, 3) AnimeLibraryUpdateJob.setupTask(context, 3)
} }
@ -207,8 +208,8 @@ object Migrations {
AnimeLibraryUpdateJob.setupTask(context) AnimeLibraryUpdateJob.setupTask(context)
} }
if (oldVersion < 64) { if (oldVersion < 64) {
val oldMangaSortingMode = prefs.getInt(libraryPreferences.libraryMangaSortingMode().key(), 0) val oldMangaSortingMode = prefs.getInt(libraryPreferences.mangaSortingMode().key(), 0)
val oldAnimeSortingMode = prefs.getInt(libraryPreferences.libraryAnimeSortingMode().key(), 0) val oldAnimeSortingMode = prefs.getInt(libraryPreferences.animeSortingMode().key(), 0)
val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true) val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true)
val newMangaSortingMode = when (oldMangaSortingMode) { val newMangaSortingMode = when (oldMangaSortingMode) {
@ -241,14 +242,14 @@ object Migrations {
} }
prefs.edit(commit = true) { prefs.edit(commit = true) {
remove(libraryPreferences.libraryMangaSortingMode().key()) remove(libraryPreferences.mangaSortingMode().key())
remove(libraryPreferences.libraryAnimeSortingMode().key()) remove(libraryPreferences.animeSortingMode().key())
remove("library_sorting_ascending") remove("library_sorting_ascending")
} }
prefs.edit { prefs.edit {
putString(libraryPreferences.libraryMangaSortingMode().key(), newMangaSortingMode) putString(libraryPreferences.mangaSortingMode().key(), newMangaSortingMode)
putString(libraryPreferences.libraryAnimeSortingMode().key(), newAnimeSortingMode) putString(libraryPreferences.animeSortingMode().key(), newAnimeSortingMode)
putString("library_sorting_ascending", newSortingDirection) putString("library_sorting_ascending", newSortingDirection)
} }
} }
@ -259,9 +260,9 @@ object Migrations {
} }
if (oldVersion < 71) { if (oldVersion < 71) {
// Handle removed every 3, 4, 6, and 8 hour library updates // 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)) { if (updateInterval in listOf(3, 4, 6, 8)) {
libraryPreferences.libraryUpdateInterval().set(12) libraryPreferences.autoUpdateInterval().set(12)
MangaLibraryUpdateJob.setupTask(context, 12) MangaLibraryUpdateJob.setupTask(context, 12)
AnimeLibraryUpdateJob.setupTask(context, 12) AnimeLibraryUpdateJob.setupTask(context, 12)
} }
@ -269,7 +270,7 @@ object Migrations {
if (oldVersion < 72) { if (oldVersion < 72) {
val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true) val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true)
if (!oldUpdateOngoingOnly) { if (!oldUpdateOngoingOnly) {
libraryPreferences.libraryUpdateItemRestriction() -= ENTRY_NON_COMPLETED libraryPreferences.autoUpdateItemRestrictions() -= ENTRY_NON_COMPLETED
} }
} }
if (oldVersion < 75) { if (oldVersion < 75) {
@ -294,29 +295,29 @@ object Migrations {
if (oldVersion < 81) { if (oldVersion < 81) {
// Handle renamed enum values // Handle renamed enum values
prefs.edit { 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" "LAST_CHECKED" -> "LAST_MANGA_UPDATE"
"UNREAD" -> "UNREAD_COUNT" "UNREAD" -> "UNREAD_COUNT"
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE" "DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
else -> oldSortingMode 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" "LAST_CHECKED" -> "LAST_MANGA_UPDATE"
"UNREAD" -> "UNREAD_COUNT" "UNREAD" -> "UNREAD_COUNT"
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE" "DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
else -> oldSortingMode else -> oldSortingMode
} }
putString(libraryPreferences.libraryMangaSortingMode().key(), newMangaSortingMode) putString(libraryPreferences.mangaSortingMode().key(), newMangaSortingMode)
putString(libraryPreferences.libraryAnimeSortingMode().key(), newAnimeSortingMode) putString(libraryPreferences.animeSortingMode().key(), newAnimeSortingMode)
} }
} }
if (oldVersion < 82) { if (oldVersion < 82) {
prefs.edit { prefs.edit {
val mangasort = prefs.getString(libraryPreferences.libraryMangaSortingMode().key(), null) ?: return@edit val mangasort = prefs.getString(libraryPreferences.mangaSortingMode().key(), null) ?: return@edit
val animesort = prefs.getString(libraryPreferences.libraryAnimeSortingMode().key(), null) ?: return@edit val animesort = prefs.getString(libraryPreferences.animeSortingMode().key(), null) ?: return@edit
val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!! val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!!
putString(libraryPreferences.libraryMangaSortingMode().key(), "$mangasort,$direction") putString(libraryPreferences.mangaSortingMode().key(), "$mangasort,$direction")
putString(libraryPreferences.libraryAnimeSortingMode().key(), "$animesort,$direction") putString(libraryPreferences.animeSortingMode().key(), "$animesort,$direction")
remove("library_sorting_ascending") remove("library_sorting_ascending")
} }
} }
@ -452,6 +453,12 @@ object Migrations {
readerPreferences.longStripSplitWebtoon().set(false) 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 return true
} }
} }

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
@ -78,6 +79,10 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
s.toInt(16) s.toInt(16)
} }
if (interval > 0) { if (interval > 0) {
val constraints = Constraints(
requiresBatteryNotLow = true,
)
val request = PeriodicWorkRequestBuilder<BackupCreateJob>( val request = PeriodicWorkRequestBuilder<BackupCreateJob>(
interval.toLong(), interval.toLong(),
TimeUnit.HOURS, TimeUnit.HOURS,
@ -86,6 +91,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
) )
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10.minutes.toJavaDuration()) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10.minutes.toJavaDuration())
.addTag(TAG_AUTO) .addTag(TAG_AUTO)
.setConstraints(constraints)
.setInputData( .setInputData(
workDataOf( workDataOf(
IS_AUTO_BACKUP_KEY to true, IS_AUTO_BACKUP_KEY to true,

View file

@ -162,10 +162,10 @@ class BackupManager(
UniFile.fromUri(context, uri) UniFile.fromUri(context, uri)
} }
) )
?: throw Exception("Couldn't create backup file") ?: throw Exception(context.getString(R.string.create_backup_file_error))
if (!file.isFile) { 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) val byteArray = parser.encodeToByteArray(BackupSerializer, backup)

View file

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.download.anime
import android.content.Context import android.content.Context
import androidx.core.net.toUri import androidx.core.net.toUri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.core.util.mapNotNullKeys
import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
import eu.kanade.tachiyomi.util.size import eu.kanade.tachiyomi.util.size
@ -334,21 +333,23 @@ class AnimeDownloadCache(
} }
} }
val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty() val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
.associate { it.name to SourceDirectory(it) }
.mapNotNullKeys { entry ->
sources.find {
provider.getSourceDirName(it).equals(entry.key, ignoreCase = true)
}?.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 sourceDirs.values
.map { sourceDir -> .map { sourceDir ->
async { async {
val animeDirs = sourceDir.dir.listFiles().orEmpty() val animeDirs = sourceDir.dir.listFiles().orEmpty()
.filterNot { it.name.isNullOrBlank() } .filter { it.isDirectory && !it.name.isNullOrBlank() }
.associate { it.name!! to AnimeDirectory(it) } .associate { it.name!! to AnimeDirectory(it) }
sourceDir.animeDirs = ConcurrentHashMap(animeDirs) sourceDir.animeDirs = ConcurrentHashMap(animeDirs)

View file

@ -5,7 +5,6 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.core.util.mapNotNullKeys
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
import eu.kanade.tachiyomi.source.MangaSource import eu.kanade.tachiyomi.source.MangaSource
import eu.kanade.tachiyomi.util.size 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 { rootDownloadsDirLock.withLock {
val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty() val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty()
.associate { it.name to SourceDirectory(it) } .filter { it.isDirectory && !it.name.isNullOrBlank() }
.mapNotNullKeys { entry -> .mapNotNull { dir ->
sources.find { val sourceId = sourceMap[dir.name!!.lowercase()]
provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) sourceId?.let { it to SourceDirectory(dir) }
}?.id
} }
.toMap()
rootDownloadsDir.sourceDirs = sourceDirs rootDownloadsDir.sourceDirs = sourceDirs
@ -376,7 +377,7 @@ class MangaDownloadCache(
.map { sourceDir -> .map { sourceDir ->
async { async {
val mangaDirs = sourceDir.dir.listFiles().orEmpty() val mangaDirs = sourceDir.dir.listFiles().orEmpty()
.filterNot { it.name.isNullOrBlank() } .filter { it.isDirectory && !it.name.isNullOrBlank() }
.associate { it.name!! to MangaDirectory(it) } .associate { it.name!! to MangaDirectory(it) }
sourceDir.mangaDirs = ConcurrentHashMap(mangaDirs) 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.items.episode.model.NoEpisodesException
import tachiyomi.domain.library.anime.LibraryAnime import tachiyomi.domain.library.anime.LibraryAnime
import tachiyomi.domain.library.service.LibraryPreferences 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_CHARGING
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED 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.DEVICE_ONLY_ON_WIFI
@ -113,7 +112,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if (tags.contains(WORK_NAME_AUTO)) { if (tags.contains(WORK_NAME_AUTO)) {
val preferences = Injekt.get<LibraryPreferences>() val preferences = Injekt.get<LibraryPreferences>()
val restrictions = preferences.libraryUpdateDeviceRestriction().get() val restrictions = preferences.autoUpdateDeviceRestrictions().get()
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
return Result.failure() 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 this is a chapter update, set the last update time to now
if (target == Target.EPISODES) { if (target == Target.EPISODES) {
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time) libraryPreferences.lastUpdatedTimestamp().set(Date().time)
} }
val categoryId = inputData.getLong(KEY_CATEGORY, -1L) val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
@ -181,14 +180,14 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val listToUpdate = if (categoryId != -1L) { val listToUpdate = if (categoryId != -1L) {
libraryAnime.filter { it.category == categoryId } libraryAnime.filter { it.category == categoryId }
} else { } else {
val categoriesToUpdate = libraryPreferences.animeLibraryUpdateCategories().get().map { it.toLong() } val categoriesToUpdate = libraryPreferences.animeUpdateCategories().get().map { it.toLong() }
val includedAnime = if (categoriesToUpdate.isNotEmpty()) { val includedAnime = if (categoriesToUpdate.isNotEmpty()) {
libraryAnime.filter { it.category in categoriesToUpdate } libraryAnime.filter { it.category in categoriesToUpdate }
} else { } else {
libraryAnime libraryAnime
} }
val categoriesToExclude = libraryPreferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() } val categoriesToExclude = libraryPreferences.animeUpdateCategoriesExclude().get().map { it.toLong() }
val excludedAnimeIds = if (categoriesToExclude.isNotEmpty()) { val excludedAnimeIds = if (categoriesToExclude.isNotEmpty()) {
libraryAnime.filter { it.category in categoriesToExclude }.map { it.anime.id } libraryAnime.filter { it.category in categoriesToExclude }.map { it.anime.id }
} else { } else {
@ -229,7 +228,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val skippedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>() val skippedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>() val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val hasDownloads = AtomicBoolean(false) val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get() val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = setAnimeFetchInterval.getWindow(ZonedDateTime.now()) val fetchWindow = setAnimeFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope { coroutineScope {
@ -558,13 +557,13 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
prefInterval: Int? = null, prefInterval: Int? = null,
) { ) {
val preferences = Injekt.get<LibraryPreferences>() val preferences = Injekt.get<LibraryPreferences>()
val interval = prefInterval ?: preferences.libraryUpdateInterval().get() val interval = prefInterval ?: preferences.autoUpdateInterval().get()
if (interval > 0) { if (interval > 0) {
val restrictions = preferences.libraryUpdateDeviceRestriction().get() val restrictions = preferences.autoUpdateDeviceRestrictions().get()
val constraints = Constraints( val constraints = Constraints(
requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED }, requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED },
requiresCharging = DEVICE_CHARGING in restrictions, requiresCharging = DEVICE_CHARGING in restrictions,
requiresBatteryNotLow = DEVICE_BATTERY_NOT_LOW in restrictions, requiresBatteryNotLow = true,
) )
val request = PeriodicWorkRequestBuilder<AnimeLibraryUpdateJob>( 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.items.chapter.model.NoChaptersException
import tachiyomi.domain.library.manga.LibraryManga import tachiyomi.domain.library.manga.LibraryManga
import tachiyomi.domain.library.service.LibraryPreferences 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_CHARGING
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED 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.DEVICE_ONLY_ON_WIFI
@ -113,7 +112,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if (tags.contains(WORK_NAME_AUTO)) { if (tags.contains(WORK_NAME_AUTO)) {
val preferences = Injekt.get<LibraryPreferences>() val preferences = Injekt.get<LibraryPreferences>()
val restrictions = preferences.libraryUpdateDeviceRestriction().get() val restrictions = preferences.autoUpdateDeviceRestrictions().get()
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
return Result.retry() 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 this is a chapter update, set the last update time to now
if (target == Target.CHAPTERS) { if (target == Target.CHAPTERS) {
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time) libraryPreferences.lastUpdatedTimestamp().set(Date().time)
} }
val categoryId = inputData.getLong(KEY_CATEGORY, -1L) val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
@ -181,14 +180,14 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val listToUpdate = if (categoryId != -1L) { val listToUpdate = if (categoryId != -1L) {
libraryManga.filter { it.category == categoryId } libraryManga.filter { it.category == categoryId }
} else { } else {
val categoriesToUpdate = libraryPreferences.mangaLibraryUpdateCategories().get().map { it.toLong() } val categoriesToUpdate = libraryPreferences.mangaUpdateCategories().get().map { it.toLong() }
val includedManga = if (categoriesToUpdate.isNotEmpty()) { val includedManga = if (categoriesToUpdate.isNotEmpty()) {
libraryManga.filter { it.category in categoriesToUpdate } libraryManga.filter { it.category in categoriesToUpdate }
} else { } else {
libraryManga libraryManga
} }
val categoriesToExclude = libraryPreferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() } val categoriesToExclude = libraryPreferences.mangaUpdateCategoriesExclude().get().map { it.toLong() }
val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) { val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) {
libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id } libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id }
} else { } else {
@ -229,7 +228,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>() val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>() val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false) val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get() val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = setMangaFetchInterval.getWindow(ZonedDateTime.now()) val fetchWindow = setMangaFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope { coroutineScope {
@ -557,13 +556,13 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
prefInterval: Int? = null, prefInterval: Int? = null,
) { ) {
val preferences = Injekt.get<LibraryPreferences>() val preferences = Injekt.get<LibraryPreferences>()
val interval = prefInterval ?: preferences.libraryUpdateInterval().get() val interval = prefInterval ?: preferences.autoUpdateInterval().get()
if (interval > 0) { if (interval > 0) {
val restrictions = preferences.libraryUpdateDeviceRestriction().get() val restrictions = preferences.autoUpdateDeviceRestrictions().get()
val constraints = Constraints( val constraints = Constraints(
requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED }, requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED },
requiresCharging = DEVICE_CHARGING in restrictions, requiresCharging = DEVICE_CHARGING in restrictions,
requiresBatteryNotLow = DEVICE_BATTERY_NOT_LOW in restrictions, requiresBatteryNotLow = true,
) )
val request = PeriodicWorkRequestBuilder<MangaLibraryUpdateJob>( 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 tachiyomi.domain.track.anime.interactor.InsertAnimeTrack
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.time.ZoneOffset import java.time.ZoneOffset
import tachiyomi.domain.track.anime.model.AnimeTrack as DomainAnimeTrack import tachiyomi.domain.track.anime.model.AnimeTrack as DomainAnimeTrack
private val insertTrack: InsertAnimeTrack by injectLazy()
interface AnimeTrackService { interface AnimeTrackService {
// Common functions // Common functions
@ -63,7 +66,7 @@ interface AnimeTrackService {
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext 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 // Update episode progress if newer episodes marked seen locally
if (hasSeenEpisodes) { if (hasSeenEpisodes) {
@ -71,7 +74,7 @@ interface AnimeTrackService {
.sortedBy { it.episodeNumber } .sortedBy { it.episodeNumber }
.takeWhile { it.seen } .takeWhile { it.seen }
.lastOrNull() .lastOrNull()
?.episodeNumber?.toDouble() ?: -1.0 ?.episodeNumber ?: -1.0
if (latestLocalSeenEpisodeNumber > track.lastEpisodeSeen) { if (latestLocalSeenEpisodeNumber > track.lastEpisodeSeen) {
track = track.copy( track = track.copy(
@ -123,6 +126,7 @@ interface AnimeTrackService {
track.last_episode_seen = episodeNumber.toFloat() track.last_episode_seen = episodeNumber.toFloat()
if (track.total_episodes != 0 && track.last_episode_seen.toInt() == track.total_episodes) { if (track.total_episodes != 0 && track.last_episode_seen.toInt() == track.total_episodes) {
track.status = getCompletionStatus() track.status = getCompletionStatus()
track.finished_watching_date = System.currentTimeMillis()
} }
withIOContext { updateRemote(track) } withIOContext { updateRemote(track) }
} }
@ -147,7 +151,7 @@ interface AnimeTrackService {
try { try {
update(track) update(track)
track.toDomainTrack(idRequired = false)?.let { track.toDomainTrack(idRequired = false)?.let {
Injekt.get<InsertAnimeTrack>().await(it) insertTrack.await(it)
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" } 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 tachiyomi.domain.track.manga.interactor.InsertMangaTrack
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.time.ZoneOffset import java.time.ZoneOffset
import tachiyomi.domain.track.manga.model.MangaTrack as DomainTrack import tachiyomi.domain.track.manga.model.MangaTrack as DomainTrack
private val insertTrack: InsertMangaTrack by injectLazy()
interface MangaTrackService { interface MangaTrackService {
// Common functions // Common functions
@ -63,7 +66,7 @@ interface MangaTrackService {
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext 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 // Update chapter progress if newer chapters marked read locally
if (hasReadChapters) { if (hasReadChapters) {
@ -71,7 +74,7 @@ interface MangaTrackService {
.sortedBy { it.chapterNumber } .sortedBy { it.chapterNumber }
.takeWhile { it.read } .takeWhile { it.read }
.lastOrNull() .lastOrNull()
?.chapterNumber?.toDouble() ?: -1.0 ?.chapterNumber ?: -1.0
if (latestLocalReadChapterNumber > track.lastChapterRead) { if (latestLocalReadChapterNumber > track.lastChapterRead) {
track = track.copy( track = track.copy(
@ -123,6 +126,7 @@ interface MangaTrackService {
track.last_chapter_read = chapterNumber.toFloat() track.last_chapter_read = chapterNumber.toFloat()
if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) {
track.status = getCompletionStatus() track.status = getCompletionStatus()
track.finished_reading_date = System.currentTimeMillis()
} }
withIOContext { updateRemote(track) } withIOContext { updateRemote(track) }
} }
@ -147,7 +151,7 @@ interface MangaTrackService {
try { try {
update(track) update(track)
track.toDomainTrack(idRequired = false)?.let { track.toDomainTrack(idRequired = false)?.let {
Injekt.get<InsertMangaTrack>().await(it) insertTrack.await(it)
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" } 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.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -12,7 +11,6 @@ import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Long) { abstract class TrackService(val id: Long) {
val preferences: BasePreferences by injectLazy()
val trackPreferences: TrackPreferences by injectLazy() val trackPreferences: TrackPreferences by injectLazy()
val networkService: NetworkHelper by injectLazy() val networkService: NetworkHelper by injectLazy()

View file

@ -66,7 +66,10 @@ class AnimeExtensionManager(
fun getAppIconForSource(sourceId: Long): Drawable? { fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = _installedAnimeExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName val pkgName = _installedAnimeExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
if (pkgName != null) { 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 return null
} }
@ -333,6 +336,7 @@ class AnimeExtensionManager(
} }
override fun onPackageUninstalled(pkgName: String) { override fun onPackageUninstalled(pkgName: String) {
AnimeExtensionLoader.uninstallPrivateExtension(context, pkgName)
unregisterAnimeExtension(pkgName) unregisterAnimeExtension(pkgName)
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }

View file

@ -32,6 +32,7 @@ sealed class AnimeExtension {
val hasUpdate: Boolean = false, val hasUpdate: Boolean = false,
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isUnofficial: Boolean = false, val isUnofficial: Boolean = false,
val isShared: Boolean,
) : AnimeExtension() ) : AnimeExtension()
data class Available( data class Available(

View file

@ -4,6 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter 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.AnimeExtension
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
@ -27,7 +30,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
* Registers this broadcast receiver * Registers this broadcast receiver
*/ */
fun register(context: Context) { 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_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(ACTION_EXTENSION_ADDED)
addAction(ACTION_EXTENSION_REPLACED)
addAction(ACTION_EXTENSION_REMOVED)
addDataScheme("package") addDataScheme("package")
} }
@ -49,7 +55,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
if (intent == null) return if (intent == null) return
when (intent.action) { when (intent.action) {
Intent.ACTION_PACKAGE_ADDED -> { Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
if (isReplacing(intent)) return if (isReplacing(intent)) return
launchNow { launchNow {
@ -61,7 +67,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
} }
} }
} }
Intent.ACTION_PACKAGE_REPLACED -> { Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
launchNow { launchNow {
when (val result = getExtensionFromIntent(context, intent)) { when (val result = getExtensionFromIntent(context, intent)) {
is AnimeLoadResult.Success -> listener.onExtensionUpdated(result.extension) 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 if (isReplacing(intent)) return
val pkgName = getPackageNameFromIntent(intent) val pkgName = getPackageNameFromIntent(intent)
@ -127,4 +133,30 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
fun onExtensionUntrusted(extension: AnimeExtension.Untrusted) fun onExtensionUntrusted(extension: AnimeExtension.Untrusted)
fun onPackageUninstalled(pkgName: String) 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 androidx.core.net.toUri
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.extension.InstallStep 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.installer.InstallerAnime
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.isPackageInstalled
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -157,6 +159,35 @@ internal class AnimeExtensionInstaller(private val context: Context) {
context.startActivity(intent) 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 -> { else -> {
val intent = val intent =
AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer) 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 * @param pkgName The package name of the extension to uninstall
*/ */
fun uninstallApk(pkgName: String) { fun uninstallApk(pkgName: String) {
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) if (context.isPackageInstalled(pkgName)) {
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @Suppress("DEPRECATION")
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
context.startActivity(intent) .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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build 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.AnimeExtension
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.system.getApplicationIcon
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy 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 installed in the system.
@ -41,12 +43,11 @@ internal object AnimeExtensionLoader {
const val LIB_VERSION_MIN = 12 const val LIB_VERSION_MIN = 12
const val LIB_VERSION_MAX = 15 const val LIB_VERSION_MAX = 15
private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @Suppress("DEPRECATION")
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
} else { PackageManager.GET_META_DATA or
@Suppress("DEPRECATION") PackageManager.GET_SIGNATURES or
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
}
// jmir1's key // jmir1's key
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c"
@ -56,8 +57,57 @@ internal object AnimeExtensionLoader {
*/ */
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() 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. * @param context The application context.
*/ */
@ -70,16 +120,43 @@ internal object AnimeExtensionLoader {
pkgManager.getInstalledPackages(PACKAGE_FLAGS) 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() if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking { return runBlocking {
val deferred = extPkgs.map { 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. * contains the required feature flag before trying to load it.
*/ */
fun loadExtensionFromPkgName(context: Context, pkgName: String): AnimeLoadResult { 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) context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
.takeIf { isPackageAnExtension(it) }
?.let {
AnimeExtensionInfo(
packageInfo = it,
isShared = true,
)
}
} catch (error: PackageManager.NameNotFoundException) { } catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point null
logcat(LogPriority.ERROR, error)
return AnimeLoadResult.Error
} }
if (!isPackageAnExtension(pkgInfo)) {
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } return selectExtensionPackage(sharedPkg, privatePkg)
return AnimeLoadResult.Error
}
return loadExtension(context, pkgName, pkgInfo)
} }
/** /**
* Loads an extension given its package name. * Loads an extension
* *
* @param context The application context. * @param context The application context.
* @param pkgName The package name of the extension to load. * @param extensionInfo The extension to load.
* @param pkgInfo The package info of the extension.
*/ */
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): AnimeLoadResult { private fun loadExtension(context: Context, extensionInfo: AnimeExtensionInfo): AnimeLoadResult {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val appInfo = try { val pkgInfo = extensionInfo.packageInfo
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) val appInfo = pkgInfo.applicationInfo
} catch (error: PackageManager.NameNotFoundException) { val pkgName = pkgInfo.packageName
// Unlikely, but the package may have been uninstalled at this point
logcat(LogPriority.ERROR, error)
return AnimeLoadResult.Error
}
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Aniyomi: ") val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Aniyomi: ")
val versionName = pkgInfo.versionName val versionName = pkgInfo.versionName
@ -139,13 +241,19 @@ internal object AnimeExtensionLoader {
return AnimeLoadResult.Error return AnimeLoadResult.Error
} }
val signatureHash = getSignatureHash(context, pkgInfo) val signatures = getSignatures(pkgInfo)
if (signatures.isNullOrEmpty()) {
if (signatureHash == null) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return AnimeLoadResult.Error return AnimeLoadResult.Error
} else if (signatureHash !in trustedSignatures) { } else if (!hasTrustedSignature(signatures)) {
val extension = AnimeExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash) val extension = AnimeExtension.Untrusted(
extName,
pkgName,
versionName,
versionCode,
libVersion,
signatures.last(),
)
logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" }) logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" })
return AnimeLoadResult.Untrusted(extension) return AnimeLoadResult.Untrusted(extension)
} }
@ -205,12 +313,35 @@ internal object AnimeExtensionLoader {
hasChangelog = hasChangelog, hasChangelog = hasChangelog,
sources = sources, sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
isUnofficial = signatureHash != officialSignature, isUnofficial = !isOfficiallySigned(signatures),
icon = context.getApplicationIcon(pkgName), icon = appInfo.loadIcon(pkgManager),
isShared = extensionInfo.isShared,
) )
return AnimeLoadResult.Success(extension) 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. * 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. * @param pkgInfo The package info of the application.
* @return List SHA256 digest of the signatures
*/ */
private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? { private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) } 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? { fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
if (pkgName != null) { 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 return null
} }
@ -333,6 +336,7 @@ class MangaExtensionManager(
} }
override fun onPackageUninstalled(pkgName: String) { override fun onPackageUninstalled(pkgName: String) {
MangaExtensionLoader.uninstallPrivateExtension(context, pkgName)
unregisterExtension(pkgName) unregisterExtension(pkgName)
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }

View file

@ -32,6 +32,7 @@ sealed class MangaExtension {
val hasUpdate: Boolean = false, val hasUpdate: Boolean = false,
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isUnofficial: Boolean = false, val isUnofficial: Boolean = false,
val isShared: Boolean,
) : MangaExtension() ) : MangaExtension()
data class Available( data class Available(

View file

@ -4,6 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter 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.MangaExtension
import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
@ -27,7 +30,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
* Registers this broadcast receiver * Registers this broadcast receiver
*/ */
fun register(context: Context) { 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_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(ACTION_EXTENSION_ADDED)
addAction(ACTION_EXTENSION_REPLACED)
addAction(ACTION_EXTENSION_REMOVED)
addDataScheme("package") addDataScheme("package")
} }
@ -49,7 +55,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
if (intent == null) return if (intent == null) return
when (intent.action) { when (intent.action) {
Intent.ACTION_PACKAGE_ADDED -> { Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
if (isReplacing(intent)) return if (isReplacing(intent)) return
launchNow { launchNow {
@ -61,7 +67,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
} }
} }
} }
Intent.ACTION_PACKAGE_REPLACED -> { Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
launchNow { launchNow {
when (val result = getExtensionFromIntent(context, intent)) { when (val result = getExtensionFromIntent(context, intent)) {
is MangaLoadResult.Success -> listener.onExtensionUpdated(result.extension) 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 if (isReplacing(intent)) return
val pkgName = getPackageNameFromIntent(intent) val pkgName = getPackageNameFromIntent(intent)
@ -127,4 +133,30 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
fun onExtensionUntrusted(extension: MangaExtension.Untrusted) fun onExtensionUntrusted(extension: MangaExtension.Untrusted)
fun onPackageUninstalled(pkgName: String) 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 androidx.core.net.toUri
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.extension.InstallStep 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.installer.InstallerManga
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.isPackageInstalled
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -157,6 +159,35 @@ internal class MangaExtensionInstaller(private val context: Context) {
context.startActivity(intent) 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 -> { else -> {
val intent = val intent =
MangaExtensionInstallService.getIntent(context, downloadId, uri, installer) 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 * @param pkgName The package name of the extension to uninstall
*/ */
fun uninstallApk(pkgName: String) { fun uninstallApk(pkgName: String) {
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) if (context.isPackageInstalled(pkgName)) {
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @Suppress("DEPRECATION")
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
context.startActivity(intent) .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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import androidx.core.content.pm.PackageInfoCompat.getSignatures
import dalvik.system.PathClassLoader import dalvik.system.PathClassLoader
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension 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.MangaSource
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.system.getApplicationIcon
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy 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") @SuppressLint("PackageManagerGetSignatures")
internal object MangaExtensionLoader { internal object MangaExtensionLoader {
@ -41,12 +55,11 @@ internal object MangaExtensionLoader {
const val LIB_VERSION_MIN = 1.2 const val LIB_VERSION_MIN = 1.2
const val LIB_VERSION_MAX = 1.5 const val LIB_VERSION_MAX = 1.5
private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @Suppress("DEPRECATION")
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
} else { PackageManager.GET_META_DATA or
@Suppress("DEPRECATION") PackageManager.GET_SIGNATURES or
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
}
// inorichi's key // inorichi's key
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
@ -56,8 +69,57 @@ internal object MangaExtensionLoader {
*/ */
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() 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. * @param context The application context.
*/ */
@ -70,16 +132,43 @@ internal object MangaExtensionLoader {
pkgManager.getInstalledPackages(PACKAGE_FLAGS) 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() if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking { return runBlocking {
val deferred = extPkgs.map { 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. * contains the required feature flag before trying to load it.
*/ */
fun loadMangaExtensionFromPkgName(context: Context, pkgName: String): MangaLoadResult { 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) context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
.takeIf { isPackageAnExtension(it) }
?.let {
MangaExtensionInfo(
packageInfo = it,
isShared = true,
)
}
} catch (error: PackageManager.NameNotFoundException) { } catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point null
logcat(LogPriority.ERROR, error)
return MangaLoadResult.Error
} }
if (!isPackageAnExtension(pkgInfo)) {
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } return selectExtensionPackage(sharedPkg, privatePkg)
return MangaLoadResult.Error
}
return loadMangaExtension(context, pkgName, pkgInfo)
} }
/** /**
* Loads an extension given its package name. * Loads an extension
* *
* @param context The application context. * @param context The application context.
* @param pkgName The package name of the extension to load. * @param extensionInfo The extension to load.
* @param pkgInfo The package info of the extension.
*/ */
private fun loadMangaExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): MangaLoadResult { private fun loadMangaExtension(context: Context, extensionInfo: MangaExtensionInfo): MangaLoadResult {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo
val appInfo = try { val appInfo = pkgInfo.applicationInfo
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) val pkgName = pkgInfo.packageName
} catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point
logcat(LogPriority.ERROR, error)
return MangaLoadResult.Error
}
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
val versionName = pkgInfo.versionName val versionName = pkgInfo.versionName
@ -139,13 +252,19 @@ internal object MangaExtensionLoader {
return MangaLoadResult.Error return MangaLoadResult.Error
} }
val signatureHash = getSignatureHash(context, pkgInfo) val signatures = getSignatures(pkgInfo)
if (signatures.isNullOrEmpty()) {
if (signatureHash == null) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return MangaLoadResult.Error return MangaLoadResult.Error
} else if (signatureHash !in trustedSignatures) { } else if (!hasTrustedSignature(signatures)) {
val extension = MangaExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash) val extension = MangaExtension.Untrusted(
extName,
pkgName,
versionName,
versionCode,
libVersion,
signatures.last(),
)
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" } logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
return MangaLoadResult.Untrusted(extension) return MangaLoadResult.Untrusted(extension)
} }
@ -205,12 +324,35 @@ internal object MangaExtensionLoader {
hasChangelog = hasChangelog, hasChangelog = hasChangelog,
sources = sources, sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
isUnofficial = signatureHash != officialSignature, isUnofficial = !isOfficiallySigned(signatures),
icon = context.getApplicationIcon(pkgName), icon = appInfo.loadIcon(pkgManager),
isShared = extensionInfo.isShared,
) )
return MangaLoadResult.Success(extension) 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. * 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. * @param pkgInfo The package info of the application.
* @return List SHA256 digest of the signatures
*/ */
private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? { private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) } 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 -> sourceScreen.forEach { pref ->
pref.isIconSpaceReserved = false pref.isIconSpaceReserved = false
pref.isSingleLineTitle = false pref.isSingleLineTitle = false
if (pref is DialogPreference) { if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) {
pref.dialogTitle = pref.title pref.dialogTitle = pref.title
} }

View file

@ -147,7 +147,7 @@ class MangaSourcePreferencesFragment : PreferenceFragmentCompat() {
sourceScreen.forEach { pref -> sourceScreen.forEach { pref ->
pref.isIconSpaceReserved = false pref.isIconSpaceReserved = false
pref.isSingleLineTitle = false pref.isSingleLineTitle = false
if (pref is DialogPreference) { if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) {
pref.dialogTitle = pref.title 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.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.ui.main.MainActivity
class DeepLinkAnimeActivity : Activity() { 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.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.ui.main.MainActivity
class DeepLinkMangaActivity : Activity() { 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 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 selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedEpisodeIds: HashSet<Long> = HashSet() private val selectedEpisodeIds: HashSet<Long> = HashSet()

View file

@ -130,7 +130,7 @@ class MangaScreenModel(
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
val skipFiltered by readerPreferences.skipFiltered().asState(coroutineScope) 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 selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedChapterIds: HashSet<Long> = HashSet() private val selectedChapterIds: HashSet<Long> = HashSet()

View file

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

View file

@ -168,7 +168,7 @@ object AnimeLibraryTab : Tab() {
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { contentPadding -> ) { contentPadding ->
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> { state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
EmptyScreen( EmptyScreen(

View file

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

View file

@ -165,7 +165,7 @@ object MangaLibraryTab : Tab() {
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { contentPadding -> ) { contentPadding ->
when { when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> { state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
EmptyScreen( 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.anime.source.globalsearch.GlobalAnimeSearchScreen
import eu.kanade.tachiyomi.ui.browse.manga.source.browse.BrowseMangaSourceScreen 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.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.anime.AnimeScreen
import eu.kanade.tachiyomi.ui.entries.manga.MangaScreen import eu.kanade.tachiyomi.ui.entries.manga.MangaScreen
import eu.kanade.tachiyomi.ui.home.HomeScreen import eu.kanade.tachiyomi.ui.home.HomeScreen
@ -449,6 +450,7 @@ class MainActivity : BaseActivity() {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
navigator.popUntilRoot() navigator.popUntilRoot()
navigator.push(GlobalMangaSearchScreen(query)) navigator.push(GlobalMangaSearchScreen(query))
navigator.push(DeepLinkMangaScreen(query))
} }
null null
} }

View file

@ -385,11 +385,14 @@ class ReaderActivity : BaseActivity() {
binding.pageNumber.setComposeContent { binding.pageNumber.setComposeContent {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val showPageNumber by viewModel.readerPreferences.showPageNumber().collectAsState()
PageIndicatorText( if (!state.menuVisible && showPageNumber) {
currentPage = state.currentPage, PageIndicatorText(
totalPages = state.totalPages, currentPage = state.currentPage,
) totalPages = state.totalPages,
)
}
} }
binding.readerMenuBottom.setComposeContent { binding.readerMenuBottom.setComposeContent {
@ -557,10 +560,6 @@ class ReaderActivity : BaseActivity() {
bottomAnimation.applySystemAnimatorScale(this) bottomAnimation.applySystemAnimatorScale(this)
binding.readerMenuBottom.startAnimation(bottomAnimation) binding.readerMenuBottom.startAnimation(bottomAnimation)
} }
if (readerPreferences.showPageNumber().get()) {
config?.setPageNumberVisibility(false)
}
} else { } else {
if (readerPreferences.fullscreen().get()) { if (readerPreferences.fullscreen().get()) {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
@ -583,10 +582,6 @@ class ReaderActivity : BaseActivity() {
bottomAnimation.applySystemAnimatorScale(this) bottomAnimation.applySystemAnimatorScale(this)
binding.readerMenuBottom.startAnimation(bottomAnimation) binding.readerMenuBottom.startAnimation(bottomAnimation)
} }
if (readerPreferences.showPageNumber().get()) {
config?.setPageNumberVisibility(true)
}
} }
} }
@ -639,9 +634,8 @@ class ReaderActivity : BaseActivity() {
private fun showReadingModeToast(mode: Int) { private fun showReadingModeToast(mode: Int) {
try { try {
val strings = resources.getStringArray(R.array.viewers_selector)
readingModeToast?.cancel() readingModeToast?.cancel()
readingModeToast = toast(strings[mode]) readingModeToast = toast(ReadingModeType.fromPreference(mode).stringRes)
} catch (e: ArrayIndexOutOfBoundsException) { } catch (e: ArrayIndexOutOfBoundsException) {
logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" } logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" }
} }
@ -895,10 +889,6 @@ class ReaderActivity : BaseActivity() {
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
readerPreferences.showPageNumber().changes()
.onEach(::setPageNumberVisibility)
.launchIn(lifecycleScope)
readerPreferences.trueColor().changes() readerPreferences.trueColor().changes()
.onEach(::setTrueColor) .onEach(::setTrueColor)
.launchIn(lifecycleScope) .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]. * Sets the 32-bit color mode according to [enabled].
*/ */

View file

@ -5,14 +5,14 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R 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) { enum class OrientationType(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), DEFAULT(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), FREE(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), PORTRAIT(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), LANDSCAPE(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_PORTRAIT(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), LOCKED_LANDSCAPE(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), REVERSE_PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, R.string.rotation_reverse_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000030),
; ;
companion object { 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.pager.VerticalPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer 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) { enum class ReadingModeType(@StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
DEFAULT(0, R.string.label_default, R.drawable.ic_reader_default_24dp, 0x00000000), DEFAULT(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), LEFT_TO_RIGHT(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), RIGHT_TO_LEFT(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), VERTICAL(R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp, 0x00000003),
WEBTOON(4, R.string.webtoon_viewer, R.drawable.ic_reader_webtoon_24dp, 0x00000004), WEBTOON(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), CONTINUOUS_VERTICAL(R.string.vertical_plus_viewer, R.drawable.ic_reader_continuous_vertical_24dp, 0x00000005),
; ;
companion object { companion object {

View file

@ -88,14 +88,14 @@ class AnimeStatsScreenModel(
} }
private fun getGlobalUpdateItemCount(libraryAnime: List<LibraryAnime>): Int { 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()) { val includedAnime = if (includedCategories.isNotEmpty()) {
libraryAnime.filter { it.category in includedCategories } libraryAnime.filter { it.category in includedCategories }
} else { } else {
libraryAnime libraryAnime
} }
val excludedCategories = preferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() } val excludedCategories = preferences.animeUpdateCategoriesExclude().get().map { it.toLong() }
val excludedMangaIds = if (excludedCategories.isNotEmpty()) { val excludedMangaIds = if (excludedCategories.isNotEmpty()) {
libraryAnime.fastMapNotNull { anime -> libraryAnime.fastMapNotNull { anime ->
anime.id.takeIf { anime.category in excludedCategories } anime.id.takeIf { anime.category in excludedCategories }
@ -104,7 +104,7 @@ class AnimeStatsScreenModel(
emptyList() emptyList()
} }
val updateRestrictions = preferences.libraryUpdateItemRestriction().get() val updateRestrictions = preferences.autoUpdateItemRestrictions().get()
return includedAnime return includedAnime
.fastFilterNot { it.anime.id in excludedMangaIds } .fastFilterNot { it.anime.id in excludedMangaIds }
.fastDistinctBy { it.anime.id } .fastDistinctBy { it.anime.id }

View file

@ -88,14 +88,14 @@ class MangaStatsScreenModel(
} }
private fun getGlobalUpdateItemCount(libraryManga: List<LibraryManga>): Int { 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()) { val includedManga = if (includedCategories.isNotEmpty()) {
libraryManga.filter { it.category in includedCategories } libraryManga.filter { it.category in includedCategories }
} else { } else {
libraryManga libraryManga
} }
val excludedCategories = preferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() } val excludedCategories = preferences.mangaUpdateCategoriesExclude().get().map { it.toLong() }
val excludedMangaIds = if (excludedCategories.isNotEmpty()) { val excludedMangaIds = if (excludedCategories.isNotEmpty()) {
libraryManga.fastMapNotNull { manga -> libraryManga.fastMapNotNull { manga ->
manga.id.takeIf { manga.category in excludedCategories } manga.id.takeIf { manga.category in excludedCategories }
@ -104,7 +104,7 @@ class MangaStatsScreenModel(
emptyList() emptyList()
} }
val updateRestrictions = preferences.libraryUpdateItemRestriction().get() val updateRestrictions = preferences.autoUpdateItemRestrictions().get()
return includedManga return includedManga
.fastFilterNot { it.manga.id in excludedMangaIds } .fastFilterNot { it.manga.id in excludedMangaIds }
.fastDistinctBy { it.manga.id } .fastDistinctBy { it.manga.id }

View file

@ -66,7 +66,7 @@ class AnimeUpdatesScreenModel(
private val _events: Channel<Event> = Channel(Int.MAX_VALUE) private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _events.receiveAsFlow() 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() val useExternalDownloader = downloadPreferences.useExternalDownloader().get()

View file

@ -64,7 +64,7 @@ class MangaUpdatesScreenModel(
private val _events: Channel<Event> = Channel(Int.MAX_VALUE) private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _events.receiveAsFlow() 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 // First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1) private val selectedPositions: Array<Int> = arrayOf(-1, -1)

View file

@ -1,24 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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"> <string-array name="playback_options">
<item>@string/playback_options_speed</item> <item>@string/playback_options_speed</item>
<item>@string/playback_options_quality</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") class HttpException(val code: Int) : IllegalStateException("HTTP error $code")

View file

@ -58,7 +58,7 @@ class ChapterRepositoryImpl(
read = chapterUpdate.read, read = chapterUpdate.read,
bookmark = chapterUpdate.bookmark, bookmark = chapterUpdate.bookmark,
lastPageRead = chapterUpdate.lastPageRead, lastPageRead = chapterUpdate.lastPageRead,
chapterNumber = chapterUpdate.chapterNumber?.toDouble(), chapterNumber = chapterUpdate.chapterNumber,
sourceOrder = chapterUpdate.sourceOrder, sourceOrder = chapterUpdate.sourceOrder,
dateFetch = chapterUpdate.dateFetch, dateFetch = chapterUpdate.dateFetch,
dateUpload = chapterUpdate.dateUpload, dateUpload = chapterUpdate.dateUpload,

View file

@ -60,7 +60,7 @@ class EpisodeRepositoryImpl(
bookmark = episodeUpdate.bookmark, bookmark = episodeUpdate.bookmark,
lastSecondSeen = episodeUpdate.lastSecondSeen, lastSecondSeen = episodeUpdate.lastSecondSeen,
totalSeconds = episodeUpdate.totalSeconds, totalSeconds = episodeUpdate.totalSeconds,
episodeNumber = episodeUpdate.episodeNumber?.toDouble(), episodeNumber = episodeUpdate.episodeNumber,
sourceOrder = episodeUpdate.sourceOrder, sourceOrder = episodeUpdate.sourceOrder,
dateFetch = episodeUpdate.dateFetch, dateFetch = episodeUpdate.dateFetch,
dateUpload = episodeUpdate.dateUpload, dateUpload = episodeUpdate.dateUpload,

View file

@ -21,10 +21,25 @@ data class GithubRelease(
@Serializable @Serializable
data class GitHubAssets(@SerialName("browser_download_url") val downloadLink: String) 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 = { val releaseMapper: (GithubRelease) -> Release = {
Release( Release(
it.version, it.version,
it.info, it.info.replace(gitHubUsernameMentionRegex) { mention ->
"[${mention.value}](https://github.com/${mention.value.substring(1)})"
},
it.releaseLink, it.releaseLink,
it.assets.map(GitHubAssets::downloadLink), it.assets.map(GitHubAssets::downloadLink),
) )

View file

@ -14,7 +14,7 @@ class CreateAnimeCategoryWithName(
private val initialFlags: Long private val initialFlags: Long
get() { get() {
val sort = preferences.libraryAnimeSortingMode().get() val sort = preferences.animeSortingMode().get()
return sort.type.flag or sort.direction.flag return sort.type.flag or sort.direction.flag
} }

View file

@ -10,7 +10,7 @@ class ResetAnimeCategoryFlags(
) { ) {
suspend fun await() { suspend fun await() {
val sort = preferences.libraryAnimeSortingMode().get() val sort = preferences.animeSortingMode().get()
categoryRepository.updateAllAnimeCategoryFlags(sort.type + sort.direction) categoryRepository.updateAllAnimeCategoryFlags(sort.type + sort.direction)
} }
} }

View file

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

View file

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

View file

@ -14,7 +14,7 @@ class CreateMangaCategoryWithName(
private val initialFlags: Long private val initialFlags: Long
get() { get() {
val sort = preferences.libraryMangaSortingMode().get() val sort = preferences.mangaSortingMode().get()
return sort.type.flag or sort.direction.flag return sort.type.flag or sort.direction.flag
} }

View file

@ -10,7 +10,7 @@ class ResetMangaCategoryFlags(
) { ) {
suspend fun await() { suspend fun await() {
val sort = preferences.libraryMangaSortingMode().get() val sort = preferences.mangaSortingMode().get()
categoryRepository.updateAllMangaCategoryFlags(sort.type + sort.direction) categoryRepository.updateAllMangaCategoryFlags(sort.type + sort.direction)
} }
} }

View file

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

View file

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

View file

@ -20,39 +20,38 @@ class LibraryPreferences(
fun isDefaultHomeTabLibraryManga() = fun isDefaultHomeTabLibraryManga() =
preferenceStore.getBoolean("default_home_tab_library", false) preferenceStore.getBoolean("default_home_tab_library", false)
fun libraryDisplayMode() = preferenceStore.getObject( fun displayMode() = preferenceStore.getObject(
"pref_display_mode_library", "pref_display_mode_library",
LibraryDisplayMode.default, LibraryDisplayMode.default,
LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::serialize,
LibraryDisplayMode.Serializer::deserialize, LibraryDisplayMode.Serializer::deserialize,
) )
fun libraryMangaSortingMode() = preferenceStore.getObject( fun mangaSortingMode() = preferenceStore.getObject(
"library_sorting_mode", "library_sorting_mode",
MangaLibrarySort.default, MangaLibrarySort.default,
MangaLibrarySort.Serializer::serialize, MangaLibrarySort.Serializer::serialize,
MangaLibrarySort.Serializer::deserialize, MangaLibrarySort.Serializer::deserialize,
) )
fun libraryAnimeSortingMode() = preferenceStore.getObject( fun animeSortingMode() = preferenceStore.getObject(
"animelib_sorting_mode", "animelib_sorting_mode",
AnimeLibrarySort.default, AnimeLibrarySort.default,
AnimeLibrarySort.Serializer::serialize, AnimeLibrarySort.Serializer::serialize,
AnimeLibrarySort.Serializer::deserialize, 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 autoUpdateDeviceRestrictions() = preferenceStore.getStringSet(
fun libraryUpdateDeviceRestriction() = preferenceStore.getStringSet(
"library_update_restriction", "library_update_restriction",
setOf( setOf(
DEVICE_ONLY_ON_WIFI, DEVICE_ONLY_ON_WIFI,
), ),
) )
fun libraryUpdateItemRestriction() = preferenceStore.getStringSet( fun autoUpdateItemRestrictions() = preferenceStore.getStringSet(
"library_update_manga_restriction", "library_update_manga_restriction",
setOf( setOf(
ENTRY_HAS_UNVIEWED, ENTRY_HAS_UNVIEWED,
@ -172,16 +171,16 @@ class LibraryPreferences(
fun lastUsedAnimeCategory() = preferenceStore.getInt("last_used_anime_category", 0) fun lastUsedAnimeCategory() = preferenceStore.getInt("last_used_anime_category", 0)
fun lastUsedMangaCategory() = preferenceStore.getInt("last_used_category", 0) fun lastUsedMangaCategory() = preferenceStore.getInt("last_used_category", 0)
fun animeLibraryUpdateCategories() = fun animeUpdateCategories() =
preferenceStore.getStringSet("animelib_update_categories", emptySet()) preferenceStore.getStringSet("animelib_update_categories", emptySet())
fun mangaLibraryUpdateCategories() = fun mangaUpdateCategories() =
preferenceStore.getStringSet("library_update_categories", emptySet()) preferenceStore.getStringSet("library_update_categories", emptySet())
fun animeLibraryUpdateCategoriesExclude() = fun animeUpdateCategoriesExclude() =
preferenceStore.getStringSet("animelib_update_categories_exclude", emptySet()) preferenceStore.getStringSet("animelib_update_categories_exclude", emptySet())
fun mangaLibraryUpdateCategoriesExclude() = fun mangaUpdateCategoriesExclude() =
preferenceStore.getStringSet("library_update_categories_exclude", emptySet()) preferenceStore.getStringSet("library_update_categories_exclude", emptySet())
// Mixture Item // Mixture Item
@ -291,7 +290,6 @@ class LibraryPreferences(
const val DEVICE_ONLY_ON_WIFI = "wifi" const val DEVICE_ONLY_ON_WIFI = "wifi"
const val DEVICE_NETWORK_NOT_METERED = "network_not_metered" const val DEVICE_NETWORK_NOT_METERED = "network_not_metered"
const val DEVICE_CHARGING = "ac" const val DEVICE_CHARGING = "ac"
const val DEVICE_BATTERY_NOT_LOW = "battery_not_low"
const val ENTRY_NON_COMPLETED = "manga_ongoing" const val ENTRY_NON_COMPLETED = "manga_ongoing"
const val ENTRY_HAS_UNVIEWED = "manga_fully_read" const val ENTRY_HAS_UNVIEWED = "manga_fully_read"

View file

@ -12,7 +12,7 @@ class StubAnimeSource(
override val name: String, override val name: String,
) : AnimeSource { ) : AnimeSource {
val isInvalid: Boolean = name.isBlank() || lang.isBlank() private val isInvalid: Boolean = name.isBlank() || lang.isBlank()
override suspend fun getAnimeDetails(anime: SAnime): SAnime { override suspend fun getAnimeDetails(anime: SAnime): SAnime {
throw AnimeSourceNotInstalledException() throw AnimeSourceNotInstalledException()

View file

@ -13,13 +13,13 @@ class StubMangaSource(
override val name: String, override val name: String,
) : MangaSource { ) : MangaSource {
val isInvalid: Boolean = name.isBlank() || lang.isBlank() private val isInvalid: Boolean = name.isBlank() || lang.isBlank()
override suspend fun getMangaDetails(manga: SManga): SManga { override suspend fun getMangaDetails(manga: SManga): SManga {
throw SourceNotInstalledException() 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> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(SourceNotInstalledException()) return Observable.error(SourceNotInstalledException())
} }
@ -28,7 +28,7 @@ class StubMangaSource(
throw SourceNotInstalledException() 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>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(SourceNotInstalledException()) return Observable.error(SourceNotInstalledException())
} }
@ -37,7 +37,7 @@ class StubMangaSource(
throw SourceNotInstalledException() 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>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(SourceNotInstalledException()) 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.Execution
import org.junit.jupiter.api.parallel.ExecutionMode import org.junit.jupiter.api.parallel.ExecutionMode
import tachiyomi.domain.items.episode.model.Episode import tachiyomi.domain.items.episode.model.Episode
import java.time.Duration
import java.time.ZonedDateTime import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.toJavaDuration
@Execution(ExecutionMode.CONCURRENT) @Execution(ExecutionMode.CONCURRENT)
class SetAnimeFetchIntervalTest { class SetAnimeFetchIntervalTest {
@ -22,49 +24,34 @@ class SetAnimeFetchIntervalTest {
@Test @Test
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() { fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
val episodes = mutableListOf<Episode>() val episodes = (1..2).map {
(1..1).forEach { episodeWithTime(episode, 10.hours)
val duration = Duration.ofHours(10)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
} }
@Test @Test
fun `calculateInterval returns 7 when 5 episodes in 1 day`() { fun `calculateInterval returns 7 when 5 episodes in 1 day`() {
val episodes = mutableListOf<Episode>() val episodes = (1..5).map {
(1..5).forEach { episodeWithTime(episode, 10.hours)
val duration = Duration.ofHours(10)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
} }
@Test @Test
fun `calculateInterval returns 7 when 7 episodes in 48 hours, 2 day`() { fun `calculateInterval returns 7 when 7 episodes in 48 hours, 2 day`() {
val episodes = mutableListOf<Episode>() val episodes = (1..2).map {
(1..2).forEach { episodeWithTime(episode, 24.hours)
val duration = Duration.ofHours(24L) } + (1..5).map {
val newEpisode = episodeAddTime(episode, duration) episodeWithTime(episode, 48.hours)
episodes.add(newEpisode)
}
(1..5).forEach {
val duration = Duration.ofHours(48L)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
} }
@Test @Test
fun `calculateInterval returns default of 1 day when interval less than 1`() { fun `calculateInterval returns default of 1 day when interval less than 1`() {
val episodes = mutableListOf<Episode>() val episodes = (1..5).map {
(1..5).forEach { episodeWithTime(episode, (15 * it).hours)
val duration = Duration.ofHours(15L * it)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
} }
@ -72,61 +59,46 @@ class SetAnimeFetchIntervalTest {
// Normal interval calculation // Normal interval calculation
@Test @Test
fun `calculateInterval returns 1 when 5 episodes in 120 hours, 5 days`() { fun `calculateInterval returns 1 when 5 episodes in 120 hours, 5 days`() {
val episodes = mutableListOf<Episode>() val episodes = (1..5).map {
(1..5).forEach { episodeWithTime(episode, (24 * it).hours)
val duration = Duration.ofHours(24L * it)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
} }
@Test @Test
fun `calculateInterval returns 2 when 5 episodes in 240 hours, 10 days`() { fun `calculateInterval returns 2 when 5 episodes in 240 hours, 10 days`() {
val episodes = mutableListOf<Episode>() val episodes = (1..5).map {
(1..5).forEach { episodeWithTime(episode, (48 * it).hours)
val duration = Duration.ofHours(48L * it)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 2 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 2
} }
@Test @Test
fun `calculateInterval returns floored value when interval is decimal`() { fun `calculateInterval returns floored value when interval is decimal`() {
val episodes = mutableListOf<Episode>() val episodes = (1..5).map {
(1..5).forEach { episodeWithTime(episode, (25 * it).hours)
val duration = Duration.ofHours(25L * it)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
} }
@Test @Test
fun `calculateInterval returns 1 when 5 episodes in 215 hours, 5 days`() { fun `calculateInterval returns 1 when 5 episodes in 215 hours, 5 days`() {
val episodes = mutableListOf<Episode>() val episodes = (1..5).map {
(1..5).forEach { episodeWithTime(episode, (43 * it).hours)
val duration = Duration.ofHours(43L * it)
val newEpisode = episodeAddTime(episode, duration)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
} }
@Test @Test
fun `calculateInterval returns interval based on fetch time if upload time not available`() { fun `calculateInterval returns interval based on fetch time if upload time not available`() {
val episodes = mutableListOf<Episode>() val episodes = (1..5).map {
(1..5).forEach { episodeWithTime(episode, (25 * it).hours).copy(dateUpload = 0L)
val duration = Duration.ofHours(25L * it)
val newEpisode = episodeAddTime(episode, duration).copy(dateUpload = 0L)
episodes.add(newEpisode)
} }
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1 setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
} }
private fun episodeAddTime(episode: Episode, duration: Duration): Episode { private fun episodeWithTime(episode: Episode, duration: Duration): Episode {
val newTime = testTime.plus(duration).toEpochSecond() * 1000 val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
return episode.copy(dateFetch = newTime, dateUpload = newTime) 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.Execution
import org.junit.jupiter.api.parallel.ExecutionMode import org.junit.jupiter.api.parallel.ExecutionMode
import tachiyomi.domain.items.chapter.model.Chapter import tachiyomi.domain.items.chapter.model.Chapter
import java.time.Duration
import java.time.ZonedDateTime import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.toJavaDuration
@Execution(ExecutionMode.CONCURRENT) @Execution(ExecutionMode.CONCURRENT)
class SetMangaFetchIntervalTest { class SetMangaFetchIntervalTest {
@ -22,49 +24,34 @@ class SetMangaFetchIntervalTest {
@Test @Test
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() { fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..2).map {
(1..1).forEach { chapterWithTime(chapter, 10.hours)
val duration = Duration.ofHours(10)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
} }
@Test @Test
fun `calculateInterval returns 7 when 5 chapters in 1 day`() { fun `calculateInterval returns 7 when 5 chapters in 1 day`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..5).map {
(1..5).forEach { chapterWithTime(chapter, 10.hours)
val duration = Duration.ofHours(10)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
} }
@Test @Test
fun `calculateInterval returns 7 when 7 chapters in 48 hours, 2 day`() { fun `calculateInterval returns 7 when 7 chapters in 48 hours, 2 day`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..2).map {
(1..2).forEach { chapterWithTime(chapter, 24.hours)
val duration = Duration.ofHours(24L) } + (1..5).map {
val newChapter = chapterAddTime(chapter, duration) chapterWithTime(chapter, 48.hours)
chapters.add(newChapter)
}
(1..5).forEach {
val duration = Duration.ofHours(48L)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
} }
@Test @Test
fun `calculateInterval returns default of 1 day when interval less than 1`() { fun `calculateInterval returns default of 1 day when interval less than 1`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..5).map {
(1..5).forEach { chapterWithTime(chapter, (15 * it).hours)
val duration = Duration.ofHours(15L * it)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
} }
@ -72,61 +59,46 @@ class SetMangaFetchIntervalTest {
// Normal interval calculation // Normal interval calculation
@Test @Test
fun `calculateInterval returns 1 when 5 chapters in 120 hours, 5 days`() { fun `calculateInterval returns 1 when 5 chapters in 120 hours, 5 days`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..5).map {
(1..5).forEach { chapterWithTime(chapter, (24 * it).hours)
val duration = Duration.ofHours(24L * it)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
} }
@Test @Test
fun `calculateInterval returns 2 when 5 chapters in 240 hours, 10 days`() { fun `calculateInterval returns 2 when 5 chapters in 240 hours, 10 days`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..5).map {
(1..5).forEach { chapterWithTime(chapter, (48 * it).hours)
val duration = Duration.ofHours(48L * it)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 2 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 2
} }
@Test @Test
fun `calculateInterval returns floored value when interval is decimal`() { fun `calculateInterval returns floored value when interval is decimal`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..5).map {
(1..5).forEach { chapterWithTime(chapter, (25 * it).hours)
val duration = Duration.ofHours(25L * it)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
} }
@Test @Test
fun `calculateInterval returns 1 when 5 chapters in 215 hours, 5 days`() { fun `calculateInterval returns 1 when 5 chapters in 215 hours, 5 days`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..5).map {
(1..5).forEach { chapterWithTime(chapter, (43 * it).hours)
val duration = Duration.ofHours(43L * it)
val newChapter = chapterAddTime(chapter, duration)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
} }
@Test @Test
fun `calculateInterval returns interval based on fetch time if upload time not available`() { fun `calculateInterval returns interval based on fetch time if upload time not available`() {
val chapters = mutableListOf<Chapter>() val chapters = (1..5).map {
(1..5).forEach { chapterWithTime(chapter, (25 * it).hours).copy(dateUpload = 0L)
val duration = Duration.ofHours(25L * it)
val newChapter = chapterAddTime(chapter, duration).copy(dateUpload = 0L)
chapters.add(newChapter)
} }
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1 setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
} }
private fun chapterAddTime(chapter: Chapter, duration: Duration): Chapter { private fun chapterWithTime(chapter: Chapter, duration: Duration): Chapter {
val newTime = testTime.plus(duration).toEpochSecond() * 1000 val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
return chapter.copy(dateFetch = newTime, dateUpload = newTime) return chapter.copy(dateFetch = newTime, dateUpload = newTime)
} }
} }

View file

@ -25,6 +25,4 @@ kotlin.mpp.androidSourceSetLayoutVersion=2
android.useAndroidX=true android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.nonFinalResIds=false #android.nonFinalResIds=false
android.experimental.useDefaultDebugSigningConfigForProfileableBuildtypes=true

View file

@ -1,16 +1,16 @@
[versions] [versions]
agp_version = "8.0.2" agp_version = "8.1.1"
lifecycle_version = "2.6.1" lifecycle_version = "2.6.1"
paging_version = "3.2.0" paging_version = "3.2.0"
[libraries] [libraries]
gradle = { module = "com.android.tools.build:gradle", version.ref = "agp_version" } 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" appcompat = "androidx.appcompat:appcompat:1.6.1"
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" 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" splashscreen = "androidx.core:core-splashscreen:1.0.1"
recyclerview = "androidx.recyclerview:recyclerview:1.3.1" recyclerview = "androidx.recyclerview:recyclerview:1.3.1"
viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01" 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-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
paging-compose = { module = "androidx.paging:paging-compose", 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-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha01"
test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01" test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04" test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04"

View file

@ -1,7 +1,7 @@
[versions] [versions]
compiler = "1.5.1" compiler = "1.5.2"
compose-bom = "2023.07.00-alpha02" compose-bom = "2023.09.00-alpha02"
accompanist = "0.31.5-beta" accompanist = "0.33.1-alpha"
[libraries] [libraries]
activity = "androidx.activity:activity-compose:1.7.2" 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" desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2" 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" rxjava = "io.reactivex:rxjava:1.3.8"
flowreactivenetwork = "ru.beryukhov:flowreactivenetwork:1.0.4" 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-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" }
sqlite-android = "com.github.requery:sqlite-android:3.42.0" 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" 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" photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0" directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1" 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" compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0"
swipe = "me.saket.swipe:swipe:1.2.0" swipe = "me.saket.swipe:swipe:1.2.0"
logcat = "com.squareup.logcat:logcat:0.1" 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-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlib_version" }
aboutLibraries-gradle = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", 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" junit = "org.junit.jupiter:junit-jupiter:5.10.0"
kotest-assertions = "io.kotest:kotest-assertions-core:5.6.2" 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-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-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" truetypeparser = "io.github.yubyf:truetypeparser-light:2.1.4"
[bundles] [bundles]
reactivex = ["rxandroid", "rxjava"]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
js-engine = ["quickjs-android"] js-engine = ["quickjs-android"]
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"] sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View file

@ -252,6 +252,7 @@
<item quantity="one">%d second</item> <item quantity="one">%d second</item>
<item quantity="other">%d seconds</item> <item quantity="other">%d seconds</item>
</plurals> </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="no_next_episode">Next Episode not found!</string>
<string name="label_storage">Storage</string> <string name="label_storage">Storage</string>
<string name="label_history">Manga</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="connected_to_wifi">Only on Wi-Fi</string>
<string name="network_not_metered">Only on unmetered network</string> <string name="network_not_metered">Only on unmetered network</string>
<string name="charging">When charging</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="restrictions">Restrictions: %s</string>
<string name="pref_library_update_manga_restriction">Skip updating entries</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_packageinstaller" translatable="false">PackageInstaller</string>
<string name="ext_installer_shizuku" translatable="false">Shizuku</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_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> <string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
<!-- Reader section --> <!-- Reader section -->
@ -489,6 +489,7 @@
<string name="creating_backup_error">Backup failed</string> <string name="creating_backup_error">Backup failed</string>
<string name="missing_storage_permission">Storage permissions not granted</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="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_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="restore_in_progress">Restore is already in progress</string>
<string name="restoring_backup">Restoring backup</string> <string name="restoring_backup">Restoring backup</string>
@ -594,8 +595,6 @@
<!-- missing prompt after Compose rewrite #7901 --> <!-- missing prompt after Compose rewrite #7901 -->
<string name="no_more_results">No more results</string> <string name="no_more_results">No more results</string>
<string name="no_results_found">No results found</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="local_source">Local source</string>
<string name="other_source">Other</string> <string name="other_source">Other</string>
<string name="last_used_source">Last used</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_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="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> <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> </resources>

View file

@ -243,7 +243,6 @@ fun SelectItem(
label = { Text(text = label) }, label = { Text(text = label) },
value = options[selectedIndex].toString(), value = options[selectedIndex].toString(),
onValueChange = {}, onValueChange = {},
enabled = false,
readOnly = true, readOnly = true,
singleLine = true, singleLine = true,
trailingIcon = { trailingIcon = {
@ -251,9 +250,7 @@ fun SelectItem(
expanded = expanded, expanded = expanded,
) )
}, },
colors = ExposedDropdownMenuDefaults.textFieldColors( colors = ExposedDropdownMenuDefaults.textFieldColors(),
disabledTextColor = MaterialTheme.colorScheme.onSurface,
),
) )
ExposedDropdownMenu( ExposedDropdownMenu(

View file

@ -24,13 +24,44 @@ interface AnimeSource {
val lang: String val lang: String
get() = "" 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. * Returns an observable with the updated details for a anime.
* *
* @param anime the anime to update. * @param anime the anime to update.
*/ */
@Deprecated( @Deprecated(
"Use the 1.x API instead", "Use the non-RxJava API instead",
ReplaceWith("getAnimeDetails"), ReplaceWith("getAnimeDetails"),
) )
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException("Not used") fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException("Not used")
@ -41,7 +72,7 @@ interface AnimeSource {
* @param anime the anime to update. * @param anime the anime to update.
*/ */
@Deprecated( @Deprecated(
"Use the 1.x API instead", "Use the non-RxJava API instead",
ReplaceWith("getEpisodeList"), ReplaceWith("getEpisodeList"),
) )
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException("Not used") fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException("Not used")
@ -53,33 +84,8 @@ interface AnimeSource {
* @param episode the episode. * @param episode the episode.
*/ */
@Deprecated( @Deprecated(
"Use the 1.x API instead", "Use the non-RxJava API instead",
ReplaceWith("getVideoList"), ReplaceWith("getVideoList"),
) )
fun fetchVideoList(episode: SEpisode): Observable<List<Video>> = Observable.empty() 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 open val versionId = 1
/** /**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits) * 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 * of the MD5 of the string `"${name.lowercase()}/$lang/$versionId"`.
* Note the generated id sets the sign bit to 0. *
* 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 { override val id by lazy { generateId(name, lang, versionId) }
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
}
/** /**
* Headers used for requests. * Headers used for requests.
@ -71,6 +72,28 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
open val client: OkHttpClient open val client: OkHttpClient
get() = network.client 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. * Headers builder for requests. Implementations can override this method for custom headers.
*/ */
@ -222,7 +245,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
episodeListParse(response) episodeListParse(response)
} }
} else { } 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() 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