mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-21 12:17:12 +03:00
parent
fa7b8427a2
commit
2c4230376c
104 changed files with 1370 additions and 592 deletions
7
.github/workflows/issue_moderator.yml
vendored
7
.github/workflows/issue_moderator.yml
vendored
|
@ -27,6 +27,13 @@ jobs:
|
|||
"type": "body",
|
||||
"regex": ".*\\* (Aniyomi version|Android version|Device): \\?.*",
|
||||
"message": "Requested information in the template was not filled out."
|
||||
},
|
||||
{
|
||||
"type": "both",
|
||||
"regex": ".*(?:fail(?:ed|ure|s)?|can\\s*(?:no|')?t|(?:not|un).*able|(?<!n[o']?t )blocked by|error) (?:to )?(?:get past|by ?pass|penetrate)?.*cloud ?fl?are.*",
|
||||
"ignoreCase": true,
|
||||
"labels": ["Cloudflare protected"],
|
||||
"message": "Refer to the **Solving Cloudflare issues** section at https://aniyomi.org/help/guides/troubleshooting/#solving-cloudflare-issues. If it doesn't work, migrate to other sources or wait until they lower their protection."
|
||||
}
|
||||
]
|
||||
auto-close-ignore-label: do-not-autoclose
|
||||
|
|
|
@ -202,7 +202,7 @@ dependencies {
|
|||
implementation(androidx.bundles.workmanager)
|
||||
|
||||
// RxJava
|
||||
implementation(libs.bundles.reactivex)
|
||||
implementation(libs.rxjava)
|
||||
implementation(libs.flowreactivenetwork)
|
||||
|
||||
// Networking
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
}
|
||||
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keep class androidx.annotation.Keep
|
||||
|
|
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
|
@ -14,8 +14,8 @@
|
|||
-keep,allowoptimization class kotlin.time.** { public protected *; }
|
||||
-keep,allowoptimization class okhttp3.** { public protected *; }
|
||||
-keep,allowoptimization class okio.** { public protected *; }
|
||||
-keep,allowoptimization class rx.** { public protected *; }
|
||||
-keep,allowoptimization class org.jsoup.** { public protected *; }
|
||||
-keep,allowoptimization class rx.** { public protected *; }
|
||||
-keep,allowoptimization class app.cash.quickjs.** { public protected *; }
|
||||
-keep,allowoptimization class uy.kohesive.injekt.** { public protected *; }
|
||||
-keep,allowoptimization class is.xyz.mpv.** { public protected *; }
|
||||
|
|
|
@ -63,10 +63,10 @@
|
|||
|
||||
|
||||
<activity
|
||||
android:name=".ui.main.DeepLinkAnimeActivity"
|
||||
android:name=".ui.deeplink.anime.DeepLinkAnimeActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:label="@string/action_global_anime_search"
|
||||
android:label="@string/action_search"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
|
@ -90,10 +90,10 @@
|
|||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.main.DeepLinkMangaActivity"
|
||||
android:name=".ui.deeplink.manga.DeepLinkMangaActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:label="@string/action_global_manga_search"
|
||||
android:label="@string/action_search"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package eu.kanade.core.util
|
||||
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
|
@ -20,15 +19,6 @@ fun <T : R, R : Any> List<T>.insertSeparators(
|
|||
return newList
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new map containing only the key entries of [transform] that are not null.
|
||||
*/
|
||||
inline fun <K, V, R> Map<out K, V>.mapNotNullKeys(transform: (Map.Entry<K?, V>) -> R?): ConcurrentHashMap<R, V> {
|
||||
val mutableMap = ConcurrentHashMap<R, V>()
|
||||
forEach { element -> transform(element)?.let { mutableMap[it] = element.value } }
|
||||
return mutableMap
|
||||
}
|
||||
|
||||
fun <E> HashSet<E>.addOrRemove(value: E, shouldAdd: Boolean) {
|
||||
if (shouldAdd) {
|
||||
add(value)
|
||||
|
|
|
@ -234,7 +234,7 @@ class DomainModule : InjektModule {
|
|||
addFactory { UpdateEpisode(get()) }
|
||||
addFactory { SetSeenStatus(get(), get(), get(), get()) }
|
||||
addFactory { ShouldUpdateDbEpisode() }
|
||||
addFactory { SyncEpisodesWithSource(get(), get(), get(), get()) }
|
||||
addFactory { SyncEpisodesWithSource(get(), get(), get(), get(), get(), get(), get()) }
|
||||
addFactory { SyncEpisodesWithTrackServiceTwoWay(get(), get()) }
|
||||
|
||||
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
|
||||
|
@ -243,7 +243,7 @@ class DomainModule : InjektModule {
|
|||
addFactory { UpdateChapter(get()) }
|
||||
addFactory { SetReadStatus(get(), get(), get(), get()) }
|
||||
addFactory { ShouldUpdateDbChapter() }
|
||||
addFactory { SyncChaptersWithSource(get(), get(), get(), get()) }
|
||||
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) }
|
||||
addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) }
|
||||
|
||||
addSingletonFactory<AnimeHistoryRepository> { AnimeHistoryRepositoryImpl(get()) }
|
||||
|
|
|
@ -28,5 +28,6 @@ class BasePreferences(
|
|||
LEGACY(R.string.ext_installer_legacy),
|
||||
PACKAGEINSTALLER(R.string.ext_installer_packageinstaller),
|
||||
SHIZUKU(R.string.ext_installer_shizuku),
|
||||
PRIVATE(R.string.ext_installer_private),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ import tachiyomi.domain.items.chapter.model.toChapterUpdate
|
|||
import tachiyomi.domain.items.chapter.repository.ChapterRepository
|
||||
import tachiyomi.domain.items.chapter.service.ChapterRecognition
|
||||
import tachiyomi.source.local.entries.manga.isLocal
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.lang.Long.max
|
||||
import java.time.ZonedDateTime
|
||||
|
@ -28,13 +27,13 @@ import java.util.Date
|
|||
import java.util.TreeSet
|
||||
|
||||
class SyncChaptersWithSource(
|
||||
private val downloadManager: MangaDownloadManager = Injekt.get(),
|
||||
private val downloadProvider: MangaDownloadProvider = Injekt.get(),
|
||||
private val chapterRepository: ChapterRepository = Injekt.get(),
|
||||
private val shouldUpdateDbChapter: ShouldUpdateDbChapter = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(),
|
||||
private val downloadManager: MangaDownloadManager,
|
||||
private val downloadProvider: MangaDownloadProvider,
|
||||
private val chapterRepository: ChapterRepository,
|
||||
private val shouldUpdateDbChapter: ShouldUpdateDbChapter,
|
||||
private val updateManga: UpdateManga,
|
||||
private val updateChapter: UpdateChapter,
|
||||
private val getChapterByMangaId: GetChapterByMangaId,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
|
@ -9,12 +9,11 @@ import tachiyomi.domain.items.chapter.model.Chapter
|
|||
import tachiyomi.domain.items.chapter.model.toChapterUpdate
|
||||
import tachiyomi.domain.track.manga.interactor.InsertMangaTrack
|
||||
import tachiyomi.domain.track.manga.model.MangaTrack
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SyncChaptersWithTrackServiceTwoWay(
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val insertTrack: InsertMangaTrack = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter,
|
||||
private val insertTrack: InsertMangaTrack,
|
||||
) {
|
||||
|
||||
suspend fun await(
|
||||
|
|
|
@ -20,7 +20,6 @@ import tachiyomi.domain.items.episode.model.toEpisodeUpdate
|
|||
import tachiyomi.domain.items.episode.repository.EpisodeRepository
|
||||
import tachiyomi.domain.items.episode.service.EpisodeRecognition
|
||||
import tachiyomi.source.local.entries.anime.isLocal
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.lang.Long.max
|
||||
import java.time.ZonedDateTime
|
||||
|
@ -28,13 +27,13 @@ import java.util.Date
|
|||
import java.util.TreeSet
|
||||
|
||||
class SyncEpisodesWithSource(
|
||||
private val downloadManager: AnimeDownloadManager = Injekt.get(),
|
||||
private val downloadProvider: AnimeDownloadProvider = Injekt.get(),
|
||||
private val episodeRepository: EpisodeRepository = Injekt.get(),
|
||||
private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode = Injekt.get(),
|
||||
private val updateAnime: UpdateAnime = Injekt.get(),
|
||||
private val updateEpisode: UpdateEpisode = Injekt.get(),
|
||||
private val getEpisodeByAnimeId: GetEpisodeByAnimeId = Injekt.get(),
|
||||
private val downloadManager: AnimeDownloadManager,
|
||||
private val downloadProvider: AnimeDownloadProvider,
|
||||
private val episodeRepository: EpisodeRepository,
|
||||
private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode,
|
||||
private val updateAnime: UpdateAnime,
|
||||
private val updateEpisode: UpdateEpisode,
|
||||
private val getEpisodeByAnimeId: GetEpisodeByAnimeId,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
|
@ -9,12 +9,11 @@ import tachiyomi.domain.items.episode.model.Episode
|
|||
import tachiyomi.domain.items.episode.model.toEpisodeUpdate
|
||||
import tachiyomi.domain.track.anime.interactor.InsertAnimeTrack
|
||||
import tachiyomi.domain.track.anime.model.AnimeTrack
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SyncEpisodesWithTrackServiceTwoWay(
|
||||
private val updateEpisode: UpdateEpisode = Injekt.get(),
|
||||
private val insertTrack: InsertAnimeTrack = Injekt.get(),
|
||||
private val updateEpisode: UpdateEpisode,
|
||||
private val insertTrack: InsertAnimeTrack,
|
||||
) {
|
||||
|
||||
suspend fun await(
|
||||
|
|
|
@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
|
@ -175,7 +173,8 @@ private fun AnimeExtensionDetails(
|
|||
data = Uri.fromParts("package", extension.pkgName, null)
|
||||
context.startActivity(this)
|
||||
}
|
||||
},
|
||||
Unit
|
||||
}.takeIf { extension.isShared },
|
||||
onClickAgeRating = {
|
||||
showNsfwWarning = true
|
||||
},
|
||||
|
@ -208,7 +207,7 @@ private fun DetailsHeader(
|
|||
extension: AnimeExtension,
|
||||
onClickAgeRating: () -> Unit,
|
||||
onClickUninstall: () -> Unit,
|
||||
onClickAppInfo: () -> Unit,
|
||||
onClickAppInfo: (() -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
@ -292,6 +291,7 @@ private fun DetailsHeader(
|
|||
top = MaterialTheme.padding.small,
|
||||
bottom = MaterialTheme.padding.medium,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
|
@ -300,16 +300,16 @@ private fun DetailsHeader(
|
|||
Text(stringResource(R.string.ext_uninstall))
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
if (onClickAppInfo != null) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -75,7 +75,7 @@ fun AnimeExtensionScreen(
|
|||
enabled = !state.isLoading,
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.isEmpty -> {
|
||||
val msg = if (!searchQuery.isNullOrEmpty()) {
|
||||
R.string.no_results_found
|
||||
|
|
|
@ -47,7 +47,7 @@ fun AnimeSourcesScreen(
|
|||
onLongClickItem: (AnimeSource) -> Unit,
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.isEmpty -> EmptyScreen(
|
||||
textResource = R.string.source_empty_screen,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
|
|
|
@ -51,7 +51,7 @@ fun MigrateAnimeSourceScreen(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.isEmpty -> EmptyScreen(
|
||||
textResource = R.string.information_empty_library,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package eu.kanade.presentation.browse.anime.components
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
@ -31,6 +30,7 @@ import eu.kanade.domain.source.anime.model.icon
|
|||
import eu.kanade.presentation.util.rememberResourceBitmapPainter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.domain.source.anime.model.AnimeSource
|
||||
import tachiyomi.source.local.entries.anime.LocalAnimeSource
|
||||
|
@ -127,7 +127,7 @@ private fun AnimeExtension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT
|
|||
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
|
||||
withIOContext {
|
||||
value = try {
|
||||
val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
val appInfo = AnimeExtensionLoader.getAnimeExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
|
||||
val appResources = context.packageManager.getResourcesForApplication(appInfo)
|
||||
Result.Success(
|
||||
appResources.getDrawableForDensity(appInfo.icon, density, null)!!
|
||||
|
|
|
@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
|
@ -176,7 +174,8 @@ private fun ExtensionDetails(
|
|||
data = Uri.fromParts("package", extension.pkgName, null)
|
||||
context.startActivity(this)
|
||||
}
|
||||
},
|
||||
Unit
|
||||
}.takeIf { extension.isShared },
|
||||
onClickAgeRating = {
|
||||
showNsfwWarning = true
|
||||
},
|
||||
|
@ -209,7 +208,7 @@ private fun DetailsHeader(
|
|||
extension: MangaExtension,
|
||||
onClickAgeRating: () -> Unit,
|
||||
onClickUninstall: () -> Unit,
|
||||
onClickAppInfo: () -> Unit,
|
||||
onClickAppInfo: (() -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
@ -293,6 +292,7 @@ private fun DetailsHeader(
|
|||
top = MaterialTheme.padding.small,
|
||||
bottom = MaterialTheme.padding.medium,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
|
@ -301,16 +301,16 @@ private fun DetailsHeader(
|
|||
Text(stringResource(R.string.ext_uninstall))
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
if (onClickAppInfo != null) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -76,7 +76,7 @@ fun MangaExtensionScreen(
|
|||
enabled = !state.isLoading,
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.isEmpty -> {
|
||||
val msg = if (!searchQuery.isNullOrEmpty()) {
|
||||
R.string.no_results_found
|
||||
|
|
|
@ -47,7 +47,7 @@ fun MangaSourcesScreen(
|
|||
onLongClickItem: (Source) -> Unit,
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.isEmpty -> EmptyScreen(
|
||||
textResource = R.string.source_empty_screen,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
|
|
|
@ -51,7 +51,7 @@ fun MigrateMangaSourceScreen(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.isEmpty -> EmptyScreen(
|
||||
textResource = R.string.information_empty_library,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package eu.kanade.presentation.browse.manga.components
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
@ -31,6 +30,7 @@ import eu.kanade.domain.source.manga.model.icon
|
|||
import eu.kanade.presentation.util.rememberResourceBitmapPainter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionLoader
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.domain.source.manga.model.Source
|
||||
import tachiyomi.source.local.entries.manga.LocalMangaSource
|
||||
|
@ -127,7 +127,7 @@ private fun MangaExtension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT
|
|||
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
|
||||
withIOContext {
|
||||
value = try {
|
||||
val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
val appInfo = MangaExtensionLoader.getMangaExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
|
||||
val appResources = context.packageManager.getResourcesForApplication(appInfo)
|
||||
Result.Success(
|
||||
appResources.getDrawableForDensity(appInfo.icon, density, null)!!
|
||||
|
|
|
@ -30,7 +30,7 @@ fun AnimeHistoryScreen(
|
|||
) { _ ->
|
||||
state.list.let {
|
||||
if (it == null) {
|
||||
LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
LoadingScreen(Modifier.padding(contentPadding))
|
||||
} else if (it.isEmpty()) {
|
||||
val msg = if (!searchQuery.isNullOrEmpty()) {
|
||||
R.string.no_results_found
|
||||
|
|
|
@ -29,7 +29,7 @@ fun MangaHistoryScreen(
|
|||
) { _ ->
|
||||
state.list.let {
|
||||
if (it == null) {
|
||||
LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
LoadingScreen(Modifier.padding(contentPadding))
|
||||
} else if (it.isEmpty()) {
|
||||
val msg = if (!searchQuery.isNullOrEmpty()) {
|
||||
R.string.no_results_found
|
||||
|
|
|
@ -181,7 +181,7 @@ private val displayModes = listOf(
|
|||
private fun ColumnScope.DisplayPage(
|
||||
screenModel: AnimeLibrarySettingsScreenModel,
|
||||
) {
|
||||
val displayMode by screenModel.libraryPreferences.libraryDisplayMode().collectAsState()
|
||||
val displayMode by screenModel.libraryPreferences.displayMode().collectAsState()
|
||||
SettingsChipRow(R.string.action_display_mode) {
|
||||
displayModes.map { (titleRes, mode) ->
|
||||
FilterChip(
|
||||
|
|
|
@ -180,7 +180,7 @@ private val displayModes = listOf(
|
|||
private fun ColumnScope.DisplayPage(
|
||||
screenModel: MangaLibrarySettingsScreenModel,
|
||||
) {
|
||||
val displayMode by screenModel.libraryPreferences.libraryDisplayMode().collectAsState()
|
||||
val displayMode by screenModel.libraryPreferences.displayMode().collectAsState()
|
||||
SettingsChipRow(R.string.action_display_mode) {
|
||||
displayModes.map { (titleRes, mode) ->
|
||||
FilterChip(
|
||||
|
|
|
@ -33,6 +33,9 @@ import tachiyomi.domain.category.manga.interactor.GetMangaCategories
|
|||
import tachiyomi.domain.category.manga.interactor.ResetMangaCategoryFlags
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY_ON_WIFI
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_HAS_UNVIEWED
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_COMPLETED
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_VIEWED
|
||||
|
@ -163,15 +166,15 @@ object SettingsLibraryScreen : SearchableSettings {
|
|||
): Preference.PreferenceGroup {
|
||||
val context = LocalContext.current
|
||||
|
||||
val libraryUpdateIntervalPref = libraryPreferences.libraryUpdateInterval()
|
||||
val libraryUpdateInterval by libraryUpdateIntervalPref.collectAsState()
|
||||
val autoUpdateIntervalPref = libraryPreferences.autoUpdateInterval()
|
||||
val autoUpdateInterval by autoUpdateIntervalPref.collectAsState()
|
||||
|
||||
val animelibUpdateCategoriesPref = libraryPreferences.animeLibraryUpdateCategories()
|
||||
val animelibUpdateCategoriesExcludePref =
|
||||
libraryPreferences.animeLibraryUpdateCategoriesExclude()
|
||||
val animeAutoUpdateCategoriesPref = libraryPreferences.animeUpdateCategories()
|
||||
val animeAutoUpdateCategoriesExcludePref =
|
||||
libraryPreferences.animeUpdateCategoriesExclude()
|
||||
|
||||
val includedAnime by animelibUpdateCategoriesPref.collectAsState()
|
||||
val excludedAnime by animelibUpdateCategoriesExcludePref.collectAsState()
|
||||
val includedAnime by animeAutoUpdateCategoriesPref.collectAsState()
|
||||
val excludedAnime by animeAutoUpdateCategoriesExcludePref.collectAsState()
|
||||
var showAnimeCategoriesDialog by rememberSaveable { mutableStateOf(false) }
|
||||
if (showAnimeCategoriesDialog) {
|
||||
TriStateListDialog(
|
||||
|
@ -183,8 +186,8 @@ object SettingsLibraryScreen : SearchableSettings {
|
|||
itemLabel = { it.visualName },
|
||||
onDismissRequest = { showAnimeCategoriesDialog = false },
|
||||
onValueChanged = { newIncluded, newExcluded ->
|
||||
animelibUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
|
||||
animelibUpdateCategoriesExcludePref.set(
|
||||
animeAutoUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
|
||||
animeAutoUpdateCategoriesExcludePref.set(
|
||||
newExcluded.map { it.id.toString() }
|
||||
.toSet(),
|
||||
)
|
||||
|
@ -193,12 +196,12 @@ object SettingsLibraryScreen : SearchableSettings {
|
|||
)
|
||||
}
|
||||
|
||||
val libraryUpdateCategoriesPref = libraryPreferences.mangaLibraryUpdateCategories()
|
||||
val libraryUpdateCategoriesExcludePref =
|
||||
libraryPreferences.mangaLibraryUpdateCategoriesExclude()
|
||||
val autoUpdateCategoriesPref = libraryPreferences.mangaUpdateCategories()
|
||||
val autoUpdateCategoriesExcludePref =
|
||||
libraryPreferences.mangaUpdateCategoriesExclude()
|
||||
|
||||
val includedManga by libraryUpdateCategoriesPref.collectAsState()
|
||||
val excludedManga by libraryUpdateCategoriesExcludePref.collectAsState()
|
||||
val includedManga by autoUpdateCategoriesPref.collectAsState()
|
||||
val excludedManga by autoUpdateCategoriesExcludePref.collectAsState()
|
||||
var showMangaCategoriesDialog by rememberSaveable { mutableStateOf(false) }
|
||||
if (showMangaCategoriesDialog) {
|
||||
TriStateListDialog(
|
||||
|
@ -210,8 +213,8 @@ object SettingsLibraryScreen : SearchableSettings {
|
|||
itemLabel = { it.visualName },
|
||||
onDismissRequest = { showMangaCategoriesDialog = false },
|
||||
onValueChanged = { newIncluded, newExcluded ->
|
||||
libraryUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
|
||||
libraryUpdateCategoriesExcludePref.set(
|
||||
autoUpdateCategoriesPref.set(newIncluded.map { it.id.toString() }.toSet())
|
||||
autoUpdateCategoriesExcludePref.set(
|
||||
newExcluded.map { it.id.toString() }
|
||||
.toSet(),
|
||||
)
|
||||
|
@ -224,7 +227,7 @@ object SettingsLibraryScreen : SearchableSettings {
|
|||
title = stringResource(R.string.pref_category_library_update),
|
||||
preferenceItems = listOf(
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
pref = libraryUpdateIntervalPref,
|
||||
pref = autoUpdateIntervalPref,
|
||||
title = stringResource(R.string.pref_library_update_interval),
|
||||
entries = mapOf(
|
||||
0 to stringResource(R.string.update_never),
|
||||
|
@ -241,15 +244,14 @@ object SettingsLibraryScreen : SearchableSettings {
|
|||
},
|
||||
),
|
||||
Preference.PreferenceItem.MultiSelectListPreference(
|
||||
pref = libraryPreferences.libraryUpdateDeviceRestriction(),
|
||||
enabled = libraryUpdateInterval > 0,
|
||||
pref = libraryPreferences.autoUpdateDeviceRestrictions(),
|
||||
enabled = autoUpdateInterval > 0,
|
||||
title = stringResource(R.string.pref_library_update_restriction),
|
||||
subtitle = stringResource(R.string.restrictions),
|
||||
entries = mapOf(
|
||||
ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read),
|
||||
ENTRY_NON_VIEWED to stringResource(R.string.pref_update_only_started),
|
||||
ENTRY_NON_COMPLETED to stringResource(R.string.pref_update_only_non_completed),
|
||||
ENTRY_OUTSIDE_RELEASE_PERIOD to stringResource(R.string.pref_update_only_in_release_period),
|
||||
DEVICE_ONLY_ON_WIFI to stringResource(R.string.connected_to_wifi),
|
||||
DEVICE_NETWORK_NOT_METERED to stringResource(R.string.network_not_metered),
|
||||
DEVICE_CHARGING to stringResource(R.string.charging),
|
||||
),
|
||||
onValueChanged = {
|
||||
// Post to event looper to allow the preference to be updated.
|
||||
|
@ -290,7 +292,7 @@ object SettingsLibraryScreen : SearchableSettings {
|
|||
subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary),
|
||||
),
|
||||
Preference.PreferenceItem.MultiSelectListPreference(
|
||||
pref = libraryPreferences.libraryUpdateItemRestriction(),
|
||||
pref = libraryPreferences.autoUpdateItemRestrictions(),
|
||||
title = stringResource(R.string.pref_library_update_manga_restriction),
|
||||
entries = mapOf(
|
||||
ENTRY_HAS_UNVIEWED to stringResource(R.string.pref_update_only_completely_read),
|
||||
|
|
|
@ -69,7 +69,7 @@ fun AnimeUpdateScreen(
|
|||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.items.isEmpty() -> EmptyScreen(
|
||||
textResource = R.string.information_no_recent,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
|
|
|
@ -65,7 +65,7 @@ fun MangaUpdateScreen(
|
|||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.items.isEmpty() -> EmptyScreen(
|
||||
textResource = R.string.information_no_recent,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
|
|
|
@ -2,19 +2,30 @@ package eu.kanade.presentation.util
|
|||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.animesource.online.LicensedEntryItemsException
|
||||
import eu.kanade.tachiyomi.network.HttpException
|
||||
import eu.kanade.tachiyomi.util.system.isOnline
|
||||
import tachiyomi.domain.items.chapter.model.NoChaptersException
|
||||
import tachiyomi.domain.items.episode.model.NoEpisodesException
|
||||
import tachiyomi.domain.source.anime.model.AnimeSourceNotInstalledException
|
||||
import tachiyomi.domain.source.manga.model.SourceNotInstalledException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
context(Context)
|
||||
val Throwable.formattedMessage: String
|
||||
get() {
|
||||
when (this) {
|
||||
is HttpException -> return getString(R.string.exception_http, code)
|
||||
is UnknownHostException -> {
|
||||
return if (!isOnline()) {
|
||||
getString(R.string.exception_offline)
|
||||
} else {
|
||||
getString(R.string.exception_unknown_host, message)
|
||||
}
|
||||
}
|
||||
is NoChaptersException, is NoEpisodesException -> return getString(R.string.no_results_found)
|
||||
is SourceNotInstalledException, is AnimeSourceNotInstalledException -> return getString(R.string.loader_not_implemented_error)
|
||||
is HttpException -> return "$message: ${getString(R.string.http_error_hint)}"
|
||||
is LicensedEntryItemsException -> return getString(R.string.licensed_manga_chapters_error)
|
||||
}
|
||||
return when (val className = this::class.simpleName) {
|
||||
"Exception", "IOException" -> message ?: className
|
||||
|
|
|
@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.util.system.toast
|
|||
import eu.kanade.tachiyomi.util.system.workManager
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.preference.TriState
|
||||
import tachiyomi.core.preference.getAndSet
|
||||
import tachiyomi.core.preference.getEnum
|
||||
import tachiyomi.core.preference.minusAssign
|
||||
import tachiyomi.core.preference.plusAssign
|
||||
|
@ -107,19 +108,19 @@ object Migrations {
|
|||
}
|
||||
if (oldVersion < 44) {
|
||||
// Reset sorting preference if using removed sort by source
|
||||
val oldMangaSortingMode = prefs.getInt(libraryPreferences.libraryMangaSortingMode().key(), 0)
|
||||
val oldMangaSortingMode = prefs.getInt(libraryPreferences.mangaSortingMode().key(), 0)
|
||||
|
||||
if (oldMangaSortingMode == 5) { // SOURCE = 5
|
||||
prefs.edit {
|
||||
putInt(libraryPreferences.libraryMangaSortingMode().key(), 0) // ALPHABETICAL = 0
|
||||
putInt(libraryPreferences.mangaSortingMode().key(), 0) // ALPHABETICAL = 0
|
||||
}
|
||||
}
|
||||
|
||||
val oldAnimeSortingMode = prefs.getInt(libraryPreferences.libraryAnimeSortingMode().key(), 0)
|
||||
val oldAnimeSortingMode = prefs.getInt(libraryPreferences.animeSortingMode().key(), 0)
|
||||
|
||||
if (oldAnimeSortingMode == 5) { // SOURCE = 5
|
||||
prefs.edit {
|
||||
putInt(libraryPreferences.libraryAnimeSortingMode().key(), 0) // ALPHABETICAL = 0
|
||||
putInt(libraryPreferences.animeSortingMode().key(), 0) // ALPHABETICAL = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -194,9 +195,9 @@ object Migrations {
|
|||
}
|
||||
if (oldVersion < 61) {
|
||||
// Handle removed every 1 or 2 hour library updates
|
||||
val updateInterval = libraryPreferences.libraryUpdateInterval().get()
|
||||
val updateInterval = libraryPreferences.autoUpdateInterval().get()
|
||||
if (updateInterval == 1 || updateInterval == 2) {
|
||||
libraryPreferences.libraryUpdateInterval().set(3)
|
||||
libraryPreferences.autoUpdateInterval().set(3)
|
||||
MangaLibraryUpdateJob.setupTask(context, 3)
|
||||
AnimeLibraryUpdateJob.setupTask(context, 3)
|
||||
}
|
||||
|
@ -207,8 +208,8 @@ object Migrations {
|
|||
AnimeLibraryUpdateJob.setupTask(context)
|
||||
}
|
||||
if (oldVersion < 64) {
|
||||
val oldMangaSortingMode = prefs.getInt(libraryPreferences.libraryMangaSortingMode().key(), 0)
|
||||
val oldAnimeSortingMode = prefs.getInt(libraryPreferences.libraryAnimeSortingMode().key(), 0)
|
||||
val oldMangaSortingMode = prefs.getInt(libraryPreferences.mangaSortingMode().key(), 0)
|
||||
val oldAnimeSortingMode = prefs.getInt(libraryPreferences.animeSortingMode().key(), 0)
|
||||
val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true)
|
||||
|
||||
val newMangaSortingMode = when (oldMangaSortingMode) {
|
||||
|
@ -241,14 +242,14 @@ object Migrations {
|
|||
}
|
||||
|
||||
prefs.edit(commit = true) {
|
||||
remove(libraryPreferences.libraryMangaSortingMode().key())
|
||||
remove(libraryPreferences.libraryAnimeSortingMode().key())
|
||||
remove(libraryPreferences.mangaSortingMode().key())
|
||||
remove(libraryPreferences.animeSortingMode().key())
|
||||
remove("library_sorting_ascending")
|
||||
}
|
||||
|
||||
prefs.edit {
|
||||
putString(libraryPreferences.libraryMangaSortingMode().key(), newMangaSortingMode)
|
||||
putString(libraryPreferences.libraryAnimeSortingMode().key(), newAnimeSortingMode)
|
||||
putString(libraryPreferences.mangaSortingMode().key(), newMangaSortingMode)
|
||||
putString(libraryPreferences.animeSortingMode().key(), newAnimeSortingMode)
|
||||
putString("library_sorting_ascending", newSortingDirection)
|
||||
}
|
||||
}
|
||||
|
@ -259,9 +260,9 @@ object Migrations {
|
|||
}
|
||||
if (oldVersion < 71) {
|
||||
// Handle removed every 3, 4, 6, and 8 hour library updates
|
||||
val updateInterval = libraryPreferences.libraryUpdateInterval().get()
|
||||
val updateInterval = libraryPreferences.autoUpdateInterval().get()
|
||||
if (updateInterval in listOf(3, 4, 6, 8)) {
|
||||
libraryPreferences.libraryUpdateInterval().set(12)
|
||||
libraryPreferences.autoUpdateInterval().set(12)
|
||||
MangaLibraryUpdateJob.setupTask(context, 12)
|
||||
AnimeLibraryUpdateJob.setupTask(context, 12)
|
||||
}
|
||||
|
@ -269,7 +270,7 @@ object Migrations {
|
|||
if (oldVersion < 72) {
|
||||
val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true)
|
||||
if (!oldUpdateOngoingOnly) {
|
||||
libraryPreferences.libraryUpdateItemRestriction() -= ENTRY_NON_COMPLETED
|
||||
libraryPreferences.autoUpdateItemRestrictions() -= ENTRY_NON_COMPLETED
|
||||
}
|
||||
}
|
||||
if (oldVersion < 75) {
|
||||
|
@ -294,29 +295,29 @@ object Migrations {
|
|||
if (oldVersion < 81) {
|
||||
// Handle renamed enum values
|
||||
prefs.edit {
|
||||
val newMangaSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.libraryMangaSortingMode().key(), "ALPHABETICAL")) {
|
||||
val newMangaSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.mangaSortingMode().key(), "ALPHABETICAL")) {
|
||||
"LAST_CHECKED" -> "LAST_MANGA_UPDATE"
|
||||
"UNREAD" -> "UNREAD_COUNT"
|
||||
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
|
||||
else -> oldSortingMode
|
||||
}
|
||||
val newAnimeSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.libraryAnimeSortingMode().key(), "ALPHABETICAL")) {
|
||||
val newAnimeSortingMode = when (val oldSortingMode = prefs.getString(libraryPreferences.animeSortingMode().key(), "ALPHABETICAL")) {
|
||||
"LAST_CHECKED" -> "LAST_MANGA_UPDATE"
|
||||
"UNREAD" -> "UNREAD_COUNT"
|
||||
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
|
||||
else -> oldSortingMode
|
||||
}
|
||||
putString(libraryPreferences.libraryMangaSortingMode().key(), newMangaSortingMode)
|
||||
putString(libraryPreferences.libraryAnimeSortingMode().key(), newAnimeSortingMode)
|
||||
putString(libraryPreferences.mangaSortingMode().key(), newMangaSortingMode)
|
||||
putString(libraryPreferences.animeSortingMode().key(), newAnimeSortingMode)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 82) {
|
||||
prefs.edit {
|
||||
val mangasort = prefs.getString(libraryPreferences.libraryMangaSortingMode().key(), null) ?: return@edit
|
||||
val animesort = prefs.getString(libraryPreferences.libraryAnimeSortingMode().key(), null) ?: return@edit
|
||||
val mangasort = prefs.getString(libraryPreferences.mangaSortingMode().key(), null) ?: return@edit
|
||||
val animesort = prefs.getString(libraryPreferences.animeSortingMode().key(), null) ?: return@edit
|
||||
val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!!
|
||||
putString(libraryPreferences.libraryMangaSortingMode().key(), "$mangasort,$direction")
|
||||
putString(libraryPreferences.libraryAnimeSortingMode().key(), "$animesort,$direction")
|
||||
putString(libraryPreferences.mangaSortingMode().key(), "$mangasort,$direction")
|
||||
putString(libraryPreferences.animeSortingMode().key(), "$animesort,$direction")
|
||||
remove("library_sorting_ascending")
|
||||
}
|
||||
}
|
||||
|
@ -452,6 +453,12 @@ object Migrations {
|
|||
readerPreferences.longStripSplitWebtoon().set(false)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 105) {
|
||||
val pref = libraryPreferences.autoUpdateDeviceRestrictions()
|
||||
if (pref.isSet() && "battery_not_low" in pref.get()) {
|
||||
pref.getAndSet { it - "battery_not_low" }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
|
@ -78,6 +79,10 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
|
|||
s.toInt(16)
|
||||
}
|
||||
if (interval > 0) {
|
||||
val constraints = Constraints(
|
||||
requiresBatteryNotLow = true,
|
||||
)
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<BackupCreateJob>(
|
||||
interval.toLong(),
|
||||
TimeUnit.HOURS,
|
||||
|
@ -86,6 +91,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
|
|||
)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10.minutes.toJavaDuration())
|
||||
.addTag(TAG_AUTO)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(
|
||||
workDataOf(
|
||||
IS_AUTO_BACKUP_KEY to true,
|
||||
|
|
|
@ -162,10 +162,10 @@ class BackupManager(
|
|||
UniFile.fromUri(context, uri)
|
||||
}
|
||||
)
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
?: throw Exception(context.getString(R.string.create_backup_file_error))
|
||||
|
||||
if (!file.isFile) {
|
||||
throw IllegalStateException("Failed to get handle on file")
|
||||
throw IllegalStateException("Failed to get handle on a backup file")
|
||||
}
|
||||
|
||||
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
|
||||
|
|
|
@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.download.anime
|
|||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.core.util.mapNotNullKeys
|
||||
import eu.kanade.tachiyomi.animesource.AnimeSource
|
||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||
import eu.kanade.tachiyomi.util.size
|
||||
|
@ -334,21 +333,23 @@ class AnimeDownloadCache(
|
|||
}
|
||||
}
|
||||
|
||||
val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty()
|
||||
.associate { it.name to SourceDirectory(it) }
|
||||
.mapNotNullKeys { entry ->
|
||||
sources.find {
|
||||
provider.getSourceDirName(it).equals(entry.key, ignoreCase = true)
|
||||
}?.id
|
||||
}
|
||||
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
|
||||
|
||||
rootDownloadsDir.sourceDirs = sourceDirs
|
||||
val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty()
|
||||
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
||||
.mapNotNull { dir ->
|
||||
val sourceId = sourceMap[dir.name!!.lowercase()]
|
||||
sourceId?.let { it to SourceDirectory(dir) }
|
||||
}
|
||||
.toMap()
|
||||
|
||||
rootDownloadsDir.sourceDirs = sourceDirs as ConcurrentHashMap<Long, SourceDirectory>
|
||||
|
||||
sourceDirs.values
|
||||
.map { sourceDir ->
|
||||
async {
|
||||
val animeDirs = sourceDir.dir.listFiles().orEmpty()
|
||||
.filterNot { it.name.isNullOrBlank() }
|
||||
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
||||
.associate { it.name!! to AnimeDirectory(it) }
|
||||
|
||||
sourceDir.animeDirs = ConcurrentHashMap(animeDirs)
|
||||
|
|
|
@ -5,7 +5,6 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.core.util.mapNotNullKeys
|
||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||
import eu.kanade.tachiyomi.source.MangaSource
|
||||
import eu.kanade.tachiyomi.util.size
|
||||
|
@ -361,14 +360,16 @@ class MangaDownloadCache(
|
|||
}
|
||||
}
|
||||
|
||||
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
|
||||
|
||||
rootDownloadsDirLock.withLock {
|
||||
val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty()
|
||||
.associate { it.name to SourceDirectory(it) }
|
||||
.mapNotNullKeys { entry ->
|
||||
sources.find {
|
||||
provider.getSourceDirName(it).equals(entry.key, ignoreCase = true)
|
||||
}?.id
|
||||
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
||||
.mapNotNull { dir ->
|
||||
val sourceId = sourceMap[dir.name!!.lowercase()]
|
||||
sourceId?.let { it to SourceDirectory(dir) }
|
||||
}
|
||||
.toMap()
|
||||
|
||||
rootDownloadsDir.sourceDirs = sourceDirs
|
||||
|
||||
|
@ -376,7 +377,7 @@ class MangaDownloadCache(
|
|||
.map { sourceDir ->
|
||||
async {
|
||||
val mangaDirs = sourceDir.dir.listFiles().orEmpty()
|
||||
.filterNot { it.name.isNullOrBlank() }
|
||||
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
||||
.associate { it.name!! to MangaDirectory(it) }
|
||||
|
||||
sourceDir.mangaDirs = ConcurrentHashMap(mangaDirs)
|
||||
|
|
|
@ -64,7 +64,6 @@ import tachiyomi.domain.items.episode.model.Episode
|
|||
import tachiyomi.domain.items.episode.model.NoEpisodesException
|
||||
import tachiyomi.domain.library.anime.LibraryAnime
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_BATTERY_NOT_LOW
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY_ON_WIFI
|
||||
|
@ -113,7 +112,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
override suspend fun doWork(): Result {
|
||||
if (tags.contains(WORK_NAME_AUTO)) {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
|
||||
val restrictions = preferences.autoUpdateDeviceRestrictions().get()
|
||||
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
|
||||
return Result.failure()
|
||||
}
|
||||
|
@ -134,7 +133,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
|
||||
// If this is a chapter update, set the last update time to now
|
||||
if (target == Target.EPISODES) {
|
||||
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time)
|
||||
libraryPreferences.lastUpdatedTimestamp().set(Date().time)
|
||||
}
|
||||
|
||||
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
|
||||
|
@ -181,14 +180,14 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
val listToUpdate = if (categoryId != -1L) {
|
||||
libraryAnime.filter { it.category == categoryId }
|
||||
} else {
|
||||
val categoriesToUpdate = libraryPreferences.animeLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val categoriesToUpdate = libraryPreferences.animeUpdateCategories().get().map { it.toLong() }
|
||||
val includedAnime = if (categoriesToUpdate.isNotEmpty()) {
|
||||
libraryAnime.filter { it.category in categoriesToUpdate }
|
||||
} else {
|
||||
libraryAnime
|
||||
}
|
||||
|
||||
val categoriesToExclude = libraryPreferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val categoriesToExclude = libraryPreferences.animeUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedAnimeIds = if (categoriesToExclude.isNotEmpty()) {
|
||||
libraryAnime.filter { it.category in categoriesToExclude }.map { it.anime.id }
|
||||
} else {
|
||||
|
@ -229,7 +228,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
val skippedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
|
||||
val hasDownloads = AtomicBoolean(false)
|
||||
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
|
||||
val fetchWindow = setAnimeFetchInterval.getWindow(ZonedDateTime.now())
|
||||
|
||||
coroutineScope {
|
||||
|
@ -558,13 +557,13 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
prefInterval: Int? = null,
|
||||
) {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val interval = prefInterval ?: preferences.libraryUpdateInterval().get()
|
||||
val interval = prefInterval ?: preferences.autoUpdateInterval().get()
|
||||
if (interval > 0) {
|
||||
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
|
||||
val restrictions = preferences.autoUpdateDeviceRestrictions().get()
|
||||
val constraints = Constraints(
|
||||
requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED },
|
||||
requiresCharging = DEVICE_CHARGING in restrictions,
|
||||
requiresBatteryNotLow = DEVICE_BATTERY_NOT_LOW in restrictions,
|
||||
requiresBatteryNotLow = true,
|
||||
)
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<AnimeLibraryUpdateJob>(
|
||||
|
|
|
@ -64,7 +64,6 @@ import tachiyomi.domain.items.chapter.model.Chapter
|
|||
import tachiyomi.domain.items.chapter.model.NoChaptersException
|
||||
import tachiyomi.domain.library.manga.LibraryManga
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_BATTERY_NOT_LOW
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED
|
||||
import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY_ON_WIFI
|
||||
|
@ -113,7 +112,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
override suspend fun doWork(): Result {
|
||||
if (tags.contains(WORK_NAME_AUTO)) {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
|
||||
val restrictions = preferences.autoUpdateDeviceRestrictions().get()
|
||||
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
|
||||
return Result.retry()
|
||||
}
|
||||
|
@ -134,7 +133,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
|
||||
// If this is a chapter update, set the last update time to now
|
||||
if (target == Target.CHAPTERS) {
|
||||
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time)
|
||||
libraryPreferences.lastUpdatedTimestamp().set(Date().time)
|
||||
}
|
||||
|
||||
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
|
||||
|
@ -181,14 +180,14 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
val listToUpdate = if (categoryId != -1L) {
|
||||
libraryManga.filter { it.category == categoryId }
|
||||
} else {
|
||||
val categoriesToUpdate = libraryPreferences.mangaLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val categoriesToUpdate = libraryPreferences.mangaUpdateCategories().get().map { it.toLong() }
|
||||
val includedManga = if (categoriesToUpdate.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToUpdate }
|
||||
} else {
|
||||
libraryManga
|
||||
}
|
||||
|
||||
val categoriesToExclude = libraryPreferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val categoriesToExclude = libraryPreferences.mangaUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id }
|
||||
} else {
|
||||
|
@ -229,7 +228,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
val hasDownloads = AtomicBoolean(false)
|
||||
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
|
||||
val fetchWindow = setMangaFetchInterval.getWindow(ZonedDateTime.now())
|
||||
|
||||
coroutineScope {
|
||||
|
@ -557,13 +556,13 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
prefInterval: Int? = null,
|
||||
) {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val interval = prefInterval ?: preferences.libraryUpdateInterval().get()
|
||||
val interval = prefInterval ?: preferences.autoUpdateInterval().get()
|
||||
if (interval > 0) {
|
||||
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
|
||||
val restrictions = preferences.autoUpdateDeviceRestrictions().get()
|
||||
val constraints = Constraints(
|
||||
requiredNetworkType = if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED },
|
||||
requiresCharging = DEVICE_CHARGING in restrictions,
|
||||
requiresBatteryNotLow = DEVICE_BATTERY_NOT_LOW in restrictions,
|
||||
requiresBatteryNotLow = true,
|
||||
)
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<MangaLibraryUpdateJob>(
|
||||
|
|
|
@ -17,9 +17,12 @@ import tachiyomi.domain.items.episode.interactor.GetEpisodeByAnimeId
|
|||
import tachiyomi.domain.track.anime.interactor.InsertAnimeTrack
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.time.ZoneOffset
|
||||
import tachiyomi.domain.track.anime.model.AnimeTrack as DomainAnimeTrack
|
||||
|
||||
private val insertTrack: InsertAnimeTrack by injectLazy()
|
||||
|
||||
interface AnimeTrackService {
|
||||
|
||||
// Common functions
|
||||
|
@ -63,7 +66,7 @@ interface AnimeTrackService {
|
|||
|
||||
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext
|
||||
|
||||
Injekt.get<InsertAnimeTrack>().await(track)
|
||||
insertTrack.await(track)
|
||||
|
||||
// Update episode progress if newer episodes marked seen locally
|
||||
if (hasSeenEpisodes) {
|
||||
|
@ -71,7 +74,7 @@ interface AnimeTrackService {
|
|||
.sortedBy { it.episodeNumber }
|
||||
.takeWhile { it.seen }
|
||||
.lastOrNull()
|
||||
?.episodeNumber?.toDouble() ?: -1.0
|
||||
?.episodeNumber ?: -1.0
|
||||
|
||||
if (latestLocalSeenEpisodeNumber > track.lastEpisodeSeen) {
|
||||
track = track.copy(
|
||||
|
@ -123,6 +126,7 @@ interface AnimeTrackService {
|
|||
track.last_episode_seen = episodeNumber.toFloat()
|
||||
if (track.total_episodes != 0 && track.last_episode_seen.toInt() == track.total_episodes) {
|
||||
track.status = getCompletionStatus()
|
||||
track.finished_watching_date = System.currentTimeMillis()
|
||||
}
|
||||
withIOContext { updateRemote(track) }
|
||||
}
|
||||
|
@ -147,7 +151,7 @@ interface AnimeTrackService {
|
|||
try {
|
||||
update(track)
|
||||
track.toDomainTrack(idRequired = false)?.let {
|
||||
Injekt.get<InsertAnimeTrack>().await(it)
|
||||
insertTrack.await(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" }
|
||||
|
|
|
@ -17,9 +17,12 @@ import tachiyomi.domain.items.chapter.interactor.GetChapterByMangaId
|
|||
import tachiyomi.domain.track.manga.interactor.InsertMangaTrack
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.time.ZoneOffset
|
||||
import tachiyomi.domain.track.manga.model.MangaTrack as DomainTrack
|
||||
|
||||
private val insertTrack: InsertMangaTrack by injectLazy()
|
||||
|
||||
interface MangaTrackService {
|
||||
|
||||
// Common functions
|
||||
|
@ -63,7 +66,7 @@ interface MangaTrackService {
|
|||
|
||||
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext
|
||||
|
||||
Injekt.get<InsertMangaTrack>().await(track)
|
||||
insertTrack.await(track)
|
||||
|
||||
// Update chapter progress if newer chapters marked read locally
|
||||
if (hasReadChapters) {
|
||||
|
@ -71,7 +74,7 @@ interface MangaTrackService {
|
|||
.sortedBy { it.chapterNumber }
|
||||
.takeWhile { it.read }
|
||||
.lastOrNull()
|
||||
?.chapterNumber?.toDouble() ?: -1.0
|
||||
?.chapterNumber ?: -1.0
|
||||
|
||||
if (latestLocalReadChapterNumber > track.lastChapterRead) {
|
||||
track = track.copy(
|
||||
|
@ -123,6 +126,7 @@ interface MangaTrackService {
|
|||
track.last_chapter_read = chapterNumber.toFloat()
|
||||
if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) {
|
||||
track.status = getCompletionStatus()
|
||||
track.finished_reading_date = System.currentTimeMillis()
|
||||
}
|
||||
withIOContext { updateRemote(track) }
|
||||
}
|
||||
|
@ -147,7 +151,7 @@ interface MangaTrackService {
|
|||
try {
|
||||
update(track)
|
||||
track.toDomainTrack(idRequired = false)?.let {
|
||||
Injekt.get<InsertMangaTrack>().await(it)
|
||||
insertTrack.await(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=${track.id}" }
|
||||
|
|
|
@ -4,7 +4,6 @@ import androidx.annotation.CallSuper
|
|||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.track.service.TrackPreferences
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import okhttp3.OkHttpClient
|
||||
|
@ -12,7 +11,6 @@ import uy.kohesive.injekt.injectLazy
|
|||
|
||||
abstract class TrackService(val id: Long) {
|
||||
|
||||
val preferences: BasePreferences by injectLazy()
|
||||
val trackPreferences: TrackPreferences by injectLazy()
|
||||
val networkService: NetworkHelper by injectLazy()
|
||||
|
||||
|
|
|
@ -66,7 +66,10 @@ class AnimeExtensionManager(
|
|||
fun getAppIconForSource(sourceId: Long): Drawable? {
|
||||
val pkgName = _installedAnimeExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
|
||||
if (pkgName != null) {
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
|
||||
AnimeExtensionLoader.getAnimeExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
|
||||
.loadIcon(context.packageManager)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
@ -333,6 +336,7 @@ class AnimeExtensionManager(
|
|||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
AnimeExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
unregisterAnimeExtension(pkgName)
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ sealed class AnimeExtension {
|
|||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
val isShared: Boolean,
|
||||
) : AnimeExtension()
|
||||
|
||||
data class Available(
|
||||
|
|
|
@ -4,6 +4,9 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
|
@ -27,7 +30,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
|||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
context.registerReceiver(this, filter)
|
||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -38,6 +41,9 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
|||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(ACTION_EXTENSION_ADDED)
|
||||
addAction(ACTION_EXTENSION_REPLACED)
|
||||
addAction(ACTION_EXTENSION_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
|
@ -49,7 +55,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
|||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
|
@ -61,7 +67,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
|||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is AnimeLoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
|
@ -71,7 +77,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
|||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||
Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
|
@ -127,4 +133,30 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
|||
fun onExtensionUntrusted(extension: AnimeExtension.Untrusted)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_EXTENSION_ADDED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_ADDED"
|
||||
private const val ACTION_EXTENSION_REPLACED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REPLACED"
|
||||
private const val ACTION_EXTENSION_REMOVED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REMOVED"
|
||||
|
||||
fun notifyAdded(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_ADDED)
|
||||
}
|
||||
|
||||
fun notifyReplaced(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_REPLACED)
|
||||
}
|
||||
|
||||
fun notifyRemoved(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_REMOVED)
|
||||
}
|
||||
|
||||
private fun notify(context: Context, pkgName: String, action: String) {
|
||||
Intent(action).apply {
|
||||
data = Uri.parse("package:$pkgName")
|
||||
`package` = context.packageName
|
||||
context.sendBroadcast(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,9 +12,11 @@ import androidx.core.content.getSystemService
|
|||
import androidx.core.net.toUri
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.anime.installer.InstallerAnime
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -157,6 +159,35 @@ internal class AnimeExtensionInstaller(private val context: Context) {
|
|||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
BasePreferences.ExtensionInstaller.PRIVATE -> {
|
||||
val extensionManager = Injekt.get<AnimeExtensionManager>()
|
||||
val tempFile = File(context.cacheDir, "temp_$downloadId")
|
||||
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
// Unlikely but just in case
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (AnimeExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
|
||||
} else {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
|
||||
tempFile.delete()
|
||||
}
|
||||
else -> {
|
||||
val intent =
|
||||
AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
|
@ -180,10 +211,15 @@ internal class AnimeExtensionInstaller(private val context: Context) {
|
|||
* @param pkgName The package name of the extension to uninstall
|
||||
*/
|
||||
fun uninstallApk(pkgName: String) {
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(intent)
|
||||
if (context.isPackageInstalled(pkgName)) {
|
||||
@Suppress("DEPRECATION")
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
AnimeExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
AnimeExtensionInstallReceiver.notifyRemoved(context, pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.anime.util
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
|
@ -14,12 +15,13 @@ import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
|
|||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import eu.kanade.tachiyomi.util.system.getApplicationIcon
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Class that handles the loading of the extensions installed in the system.
|
||||
|
@ -41,12 +43,11 @@ internal object AnimeExtensionLoader {
|
|||
const val LIB_VERSION_MIN = 12
|
||||
const val LIB_VERSION_MAX = 15
|
||||
|
||||
private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
|
||||
PackageManager.GET_META_DATA or
|
||||
PackageManager.GET_SIGNATURES or
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
|
||||
|
||||
// jmir1's key
|
||||
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c"
|
||||
|
@ -56,8 +57,57 @@ internal object AnimeExtensionLoader {
|
|||
*/
|
||||
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get()
|
||||
|
||||
private const val PRIVATE_EXTENSION_EXTENSION = "ext"
|
||||
|
||||
private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts")
|
||||
|
||||
fun installPrivateExtensionFile(context: Context, file: File): Boolean {
|
||||
val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS)
|
||||
?.takeIf { isPackageAnExtension(it) } ?: return false
|
||||
val currentExtension = getAnimeExtensionPackageInfoFromPkgName(context, extension.packageName)
|
||||
|
||||
if (currentExtension != null) {
|
||||
if (PackageInfoCompat.getLongVersionCode(extension) <
|
||||
PackageInfoCompat.getLongVersionCode(currentExtension)
|
||||
) {
|
||||
logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." }
|
||||
return false
|
||||
}
|
||||
|
||||
val extensionSignatures = getSignatures(extension)
|
||||
if (extensionSignatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.ERROR) { "Extension to be installed is not signed." }
|
||||
return false
|
||||
}
|
||||
|
||||
if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) {
|
||||
logcat(LogPriority.ERROR) { "Installed extension signature is not matched." }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
|
||||
return try {
|
||||
file.copyTo(target, overwrite = true)
|
||||
if (currentExtension != null) {
|
||||
AnimeExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
|
||||
} else {
|
||||
AnimeExtensionInstallReceiver.notifyAdded(context, extension.packageName)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to copy extension file." }
|
||||
target.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPrivateExtension(context: Context, pkgName: String) {
|
||||
File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
* Return a list of all the available extensions initialized concurrently.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
|
@ -70,16 +120,43 @@ internal object AnimeExtensionLoader {
|
|||
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
}
|
||||
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
val sharedExtPkgs = installedPkgs
|
||||
.asSequence()
|
||||
.filter { isPackageAnExtension(it) }
|
||||
.map { AnimeExtensionInfo(packageInfo = it, isShared = true) }
|
||||
|
||||
val privateExtPkgs = getPrivateExtensionDir(context)
|
||||
.listFiles()
|
||||
?.asSequence()
|
||||
?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION }
|
||||
?.mapNotNull {
|
||||
val path = it.absolutePath
|
||||
pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
|
||||
?.apply { applicationInfo.fixBasePaths(path) }
|
||||
}
|
||||
?.filter { isPackageAnExtension(it) }
|
||||
?.map { AnimeExtensionInfo(packageInfo = it, isShared = false) }
|
||||
?: emptySequence()
|
||||
|
||||
val extPkgs = (sharedExtPkgs + privateExtPkgs)
|
||||
// Remove duplicates. Shared takes priority than private by default
|
||||
.distinctBy { it.packageInfo.packageName }
|
||||
// Compare version number
|
||||
.mapNotNull { sharedPkg ->
|
||||
val privatePkg = privateExtPkgs
|
||||
.singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName }
|
||||
selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
.toList()
|
||||
|
||||
if (extPkgs.isEmpty()) return emptyList()
|
||||
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
val deferred = extPkgs.map {
|
||||
async { loadExtension(context, it.packageName, it) }
|
||||
async { loadExtension(context, it) }
|
||||
}
|
||||
deferred.map { it.await() }
|
||||
deferred.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,37 +165,62 @@ internal object AnimeExtensionLoader {
|
|||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): AnimeLoadResult {
|
||||
val pkgInfo = try {
|
||||
val extensionPackage = getAnimeExtensionInfoFromPkgName(context, pkgName)
|
||||
if (extensionPackage == null) {
|
||||
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
return loadExtension(context, extensionPackage)
|
||||
}
|
||||
|
||||
fun getAnimeExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
||||
return getAnimeExtensionInfoFromPkgName(context, pkgName)?.packageInfo
|
||||
}
|
||||
|
||||
private fun getAnimeExtensionInfoFromPkgName(context: Context, pkgName: String): AnimeExtensionInfo? {
|
||||
val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION")
|
||||
val privatePkg = if (privateExtensionFile.isFile) {
|
||||
context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS)
|
||||
?.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
it.applicationInfo.fixBasePaths(privateExtensionFile.absolutePath)
|
||||
AnimeExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val sharedPkg = try {
|
||||
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
AnimeExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = true,
|
||||
)
|
||||
}
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return AnimeLoadResult.Error
|
||||
null
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
return loadExtension(context, pkgName, pkgInfo)
|
||||
|
||||
return selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
* Loads an extension
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
* @param extensionInfo The extension to load.
|
||||
*/
|
||||
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): AnimeLoadResult {
|
||||
private fun loadExtension(context: Context, extensionInfo: AnimeExtensionInfo): AnimeLoadResult {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val appInfo = try {
|
||||
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
val appInfo = pkgInfo.applicationInfo
|
||||
val pkgName = pkgInfo.packageName
|
||||
|
||||
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Aniyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
|
@ -139,13 +241,19 @@ internal object AnimeExtensionLoader {
|
|||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(context, pkgInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
val signatures = getSignatures(pkgInfo)
|
||||
if (signatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return AnimeLoadResult.Error
|
||||
} else if (signatureHash !in trustedSignatures) {
|
||||
val extension = AnimeExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash)
|
||||
} else if (!hasTrustedSignature(signatures)) {
|
||||
val extension = AnimeExtension.Untrusted(
|
||||
extName,
|
||||
pkgName,
|
||||
versionName,
|
||||
versionCode,
|
||||
libVersion,
|
||||
signatures.last(),
|
||||
)
|
||||
logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" })
|
||||
return AnimeLoadResult.Untrusted(extension)
|
||||
}
|
||||
|
@ -205,12 +313,35 @@ internal object AnimeExtensionLoader {
|
|||
hasChangelog = hasChangelog,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
isUnofficial = signatureHash != officialSignature,
|
||||
icon = context.getApplicationIcon(pkgName),
|
||||
isUnofficial = !isOfficiallySigned(signatures),
|
||||
icon = appInfo.loadIcon(pkgManager),
|
||||
isShared = extensionInfo.isShared,
|
||||
)
|
||||
return AnimeLoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which extension package to use based on version code
|
||||
*
|
||||
* @param shared extension installed to system
|
||||
* @param private extension installed to data directory
|
||||
*/
|
||||
private fun selectExtensionPackage(shared: AnimeExtensionInfo?, private: AnimeExtensionInfo?): AnimeExtensionInfo? {
|
||||
when {
|
||||
private == null && shared != null -> return shared
|
||||
shared == null && private != null -> return private
|
||||
shared == null && private == null -> return null
|
||||
}
|
||||
|
||||
return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >=
|
||||
PackageInfoCompat.getLongVersionCode(private!!.packageInfo)
|
||||
) {
|
||||
shared
|
||||
} else {
|
||||
private
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is an extension.
|
||||
*
|
||||
|
@ -221,12 +352,50 @@ internal object AnimeExtensionLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
* Returns the signatures of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
* @return List SHA256 digest of the signatures
|
||||
*/
|
||||
private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? {
|
||||
val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName)
|
||||
return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) }
|
||||
private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val signingInfo = pkgInfo.signingInfo
|
||||
if (signingInfo.hasMultipleSigners()) {
|
||||
signingInfo.apkContentsSigners
|
||||
} else {
|
||||
signingInfo.signingCertificateHistory
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pkgInfo.signatures
|
||||
}
|
||||
?.map { Hash.sha256(it.toByteArray()) }
|
||||
?.toList()
|
||||
}
|
||||
|
||||
private fun hasTrustedSignature(signatures: List<String>): Boolean {
|
||||
return trustedSignatures.any { signatures.contains(it) }
|
||||
}
|
||||
|
||||
private fun isOfficiallySigned(signatures: List<String>): Boolean {
|
||||
return signatures.all { it == officialSignature }
|
||||
}
|
||||
|
||||
/**
|
||||
* On Android 13+ the ApplicationInfo generated by getPackageArchiveInfo doesn't
|
||||
* have sourceDir which breaks assets loading (used for getting icon here).
|
||||
*/
|
||||
private fun ApplicationInfo.fixBasePaths(apkPath: String) {
|
||||
if (sourceDir == null) {
|
||||
sourceDir = apkPath
|
||||
}
|
||||
if (publicSourceDir == null) {
|
||||
publicSourceDir = apkPath
|
||||
}
|
||||
}
|
||||
|
||||
private data class AnimeExtensionInfo(
|
||||
val packageInfo: PackageInfo,
|
||||
val isShared: Boolean,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -66,7 +66,10 @@ class MangaExtensionManager(
|
|||
fun getAppIconForSource(sourceId: Long): Drawable? {
|
||||
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
|
||||
if (pkgName != null) {
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
|
||||
MangaExtensionLoader.getMangaExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
|
||||
.loadIcon(context.packageManager)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
@ -333,6 +336,7 @@ class MangaExtensionManager(
|
|||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
MangaExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
unregisterExtension(pkgName)
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ sealed class MangaExtension {
|
|||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
val isShared: Boolean,
|
||||
) : MangaExtension()
|
||||
|
||||
data class Available(
|
||||
|
|
|
@ -4,6 +4,9 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
|
@ -27,7 +30,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
|
|||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
context.registerReceiver(this, filter)
|
||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -38,6 +41,9 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
|
|||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(ACTION_EXTENSION_ADDED)
|
||||
addAction(ACTION_EXTENSION_REPLACED)
|
||||
addAction(ACTION_EXTENSION_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
|
@ -49,7 +55,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
|
|||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
|
@ -61,7 +67,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
|
|||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is MangaLoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
|
@ -71,7 +77,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
|
|||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||
Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
|
@ -127,4 +133,30 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) :
|
|||
fun onExtensionUntrusted(extension: MangaExtension.Untrusted)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_EXTENSION_ADDED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_ADDED"
|
||||
private const val ACTION_EXTENSION_REPLACED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REPLACED"
|
||||
private const val ACTION_EXTENSION_REMOVED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REMOVED"
|
||||
|
||||
fun notifyAdded(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_ADDED)
|
||||
}
|
||||
|
||||
fun notifyReplaced(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_REPLACED)
|
||||
}
|
||||
|
||||
fun notifyRemoved(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_REMOVED)
|
||||
}
|
||||
|
||||
private fun notify(context: Context, pkgName: String, action: String) {
|
||||
Intent(action).apply {
|
||||
data = Uri.parse("package:$pkgName")
|
||||
`package` = context.packageName
|
||||
context.sendBroadcast(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,9 +12,11 @@ import androidx.core.content.getSystemService
|
|||
import androidx.core.net.toUri
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.manga.installer.InstallerManga
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -157,6 +159,35 @@ internal class MangaExtensionInstaller(private val context: Context) {
|
|||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
BasePreferences.ExtensionInstaller.PRIVATE -> {
|
||||
val extensionManager = Injekt.get<MangaExtensionManager>()
|
||||
val tempFile = File(context.cacheDir, "temp_$downloadId")
|
||||
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
// Unlikely but just in case
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (MangaExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
|
||||
} else {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
|
||||
tempFile.delete()
|
||||
}
|
||||
else -> {
|
||||
val intent =
|
||||
MangaExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
|
@ -180,10 +211,15 @@ internal class MangaExtensionInstaller(private val context: Context) {
|
|||
* @param pkgName The package name of the extension to uninstall
|
||||
*/
|
||||
fun uninstallApk(pkgName: String) {
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(intent)
|
||||
if (context.isPackageInstalled(pkgName)) {
|
||||
@Suppress("DEPRECATION")
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
MangaExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
MangaExtensionInstallReceiver.notifyRemoved(context, pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,10 +2,12 @@ package eu.kanade.tachiyomi.extension.manga.util
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.core.content.pm.PackageInfoCompat.getSignatures
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
|
@ -14,15 +16,27 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
|||
import eu.kanade.tachiyomi.source.MangaSource
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import eu.kanade.tachiyomi.util.system.getApplicationIcon
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Class that handles the loading of the extensions installed in the system.
|
||||
* Class that handles the loading of the extensions. Supports two kinds of extensions:
|
||||
*
|
||||
* 1. Shared extension: This extension is installed to the system with package
|
||||
* installer, so other variants of Tachiyomi/Aniyomi and its forks can also use this extension.
|
||||
*
|
||||
* 2. Private extension: This extension is put inside private data directory of the
|
||||
* running app, so this extension can only be used by the running app and not shared
|
||||
* with other apps.
|
||||
*
|
||||
* When both kinds of extensions are installed with a same package name, shared
|
||||
* extension will be used unless the version codes are different. In that case the
|
||||
* one with higher version code will be used.
|
||||
*/
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
internal object MangaExtensionLoader {
|
||||
|
@ -41,12 +55,11 @@ internal object MangaExtensionLoader {
|
|||
const val LIB_VERSION_MIN = 1.2
|
||||
const val LIB_VERSION_MAX = 1.5
|
||||
|
||||
private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
|
||||
PackageManager.GET_META_DATA or
|
||||
PackageManager.GET_SIGNATURES or
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
|
||||
|
||||
// inorichi's key
|
||||
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
||||
|
@ -56,8 +69,57 @@ internal object MangaExtensionLoader {
|
|||
*/
|
||||
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get()
|
||||
|
||||
private const val PRIVATE_EXTENSION_EXTENSION = "ext"
|
||||
|
||||
private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts")
|
||||
|
||||
fun installPrivateExtensionFile(context: Context, file: File): Boolean {
|
||||
val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS)
|
||||
?.takeIf { isPackageAnExtension(it) } ?: return false
|
||||
val currentExtension = getMangaExtensionPackageInfoFromPkgName(context, extension.packageName)
|
||||
|
||||
if (currentExtension != null) {
|
||||
if (PackageInfoCompat.getLongVersionCode(extension) <
|
||||
PackageInfoCompat.getLongVersionCode(currentExtension)
|
||||
) {
|
||||
logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." }
|
||||
return false
|
||||
}
|
||||
|
||||
val extensionSignatures = getSignatures(extension)
|
||||
if (extensionSignatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.ERROR) { "Extension to be installed is not signed." }
|
||||
return false
|
||||
}
|
||||
|
||||
if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) {
|
||||
logcat(LogPriority.ERROR) { "Installed extension signature is not matched." }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
|
||||
return try {
|
||||
file.copyTo(target, overwrite = true)
|
||||
if (currentExtension != null) {
|
||||
MangaExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
|
||||
} else {
|
||||
MangaExtensionInstallReceiver.notifyAdded(context, extension.packageName)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to copy extension file." }
|
||||
target.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPrivateExtension(context: Context, pkgName: String) {
|
||||
File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
* Return a list of all the available extensions initialized concurrently.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
|
@ -70,16 +132,43 @@ internal object MangaExtensionLoader {
|
|||
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
}
|
||||
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
val sharedExtPkgs = installedPkgs
|
||||
.asSequence()
|
||||
.filter { isPackageAnExtension(it) }
|
||||
.map { MangaExtensionInfo(packageInfo = it, isShared = true) }
|
||||
|
||||
val privateExtPkgs = getPrivateExtensionDir(context)
|
||||
.listFiles()
|
||||
?.asSequence()
|
||||
?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION }
|
||||
?.mapNotNull {
|
||||
val path = it.absolutePath
|
||||
pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
|
||||
?.apply { applicationInfo.fixBasePaths(path) }
|
||||
}
|
||||
?.filter { isPackageAnExtension(it) }
|
||||
?.map { MangaExtensionInfo(packageInfo = it, isShared = false) }
|
||||
?: emptySequence()
|
||||
|
||||
val extPkgs = (sharedExtPkgs + privateExtPkgs)
|
||||
// Remove duplicates. Shared takes priority than private by default
|
||||
.distinctBy { it.packageInfo.packageName }
|
||||
// Compare version number
|
||||
.mapNotNull { sharedPkg ->
|
||||
val privatePkg = privateExtPkgs
|
||||
.singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName }
|
||||
selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
.toList()
|
||||
|
||||
if (extPkgs.isEmpty()) return emptyList()
|
||||
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
val deferred = extPkgs.map {
|
||||
async { loadMangaExtension(context, it.packageName, it) }
|
||||
async { loadMangaExtension(context, it) }
|
||||
}
|
||||
deferred.map { it.await() }
|
||||
deferred.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,37 +177,61 @@ internal object MangaExtensionLoader {
|
|||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadMangaExtensionFromPkgName(context: Context, pkgName: String): MangaLoadResult {
|
||||
val pkgInfo = try {
|
||||
val extensionPackage = getMangaExtensionInfoFromPkgName(context, pkgName)
|
||||
if (extensionPackage == null) {
|
||||
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
return loadMangaExtension(context, extensionPackage)
|
||||
}
|
||||
|
||||
fun getMangaExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
||||
return getMangaExtensionInfoFromPkgName(context, pkgName)?.packageInfo
|
||||
}
|
||||
|
||||
private fun getMangaExtensionInfoFromPkgName(context: Context, pkgName: String): MangaExtensionInfo? {
|
||||
val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION")
|
||||
val privatePkg = if (privateExtensionFile.isFile) {
|
||||
context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS)
|
||||
?.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
it.applicationInfo.fixBasePaths(privateExtensionFile.absolutePath)
|
||||
MangaExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val sharedPkg = try {
|
||||
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
MangaExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = true,
|
||||
)
|
||||
}
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return MangaLoadResult.Error
|
||||
null
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
return loadMangaExtension(context, pkgName, pkgInfo)
|
||||
|
||||
return selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
* Loads an extension
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
* @param extensionInfo The extension to load.
|
||||
*/
|
||||
private fun loadMangaExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): MangaLoadResult {
|
||||
private fun loadMangaExtension(context: Context, extensionInfo: MangaExtensionInfo): MangaLoadResult {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val appInfo = try {
|
||||
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
val appInfo = pkgInfo.applicationInfo
|
||||
val pkgName = pkgInfo.packageName
|
||||
|
||||
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
|
@ -139,13 +252,19 @@ internal object MangaExtensionLoader {
|
|||
return MangaLoadResult.Error
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(context, pkgInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
val signatures = getSignatures(pkgInfo)
|
||||
if (signatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return MangaLoadResult.Error
|
||||
} else if (signatureHash !in trustedSignatures) {
|
||||
val extension = MangaExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash)
|
||||
} else if (!hasTrustedSignature(signatures)) {
|
||||
val extension = MangaExtension.Untrusted(
|
||||
extName,
|
||||
pkgName,
|
||||
versionName,
|
||||
versionCode,
|
||||
libVersion,
|
||||
signatures.last(),
|
||||
)
|
||||
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
||||
return MangaLoadResult.Untrusted(extension)
|
||||
}
|
||||
|
@ -205,12 +324,35 @@ internal object MangaExtensionLoader {
|
|||
hasChangelog = hasChangelog,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
isUnofficial = signatureHash != officialSignature,
|
||||
icon = context.getApplicationIcon(pkgName),
|
||||
isUnofficial = !isOfficiallySigned(signatures),
|
||||
icon = appInfo.loadIcon(pkgManager),
|
||||
isShared = extensionInfo.isShared,
|
||||
)
|
||||
return MangaLoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which extension package to use based on version code
|
||||
*
|
||||
* @param shared extension installed to system
|
||||
* @param private extension installed to data directory
|
||||
*/
|
||||
private fun selectExtensionPackage(shared: MangaExtensionInfo?, private: MangaExtensionInfo?): MangaExtensionInfo? {
|
||||
when {
|
||||
private == null && shared != null -> return shared
|
||||
shared == null && private != null -> return private
|
||||
shared == null && private == null -> return null
|
||||
}
|
||||
|
||||
return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >=
|
||||
PackageInfoCompat.getLongVersionCode(private!!.packageInfo)
|
||||
) {
|
||||
shared
|
||||
} else {
|
||||
private
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is an extension.
|
||||
*
|
||||
|
@ -221,12 +363,50 @@ internal object MangaExtensionLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
* Returns the signatures of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
* @return List SHA256 digest of the signatures
|
||||
*/
|
||||
private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? {
|
||||
val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName)
|
||||
return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) }
|
||||
private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val signingInfo = pkgInfo.signingInfo
|
||||
if (signingInfo.hasMultipleSigners()) {
|
||||
signingInfo.apkContentsSigners
|
||||
} else {
|
||||
signingInfo.signingCertificateHistory
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pkgInfo.signatures
|
||||
}
|
||||
?.map { Hash.sha256(it.toByteArray()) }
|
||||
?.toList()
|
||||
}
|
||||
|
||||
private fun hasTrustedSignature(signatures: List<String>): Boolean {
|
||||
return trustedSignatures.any { signatures.contains(it) }
|
||||
}
|
||||
|
||||
private fun isOfficiallySigned(signatures: List<String>): Boolean {
|
||||
return signatures.all { it == officialSignature }
|
||||
}
|
||||
|
||||
/**
|
||||
* On Android 13+ the ApplicationInfo generated by getPackageArchiveInfo doesn't
|
||||
* have sourceDir which breaks assets loading (used for getting icon here).
|
||||
*/
|
||||
private fun ApplicationInfo.fixBasePaths(apkPath: String) {
|
||||
if (sourceDir == null) {
|
||||
sourceDir = apkPath
|
||||
}
|
||||
if (publicSourceDir == null) {
|
||||
publicSourceDir = apkPath
|
||||
}
|
||||
}
|
||||
|
||||
private data class MangaExtensionInfo(
|
||||
val packageInfo: PackageInfo,
|
||||
val isShared: Boolean,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -147,7 +147,7 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
|
|||
sourceScreen.forEach { pref ->
|
||||
pref.isIconSpaceReserved = false
|
||||
pref.isSingleLineTitle = false
|
||||
if (pref is DialogPreference) {
|
||||
if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) {
|
||||
pref.dialogTitle = pref.title
|
||||
}
|
||||
|
||||
|
|
|
@ -147,7 +147,7 @@ class MangaSourcePreferencesFragment : PreferenceFragmentCompat() {
|
|||
sourceScreen.forEach { pref ->
|
||||
pref.isIconSpaceReserved = false
|
||||
pref.isSingleLineTitle = false
|
||||
if (pref is DialogPreference) {
|
||||
if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) {
|
||||
pref.dialogTitle = pref.title
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
package eu.kanade.tachiyomi.ui.main
|
||||
package eu.kanade.tachiyomi.ui.deeplink.anime
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
|
||||
class DeepLinkAnimeActivity : Activity() {
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
package eu.kanade.tachiyomi.ui.main
|
||||
package eu.kanade.tachiyomi.ui.deeplink.manga
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
|
||||
class DeepLinkMangaActivity : Activity() {
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -134,7 +134,7 @@ class AnimeScreenModel(
|
|||
|
||||
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
|
||||
|
||||
val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.autoUpdateItemRestrictions().get()
|
||||
|
||||
private val selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
|
||||
private val selectedEpisodeIds: HashSet<Long> = HashSet()
|
||||
|
|
|
@ -130,7 +130,7 @@ class MangaScreenModel(
|
|||
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
|
||||
val skipFiltered by readerPreferences.skipFiltered().asState(coroutineScope)
|
||||
|
||||
val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.autoUpdateItemRestrictions().get()
|
||||
|
||||
private val selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
|
||||
private val selectedChapterIds: HashSet<Long> = HashSet()
|
||||
|
|
|
@ -526,7 +526,7 @@ class AnimeLibraryScreenModel(
|
|||
}
|
||||
|
||||
fun getDisplayMode(): PreferenceMutableState<LibraryDisplayMode> {
|
||||
return libraryPreferences.libraryDisplayMode().asState(coroutineScope)
|
||||
return libraryPreferences.displayMode().asState(coroutineScope)
|
||||
}
|
||||
|
||||
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
|
||||
|
|
|
@ -168,7 +168,7 @@ object AnimeLibraryTab : Tab() {
|
|||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) { contentPadding ->
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> {
|
||||
val handler = LocalUriHandler.current
|
||||
EmptyScreen(
|
||||
|
|
|
@ -520,7 +520,7 @@ class MangaLibraryScreenModel(
|
|||
}
|
||||
|
||||
fun getDisplayMode(): PreferenceMutableState<LibraryDisplayMode> {
|
||||
return libraryPreferences.libraryDisplayMode().asState(coroutineScope)
|
||||
return libraryPreferences.displayMode().asState(coroutineScope)
|
||||
}
|
||||
|
||||
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
|
||||
|
|
|
@ -165,7 +165,7 @@ object MangaLibraryTab : Tab() {
|
|||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) { contentPadding ->
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> {
|
||||
val handler = LocalUriHandler.current
|
||||
EmptyScreen(
|
||||
|
|
|
@ -83,6 +83,7 @@ import eu.kanade.tachiyomi.ui.browse.anime.source.browse.BrowseAnimeSourceScreen
|
|||
import eu.kanade.tachiyomi.ui.browse.anime.source.globalsearch.GlobalAnimeSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.manga.source.browse.BrowseMangaSourceScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.manga.source.globalsearch.GlobalMangaSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.deeplink.manga.DeepLinkMangaScreen
|
||||
import eu.kanade.tachiyomi.ui.entries.anime.AnimeScreen
|
||||
import eu.kanade.tachiyomi.ui.entries.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.home.HomeScreen
|
||||
|
@ -449,6 +450,7 @@ class MainActivity : BaseActivity() {
|
|||
if (!query.isNullOrEmpty()) {
|
||||
navigator.popUntilRoot()
|
||||
navigator.push(GlobalMangaSearchScreen(query))
|
||||
navigator.push(DeepLinkMangaScreen(query))
|
||||
}
|
||||
null
|
||||
}
|
||||
|
|
|
@ -385,11 +385,14 @@ class ReaderActivity : BaseActivity() {
|
|||
|
||||
binding.pageNumber.setComposeContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val showPageNumber by viewModel.readerPreferences.showPageNumber().collectAsState()
|
||||
|
||||
PageIndicatorText(
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
)
|
||||
if (!state.menuVisible && showPageNumber) {
|
||||
PageIndicatorText(
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
binding.readerMenuBottom.setComposeContent {
|
||||
|
@ -557,10 +560,6 @@ class ReaderActivity : BaseActivity() {
|
|||
bottomAnimation.applySystemAnimatorScale(this)
|
||||
binding.readerMenuBottom.startAnimation(bottomAnimation)
|
||||
}
|
||||
|
||||
if (readerPreferences.showPageNumber().get()) {
|
||||
config?.setPageNumberVisibility(false)
|
||||
}
|
||||
} else {
|
||||
if (readerPreferences.fullscreen().get()) {
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
|
@ -583,10 +582,6 @@ class ReaderActivity : BaseActivity() {
|
|||
bottomAnimation.applySystemAnimatorScale(this)
|
||||
binding.readerMenuBottom.startAnimation(bottomAnimation)
|
||||
}
|
||||
|
||||
if (readerPreferences.showPageNumber().get()) {
|
||||
config?.setPageNumberVisibility(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -639,9 +634,8 @@ class ReaderActivity : BaseActivity() {
|
|||
|
||||
private fun showReadingModeToast(mode: Int) {
|
||||
try {
|
||||
val strings = resources.getStringArray(R.array.viewers_selector)
|
||||
readingModeToast?.cancel()
|
||||
readingModeToast = toast(strings[mode])
|
||||
readingModeToast = toast(ReadingModeType.fromPreference(mode).stringRes)
|
||||
} catch (e: ArrayIndexOutOfBoundsException) {
|
||||
logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" }
|
||||
}
|
||||
|
@ -895,10 +889,6 @@ class ReaderActivity : BaseActivity() {
|
|||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.showPageNumber().changes()
|
||||
.onEach(::setPageNumberVisibility)
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.trueColor().changes()
|
||||
.onEach(::setTrueColor)
|
||||
.launchIn(lifecycleScope)
|
||||
|
@ -948,13 +938,6 @@ class ReaderActivity : BaseActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visibility of the bottom page indicator according to [visible].
|
||||
*/
|
||||
fun setPageNumberVisibility(visible: Boolean) {
|
||||
binding.pageNumber.isVisible = visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the 32-bit color mode according to [enabled].
|
||||
*/
|
||||
|
|
|
@ -5,14 +5,14 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.StringRes
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
enum class OrientationType(val prefValue: Int, val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(0, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.label_default, R.drawable.ic_screen_rotation_24dp, 0x00000000),
|
||||
FREE(1, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp, 0x00000008),
|
||||
PORTRAIT(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000010),
|
||||
LANDSCAPE(3, ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_landscape, R.drawable.ic_stay_current_landscape_24dp, 0x00000018),
|
||||
LOCKED_PORTRAIT(4, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp, 0x00000020),
|
||||
LOCKED_LANDSCAPE(5, ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp, 0x00000028),
|
||||
REVERSE_PORTRAIT(6, ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, R.string.rotation_reverse_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000030),
|
||||
enum class OrientationType(val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.label_default, R.drawable.ic_screen_rotation_24dp, 0x00000000),
|
||||
FREE(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp, 0x00000008),
|
||||
PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000010),
|
||||
LANDSCAPE(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_landscape, R.drawable.ic_stay_current_landscape_24dp, 0x00000018),
|
||||
LOCKED_PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp, 0x00000020),
|
||||
LOCKED_LANDSCAPE(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp, 0x00000028),
|
||||
REVERSE_PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, R.string.rotation_reverse_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000030),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
|||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
|
||||
enum class ReadingModeType(val prefValue: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(0, R.string.label_default, R.drawable.ic_reader_default_24dp, 0x00000000),
|
||||
LEFT_TO_RIGHT(1, R.string.left_to_right_viewer, R.drawable.ic_reader_ltr_24dp, 0x00000001),
|
||||
RIGHT_TO_LEFT(2, R.string.right_to_left_viewer, R.drawable.ic_reader_rtl_24dp, 0x00000002),
|
||||
VERTICAL(3, R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp, 0x00000003),
|
||||
WEBTOON(4, R.string.webtoon_viewer, R.drawable.ic_reader_webtoon_24dp, 0x00000004),
|
||||
CONTINUOUS_VERTICAL(5, R.string.vertical_plus_viewer, R.drawable.ic_reader_continuous_vertical_24dp, 0x00000005),
|
||||
enum class ReadingModeType(@StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(R.string.label_default, R.drawable.ic_reader_default_24dp, 0x00000000),
|
||||
LEFT_TO_RIGHT(R.string.left_to_right_viewer, R.drawable.ic_reader_ltr_24dp, 0x00000001),
|
||||
RIGHT_TO_LEFT(R.string.right_to_left_viewer, R.drawable.ic_reader_rtl_24dp, 0x00000002),
|
||||
VERTICAL(R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp, 0x00000003),
|
||||
WEBTOON(R.string.webtoon_viewer, R.drawable.ic_reader_webtoon_24dp, 0x00000004),
|
||||
CONTINUOUS_VERTICAL(R.string.vertical_plus_viewer, R.drawable.ic_reader_continuous_vertical_24dp, 0x00000005),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -88,14 +88,14 @@ class AnimeStatsScreenModel(
|
|||
}
|
||||
|
||||
private fun getGlobalUpdateItemCount(libraryAnime: List<LibraryAnime>): Int {
|
||||
val includedCategories = preferences.animeLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val includedCategories = preferences.animeUpdateCategories().get().map { it.toLong() }
|
||||
val includedAnime = if (includedCategories.isNotEmpty()) {
|
||||
libraryAnime.filter { it.category in includedCategories }
|
||||
} else {
|
||||
libraryAnime
|
||||
}
|
||||
|
||||
val excludedCategories = preferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedCategories = preferences.animeUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedMangaIds = if (excludedCategories.isNotEmpty()) {
|
||||
libraryAnime.fastMapNotNull { anime ->
|
||||
anime.id.takeIf { anime.category in excludedCategories }
|
||||
|
@ -104,7 +104,7 @@ class AnimeStatsScreenModel(
|
|||
emptyList()
|
||||
}
|
||||
|
||||
val updateRestrictions = preferences.libraryUpdateItemRestriction().get()
|
||||
val updateRestrictions = preferences.autoUpdateItemRestrictions().get()
|
||||
return includedAnime
|
||||
.fastFilterNot { it.anime.id in excludedMangaIds }
|
||||
.fastDistinctBy { it.anime.id }
|
||||
|
|
|
@ -88,14 +88,14 @@ class MangaStatsScreenModel(
|
|||
}
|
||||
|
||||
private fun getGlobalUpdateItemCount(libraryManga: List<LibraryManga>): Int {
|
||||
val includedCategories = preferences.mangaLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val includedCategories = preferences.mangaUpdateCategories().get().map { it.toLong() }
|
||||
val includedManga = if (includedCategories.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in includedCategories }
|
||||
} else {
|
||||
libraryManga
|
||||
}
|
||||
|
||||
val excludedCategories = preferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedCategories = preferences.mangaUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedMangaIds = if (excludedCategories.isNotEmpty()) {
|
||||
libraryManga.fastMapNotNull { manga ->
|
||||
manga.id.takeIf { manga.category in excludedCategories }
|
||||
|
@ -104,7 +104,7 @@ class MangaStatsScreenModel(
|
|||
emptyList()
|
||||
}
|
||||
|
||||
val updateRestrictions = preferences.libraryUpdateItemRestriction().get()
|
||||
val updateRestrictions = preferences.autoUpdateItemRestrictions().get()
|
||||
return includedManga
|
||||
.fastFilterNot { it.manga.id in excludedMangaIds }
|
||||
.fastDistinctBy { it.manga.id }
|
||||
|
|
|
@ -66,7 +66,7 @@ class AnimeUpdatesScreenModel(
|
|||
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||
val events: Flow<Event> = _events.receiveAsFlow()
|
||||
|
||||
val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope)
|
||||
val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope)
|
||||
|
||||
val useExternalDownloader = downloadPreferences.useExternalDownloader().get()
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@ class MangaUpdatesScreenModel(
|
|||
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||
val events: Flow<Event> = _events.receiveAsFlow()
|
||||
|
||||
val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope)
|
||||
val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope)
|
||||
|
||||
// First and last selected index in list
|
||||
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
|
||||
|
|
|
@ -1,24 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="viewers_selector">
|
||||
<item>@string/label_default</item>
|
||||
<item>@string/left_to_right_viewer</item>
|
||||
<item>@string/right_to_left_viewer</item>
|
||||
<item>@string/vertical_viewer</item>
|
||||
<item>@string/webtoon_viewer</item>
|
||||
<item>@string/vertical_plus_viewer</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="rotation_type">
|
||||
<item>@string/label_default</item>
|
||||
<item>@string/rotation_free</item>
|
||||
<item>@string/rotation_portrait</item>
|
||||
<item>@string/rotation_landscape</item>
|
||||
<item>@string/rotation_force_portrait</item>
|
||||
<item>@string/rotation_force_landscape</item>
|
||||
<item>@string/rotation_reverse_portrait</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="playback_options">
|
||||
<item>@string/playback_options_speed</item>
|
||||
<item>@string/playback_options_quality</item>
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -58,7 +58,7 @@ class ChapterRepositoryImpl(
|
|||
read = chapterUpdate.read,
|
||||
bookmark = chapterUpdate.bookmark,
|
||||
lastPageRead = chapterUpdate.lastPageRead,
|
||||
chapterNumber = chapterUpdate.chapterNumber?.toDouble(),
|
||||
chapterNumber = chapterUpdate.chapterNumber,
|
||||
sourceOrder = chapterUpdate.sourceOrder,
|
||||
dateFetch = chapterUpdate.dateFetch,
|
||||
dateUpload = chapterUpdate.dateUpload,
|
||||
|
|
|
@ -60,7 +60,7 @@ class EpisodeRepositoryImpl(
|
|||
bookmark = episodeUpdate.bookmark,
|
||||
lastSecondSeen = episodeUpdate.lastSecondSeen,
|
||||
totalSeconds = episodeUpdate.totalSeconds,
|
||||
episodeNumber = episodeUpdate.episodeNumber?.toDouble(),
|
||||
episodeNumber = episodeUpdate.episodeNumber,
|
||||
sourceOrder = episodeUpdate.sourceOrder,
|
||||
dateFetch = episodeUpdate.dateFetch,
|
||||
dateUpload = episodeUpdate.dateUpload,
|
||||
|
|
|
@ -21,10 +21,25 @@ data class GithubRelease(
|
|||
@Serializable
|
||||
data class GitHubAssets(@SerialName("browser_download_url") val downloadLink: String)
|
||||
|
||||
/**
|
||||
* Regular expression that matches a mention to a valid GitHub username, like it's
|
||||
* done in GitHub Flavored Markdown. It follows these constraints:
|
||||
*
|
||||
* - Alphanumeric with single hyphens (no consecutive hyphens)
|
||||
* - Cannot begin or end with a hyphen
|
||||
* - Max length of 39 characters
|
||||
*
|
||||
* Reference: https://stackoverflow.com/a/30281147
|
||||
*/
|
||||
val gitHubUsernameMentionRegex =
|
||||
"""\B@([a-z0-9](?:-(?=[a-z0-9])|[a-z0-9]){0,38}(?<=[a-z0-9]))""".toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
val releaseMapper: (GithubRelease) -> Release = {
|
||||
Release(
|
||||
it.version,
|
||||
it.info,
|
||||
it.info.replace(gitHubUsernameMentionRegex) { mention ->
|
||||
"[${mention.value}](https://github.com/${mention.value.substring(1)})"
|
||||
},
|
||||
it.releaseLink,
|
||||
it.assets.map(GitHubAssets::downloadLink),
|
||||
)
|
||||
|
|
|
@ -14,7 +14,7 @@ class CreateAnimeCategoryWithName(
|
|||
|
||||
private val initialFlags: Long
|
||||
get() {
|
||||
val sort = preferences.libraryAnimeSortingMode().get()
|
||||
val sort = preferences.animeSortingMode().get()
|
||||
return sort.type.flag or sort.direction.flag
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ class ResetAnimeCategoryFlags(
|
|||
) {
|
||||
|
||||
suspend fun await() {
|
||||
val sort = preferences.libraryAnimeSortingMode().get()
|
||||
val sort = preferences.animeSortingMode().get()
|
||||
categoryRepository.updateAllAnimeCategoryFlags(sort.type + sort.direction)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,6 @@ class SetAnimeDisplayMode(
|
|||
) {
|
||||
|
||||
fun await(display: LibraryDisplayMode) {
|
||||
preferences.libraryDisplayMode().set(display)
|
||||
preferences.displayMode().set(display)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class SetSortModeForAnimeCategory(
|
|||
),
|
||||
)
|
||||
} else {
|
||||
preferences.libraryAnimeSortingMode().set(AnimeLibrarySort(type, direction))
|
||||
preferences.animeSortingMode().set(AnimeLibrarySort(type, direction))
|
||||
categoryRepository.updateAllAnimeCategoryFlags(flags)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ class CreateMangaCategoryWithName(
|
|||
|
||||
private val initialFlags: Long
|
||||
get() {
|
||||
val sort = preferences.libraryMangaSortingMode().get()
|
||||
val sort = preferences.mangaSortingMode().get()
|
||||
return sort.type.flag or sort.direction.flag
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ class ResetMangaCategoryFlags(
|
|||
) {
|
||||
|
||||
suspend fun await() {
|
||||
val sort = preferences.libraryMangaSortingMode().get()
|
||||
val sort = preferences.mangaSortingMode().get()
|
||||
categoryRepository.updateAllMangaCategoryFlags(sort.type + sort.direction)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,6 @@ class SetMangaDisplayMode(
|
|||
) {
|
||||
|
||||
fun await(display: LibraryDisplayMode) {
|
||||
preferences.libraryDisplayMode().set(display)
|
||||
preferences.displayMode().set(display)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class SetSortModeForMangaCategory(
|
|||
),
|
||||
)
|
||||
} else {
|
||||
preferences.libraryMangaSortingMode().set(MangaLibrarySort(type, direction))
|
||||
preferences.mangaSortingMode().set(MangaLibrarySort(type, direction))
|
||||
categoryRepository.updateAllMangaCategoryFlags(flags)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,39 +20,38 @@ class LibraryPreferences(
|
|||
fun isDefaultHomeTabLibraryManga() =
|
||||
preferenceStore.getBoolean("default_home_tab_library", false)
|
||||
|
||||
fun libraryDisplayMode() = preferenceStore.getObject(
|
||||
fun displayMode() = preferenceStore.getObject(
|
||||
"pref_display_mode_library",
|
||||
LibraryDisplayMode.default,
|
||||
LibraryDisplayMode.Serializer::serialize,
|
||||
LibraryDisplayMode.Serializer::deserialize,
|
||||
)
|
||||
|
||||
fun libraryMangaSortingMode() = preferenceStore.getObject(
|
||||
fun mangaSortingMode() = preferenceStore.getObject(
|
||||
"library_sorting_mode",
|
||||
MangaLibrarySort.default,
|
||||
MangaLibrarySort.Serializer::serialize,
|
||||
MangaLibrarySort.Serializer::deserialize,
|
||||
)
|
||||
|
||||
fun libraryAnimeSortingMode() = preferenceStore.getObject(
|
||||
fun animeSortingMode() = preferenceStore.getObject(
|
||||
"animelib_sorting_mode",
|
||||
AnimeLibrarySort.default,
|
||||
AnimeLibrarySort.Serializer::serialize,
|
||||
AnimeLibrarySort.Serializer::deserialize,
|
||||
)
|
||||
|
||||
fun libraryUpdateInterval() = preferenceStore.getInt("pref_library_update_interval_key", 0)
|
||||
fun lastUpdatedTimestamp() = preferenceStore.getLong("library_update_last_timestamp", 0L)
|
||||
fun autoUpdateInterval() = preferenceStore.getInt("pref_library_update_interval_key", 0)
|
||||
|
||||
fun libraryUpdateLastTimestamp() = preferenceStore.getLong("library_update_last_timestamp", 0L)
|
||||
|
||||
fun libraryUpdateDeviceRestriction() = preferenceStore.getStringSet(
|
||||
fun autoUpdateDeviceRestrictions() = preferenceStore.getStringSet(
|
||||
"library_update_restriction",
|
||||
setOf(
|
||||
DEVICE_ONLY_ON_WIFI,
|
||||
),
|
||||
)
|
||||
|
||||
fun libraryUpdateItemRestriction() = preferenceStore.getStringSet(
|
||||
fun autoUpdateItemRestrictions() = preferenceStore.getStringSet(
|
||||
"library_update_manga_restriction",
|
||||
setOf(
|
||||
ENTRY_HAS_UNVIEWED,
|
||||
|
@ -172,16 +171,16 @@ class LibraryPreferences(
|
|||
fun lastUsedAnimeCategory() = preferenceStore.getInt("last_used_anime_category", 0)
|
||||
fun lastUsedMangaCategory() = preferenceStore.getInt("last_used_category", 0)
|
||||
|
||||
fun animeLibraryUpdateCategories() =
|
||||
fun animeUpdateCategories() =
|
||||
preferenceStore.getStringSet("animelib_update_categories", emptySet())
|
||||
|
||||
fun mangaLibraryUpdateCategories() =
|
||||
fun mangaUpdateCategories() =
|
||||
preferenceStore.getStringSet("library_update_categories", emptySet())
|
||||
|
||||
fun animeLibraryUpdateCategoriesExclude() =
|
||||
fun animeUpdateCategoriesExclude() =
|
||||
preferenceStore.getStringSet("animelib_update_categories_exclude", emptySet())
|
||||
|
||||
fun mangaLibraryUpdateCategoriesExclude() =
|
||||
fun mangaUpdateCategoriesExclude() =
|
||||
preferenceStore.getStringSet("library_update_categories_exclude", emptySet())
|
||||
|
||||
// Mixture Item
|
||||
|
@ -291,7 +290,6 @@ class LibraryPreferences(
|
|||
const val DEVICE_ONLY_ON_WIFI = "wifi"
|
||||
const val DEVICE_NETWORK_NOT_METERED = "network_not_metered"
|
||||
const val DEVICE_CHARGING = "ac"
|
||||
const val DEVICE_BATTERY_NOT_LOW = "battery_not_low"
|
||||
|
||||
const val ENTRY_NON_COMPLETED = "manga_ongoing"
|
||||
const val ENTRY_HAS_UNVIEWED = "manga_fully_read"
|
||||
|
|
|
@ -12,7 +12,7 @@ class StubAnimeSource(
|
|||
override val name: String,
|
||||
) : AnimeSource {
|
||||
|
||||
val isInvalid: Boolean = name.isBlank() || lang.isBlank()
|
||||
private val isInvalid: Boolean = name.isBlank() || lang.isBlank()
|
||||
|
||||
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
|
||||
throw AnimeSourceNotInstalledException()
|
||||
|
|
|
@ -13,13 +13,13 @@ class StubMangaSource(
|
|||
override val name: String,
|
||||
) : MangaSource {
|
||||
|
||||
val isInvalid: Boolean = name.isBlank() || lang.isBlank()
|
||||
private val isInvalid: Boolean = name.isBlank() || lang.isBlank()
|
||||
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
throw SourceNotInstalledException()
|
||||
}
|
||||
|
||||
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails"))
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return Observable.error(SourceNotInstalledException())
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ class StubMangaSource(
|
|||
throw SourceNotInstalledException()
|
||||
}
|
||||
|
||||
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList"))
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return Observable.error(SourceNotInstalledException())
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ class StubMangaSource(
|
|||
throw SourceNotInstalledException()
|
||||
}
|
||||
|
||||
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getPageList"))
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return Observable.error(SourceNotInstalledException())
|
||||
}
|
||||
|
|
|
@ -6,8 +6,10 @@ import org.junit.jupiter.api.Test
|
|||
import org.junit.jupiter.api.parallel.Execution
|
||||
import org.junit.jupiter.api.parallel.ExecutionMode
|
||||
import tachiyomi.domain.items.episode.model.Episode
|
||||
import java.time.Duration
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@Execution(ExecutionMode.CONCURRENT)
|
||||
class SetAnimeFetchIntervalTest {
|
||||
|
@ -22,49 +24,34 @@ class SetAnimeFetchIntervalTest {
|
|||
|
||||
@Test
|
||||
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..1).forEach {
|
||||
val duration = Duration.ofHours(10)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..2).map {
|
||||
episodeWithTime(episode, 10.hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 7 when 5 episodes in 1 day`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(10)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..5).map {
|
||||
episodeWithTime(episode, 10.hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 7 when 7 episodes in 48 hours, 2 day`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..2).forEach {
|
||||
val duration = Duration.ofHours(24L)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
}
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(48L)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..2).map {
|
||||
episodeWithTime(episode, 24.hours)
|
||||
} + (1..5).map {
|
||||
episodeWithTime(episode, 48.hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns default of 1 day when interval less than 1`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(15L * it)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..5).map {
|
||||
episodeWithTime(episode, (15 * it).hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
|
||||
}
|
||||
|
@ -72,61 +59,46 @@ class SetAnimeFetchIntervalTest {
|
|||
// Normal interval calculation
|
||||
@Test
|
||||
fun `calculateInterval returns 1 when 5 episodes in 120 hours, 5 days`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(24L * it)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..5).map {
|
||||
episodeWithTime(episode, (24 * it).hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 2 when 5 episodes in 240 hours, 10 days`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(48L * it)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..5).map {
|
||||
episodeWithTime(episode, (48 * it).hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns floored value when interval is decimal`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(25L * it)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..5).map {
|
||||
episodeWithTime(episode, (25 * it).hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 1 when 5 episodes in 215 hours, 5 days`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(43L * it)
|
||||
val newEpisode = episodeAddTime(episode, duration)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..5).map {
|
||||
episodeWithTime(episode, (43 * it).hours)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns interval based on fetch time if upload time not available`() {
|
||||
val episodes = mutableListOf<Episode>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(25L * it)
|
||||
val newEpisode = episodeAddTime(episode, duration).copy(dateUpload = 0L)
|
||||
episodes.add(newEpisode)
|
||||
val episodes = (1..5).map {
|
||||
episodeWithTime(episode, (25 * it).hours).copy(dateUpload = 0L)
|
||||
}
|
||||
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
private fun episodeAddTime(episode: Episode, duration: Duration): Episode {
|
||||
val newTime = testTime.plus(duration).toEpochSecond() * 1000
|
||||
private fun episodeWithTime(episode: Episode, duration: Duration): Episode {
|
||||
val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
|
||||
return episode.copy(dateFetch = newTime, dateUpload = newTime)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,10 @@ import org.junit.jupiter.api.Test
|
|||
import org.junit.jupiter.api.parallel.Execution
|
||||
import org.junit.jupiter.api.parallel.ExecutionMode
|
||||
import tachiyomi.domain.items.chapter.model.Chapter
|
||||
import java.time.Duration
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@Execution(ExecutionMode.CONCURRENT)
|
||||
class SetMangaFetchIntervalTest {
|
||||
|
@ -22,49 +24,34 @@ class SetMangaFetchIntervalTest {
|
|||
|
||||
@Test
|
||||
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..1).forEach {
|
||||
val duration = Duration.ofHours(10)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..2).map {
|
||||
chapterWithTime(chapter, 10.hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 7 when 5 chapters in 1 day`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(10)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..5).map {
|
||||
chapterWithTime(chapter, 10.hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 7 when 7 chapters in 48 hours, 2 day`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..2).forEach {
|
||||
val duration = Duration.ofHours(24L)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
}
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(48L)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..2).map {
|
||||
chapterWithTime(chapter, 24.hours)
|
||||
} + (1..5).map {
|
||||
chapterWithTime(chapter, 48.hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns default of 1 day when interval less than 1`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(15L * it)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..5).map {
|
||||
chapterWithTime(chapter, (15 * it).hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
|
||||
}
|
||||
|
@ -72,61 +59,46 @@ class SetMangaFetchIntervalTest {
|
|||
// Normal interval calculation
|
||||
@Test
|
||||
fun `calculateInterval returns 1 when 5 chapters in 120 hours, 5 days`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(24L * it)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..5).map {
|
||||
chapterWithTime(chapter, (24 * it).hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 2 when 5 chapters in 240 hours, 10 days`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(48L * it)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..5).map {
|
||||
chapterWithTime(chapter, (48 * it).hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns floored value when interval is decimal`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(25L * it)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..5).map {
|
||||
chapterWithTime(chapter, (25 * it).hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns 1 when 5 chapters in 215 hours, 5 days`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(43L * it)
|
||||
val newChapter = chapterAddTime(chapter, duration)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..5).map {
|
||||
chapterWithTime(chapter, (43 * it).hours)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInterval returns interval based on fetch time if upload time not available`() {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
(1..5).forEach {
|
||||
val duration = Duration.ofHours(25L * it)
|
||||
val newChapter = chapterAddTime(chapter, duration).copy(dateUpload = 0L)
|
||||
chapters.add(newChapter)
|
||||
val chapters = (1..5).map {
|
||||
chapterWithTime(chapter, (25 * it).hours).copy(dateUpload = 0L)
|
||||
}
|
||||
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
|
||||
}
|
||||
|
||||
private fun chapterAddTime(chapter: Chapter, duration: Duration): Chapter {
|
||||
val newTime = testTime.plus(duration).toEpochSecond() * 1000
|
||||
private fun chapterWithTime(chapter: Chapter, duration: Duration): Chapter {
|
||||
val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
|
||||
return chapter.copy(dateFetch = newTime, dateUpload = newTime)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,4 @@ kotlin.mpp.androidSourceSetLayoutVersion=2
|
|||
android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
|
||||
android.experimental.useDefaultDebugSigningConfigForProfileableBuildtypes=true
|
||||
#android.nonFinalResIds=false
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
[versions]
|
||||
agp_version = "8.0.2"
|
||||
agp_version = "8.1.1"
|
||||
lifecycle_version = "2.6.1"
|
||||
paging_version = "3.2.0"
|
||||
|
||||
[libraries]
|
||||
gradle = { module = "com.android.tools.build:gradle", version.ref = "agp_version" }
|
||||
|
||||
annotation = "androidx.annotation:annotation:1.7.0-alpha03"
|
||||
annotation = "androidx.annotation:annotation:1.7.0-rc01"
|
||||
appcompat = "androidx.appcompat:appcompat:1.6.1"
|
||||
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||
corektx = "androidx.core:core-ktx:1.12.0-beta01"
|
||||
corektx = "androidx.core:core-ktx:1.12.0-rc01"
|
||||
splashscreen = "androidx.core:core-splashscreen:1.0.1"
|
||||
recyclerview = "androidx.recyclerview:recyclerview:1.3.1"
|
||||
viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01"
|
||||
|
@ -28,7 +28,7 @@ guava = "com.google.guava:guava:32.1.2-android"
|
|||
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
|
||||
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" }
|
||||
|
||||
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-beta02"
|
||||
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-beta04"
|
||||
test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha01"
|
||||
test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01"
|
||||
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[versions]
|
||||
compiler = "1.5.1"
|
||||
compose-bom = "2023.07.00-alpha02"
|
||||
accompanist = "0.31.5-beta"
|
||||
compiler = "1.5.2"
|
||||
compose-bom = "2023.09.00-alpha02"
|
||||
accompanist = "0.33.1-alpha"
|
||||
|
||||
[libraries]
|
||||
activity = "androidx.activity:activity-compose:1.7.2"
|
||||
|
|
|
@ -12,7 +12,6 @@ richtext = "0.17.0"
|
|||
desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
|
||||
android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2"
|
||||
|
||||
rxandroid = "io.reactivex:rxandroid:1.2.1"
|
||||
rxjava = "io.reactivex:rxjava:1.3.8"
|
||||
flowreactivenetwork = "ru.beryukhov:flowreactivenetwork:1.0.4"
|
||||
|
||||
|
@ -35,7 +34,7 @@ sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref =
|
|||
sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" }
|
||||
sqlite-android = "com.github.requery:sqlite-android:3.42.0"
|
||||
|
||||
preferencektx = "androidx.preference:preference-ktx:1.2.0"
|
||||
preferencektx = "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
injekt-core = "com.github.inorichi.injekt:injekt-core:65b0440"
|
||||
|
||||
|
@ -57,14 +56,14 @@ flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013
|
|||
photoview = "com.github.chrisbanes:PhotoView:2.3.0"
|
||||
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
|
||||
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
|
||||
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.0.4"
|
||||
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.0.6"
|
||||
compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0"
|
||||
|
||||
swipe = "me.saket.swipe:swipe:1.2.0"
|
||||
|
||||
logcat = "com.squareup.logcat:logcat:0.1"
|
||||
|
||||
acra-http = "ch.acra:acra-http:5.11.0"
|
||||
acra-http = "ch.acra:acra-http:5.11.1"
|
||||
|
||||
aboutLibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlib_version" }
|
||||
aboutLibraries-gradle = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutlib_version" }
|
||||
|
@ -84,7 +83,7 @@ sqldelight-gradle = { module = "app.cash.sqldelight:gradle-plugin", version.ref
|
|||
|
||||
junit = "org.junit.jupiter:junit-jupiter:5.10.0"
|
||||
kotest-assertions = "io.kotest:kotest-assertions-core:5.6.2"
|
||||
mockk = "io.mockk:mockk:1.13.5"
|
||||
mockk = "io.mockk:mockk:1.13.7"
|
||||
|
||||
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
|
||||
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
|
||||
|
@ -101,7 +100,6 @@ seeker = "io.github.2307vivek:seeker:1.1.1"
|
|||
truetypeparser = "io.github.yubyf:truetypeparser-light:2.1.4"
|
||||
|
||||
[bundles]
|
||||
reactivex = ["rxandroid", "rxjava"]
|
||||
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
|
||||
js-engine = ["quickjs-android"]
|
||||
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]
|
||||
|
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
@ -252,6 +252,7 @@
|
|||
<item quantity="one">%d second</item>
|
||||
<item quantity="other">%d seconds</item>
|
||||
</plurals>
|
||||
<string name="licensed_manga_chapters_error">Licensed - No items to show</string>
|
||||
<string name="no_next_episode">Next Episode not found!</string>
|
||||
<string name="label_storage">Storage</string>
|
||||
<string name="label_history">Manga</string>
|
||||
|
|
|
@ -253,7 +253,6 @@
|
|||
<string name="connected_to_wifi">Only on Wi-Fi</string>
|
||||
<string name="network_not_metered">Only on unmetered network</string>
|
||||
<string name="charging">When charging</string>
|
||||
<string name="battery_not_low">When battery not low</string>
|
||||
<string name="restrictions">Restrictions: %s</string>
|
||||
|
||||
<string name="pref_library_update_manga_restriction">Skip updating entries</string>
|
||||
|
@ -313,6 +312,7 @@
|
|||
<string name="ext_installer_packageinstaller" translatable="false">PackageInstaller</string>
|
||||
<string name="ext_installer_shizuku" translatable="false">Shizuku</string>
|
||||
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
|
||||
<string name="ext_installer_private" translatable="false">Private</string>
|
||||
<string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
|
||||
|
||||
<!-- Reader section -->
|
||||
|
@ -489,6 +489,7 @@
|
|||
<string name="creating_backup_error">Backup failed</string>
|
||||
<string name="missing_storage_permission">Storage permissions not granted</string>
|
||||
<string name="empty_backup_error">No library entries to back up</string>
|
||||
<string name="create_backup_file_error">Couldn\'t create a backup file</string>
|
||||
<string name="restore_miui_warning">Backup/restore may not function properly if MIUI Optimization is disabled.</string>
|
||||
<string name="restore_in_progress">Restore is already in progress</string>
|
||||
<string name="restoring_backup">Restoring backup</string>
|
||||
|
@ -594,8 +595,6 @@
|
|||
<!-- missing prompt after Compose rewrite #7901 -->
|
||||
<string name="no_more_results">No more results</string>
|
||||
<string name="no_results_found">No results found</string>
|
||||
<!-- Do not translate "WebView" -->
|
||||
<string name="http_error_hint">Check website in WebView</string>
|
||||
<string name="local_source">Local source</string>
|
||||
<string name="other_source">Other</string>
|
||||
<string name="last_used_source">Last used</string>
|
||||
|
@ -927,4 +926,10 @@
|
|||
<string name="appwidget_updates_description">See your recently updated library entries</string>
|
||||
<string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string>
|
||||
<string name="remove_manga">You are about to remove \"%s\" from your library</string>
|
||||
|
||||
<!-- Common exceptions -->
|
||||
<!-- Do not translate "WebView" -->
|
||||
<string name="exception_http">HTTP %d, check website in WebView</string>
|
||||
<string name="exception_offline">No Internet connection</string>
|
||||
<string name="exception_unknown_host">Couldn\'t reach %s</string>
|
||||
</resources>
|
||||
|
|
|
@ -243,7 +243,6 @@ fun SelectItem(
|
|||
label = { Text(text = label) },
|
||||
value = options[selectedIndex].toString(),
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
|
@ -251,9 +250,7 @@ fun SelectItem(
|
|||
expanded = expanded,
|
||||
)
|
||||
},
|
||||
colors = ExposedDropdownMenuDefaults.textFieldColors(
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
colors = ExposedDropdownMenuDefaults.textFieldColors(),
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
|
|
|
@ -24,13 +24,44 @@ interface AnimeSource {
|
|||
val lang: String
|
||||
get() = ""
|
||||
|
||||
/**
|
||||
* Get the updated details for a anime.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getAnimeDetails(anime: SAnime): SAnime {
|
||||
return fetchAnimeDetails(anime).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the available episodes for a anime.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
|
||||
return fetchEpisodeList(anime).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of videos a episode has. Pages should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*
|
||||
* @param episode the episode.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getVideoList(episode: SEpisode): List<Video> {
|
||||
return fetchVideoList(episode).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a anime.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getAnimeDetails"),
|
||||
)
|
||||
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException("Not used")
|
||||
|
@ -41,7 +72,7 @@ interface AnimeSource {
|
|||
* @param anime the anime to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getEpisodeList"),
|
||||
)
|
||||
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException("Not used")
|
||||
|
@ -53,33 +84,8 @@ interface AnimeSource {
|
|||
* @param episode the episode.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getVideoList"),
|
||||
)
|
||||
fun fetchVideoList(episode: SEpisode): Observable<List<Video>> = Observable.empty()
|
||||
|
||||
/**
|
||||
* [1.x API] Get the updated details for a anime.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getAnimeDetails(anime: SAnime): SAnime {
|
||||
return fetchAnimeDetails(anime).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get all the available episodes for a anime.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
|
||||
return fetchEpisodeList(anime).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get the list of videos a episode has. Videos should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getVideoList(episode: SEpisode): List<Video> {
|
||||
return fetchVideoList(episode).awaitSingle()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,15 +50,16 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
|
|||
open val versionId = 1
|
||||
|
||||
/**
|
||||
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string: sourcename/language/versionId
|
||||
* Note the generated id sets the sign bit to 0.
|
||||
* ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string `"${name.lowercase()}/$lang/$versionId"`.
|
||||
*
|
||||
* The ID is generated by the [generateId] function, which can be reused if needed
|
||||
* to generate outdated IDs for cases where the source name or language needs to
|
||||
* be changed but migrations can be avoided.
|
||||
*
|
||||
* Note: the generated ID sets the sign bit to `0`.
|
||||
*/
|
||||
override val id by lazy {
|
||||
val key = "${name.lowercase()}/$lang/$versionId"
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||
}
|
||||
override val id by lazy { generateId(name, lang, versionId) }
|
||||
|
||||
/**
|
||||
* Headers used for requests.
|
||||
|
@ -71,6 +72,28 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
|
|||
open val client: OkHttpClient
|
||||
get() = network.client
|
||||
|
||||
/**
|
||||
* Generates a unique ID for the source based on the provided [name], [lang] and
|
||||
* [versionId]. It will use the first 16 characters (64 bits) of the MD5 of the string
|
||||
* `"${name.lowercase()}/$lang/$versionId"`.
|
||||
*
|
||||
* Note: the generated ID sets the sign bit to `0`.
|
||||
*
|
||||
* Can be used to generate outdated IDs, such as when the source name or language
|
||||
* needs to be changed but migrations can be avoided.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param name [String] the name of the source
|
||||
* @param lang [String] the language of the source
|
||||
* @param versionId [Int] the version ID of the source
|
||||
* @return a unique ID for the source
|
||||
*/
|
||||
protected fun generateId(name: String, lang: String, versionId: Int): Long {
|
||||
val key = "${name.lowercase()}/$lang/$versionId"
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers builder for requests. Implementations can override this method for custom headers.
|
||||
*/
|
||||
|
@ -222,7 +245,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
|
|||
episodeListParse(response)
|
||||
}
|
||||
} else {
|
||||
Observable.error(Exception("Licensed - No episodes to show"))
|
||||
Observable.error(Exception(LicensedEntryItemsException()))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -432,3 +455,5 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
|
|||
*/
|
||||
override fun getFilterList() = AnimeFilterList()
|
||||
}
|
||||
|
||||
class LicensedEntryItemsException : Exception("Licensed - No items to show")
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue