Merge remote-tracking branch 'upstream/master'

This commit is contained in:
jmir1 2021-07-11 19:41:13 +02:00
commit 257456016b
29 changed files with 642 additions and 173 deletions

View file

@ -134,7 +134,7 @@ dependencies {
implementation(kotlin("reflect", version = BuildPluginsVersion.KOTLIN))
val coroutinesVersion = "1.4.3"
val coroutinesVersion = "1.5.1"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
@ -164,7 +164,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.6.0-beta01")
// UI library
implementation("com.google.android.material:material:1.4.0")
implementation("com.google.android.material:material:1.5.0-alpha01")
"standardImplementation"("com.google.firebase:firebase-core:19.0.0")
@ -185,7 +185,7 @@ dependencies {
implementation("org.conscrypt:conscrypt-android:2.5.2")
// JSON
val kotlinSerializationVersion = "1.2.0"
val kotlinSerializationVersion = "1.2.2"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
implementation("com.google.code.gson:gson:2.8.7")

View file

@ -26,6 +26,7 @@ import coil.decode.ImageDecoderDecoder
import eu.kanade.tachiyomi.data.coil.AnimeCoverFetcher
import eu.kanade.tachiyomi.data.coil.ByteBufferFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.NetworkHelper
@ -111,6 +112,7 @@ open class App : Application(), LifecycleObserver, ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this).apply {
componentRegistry {
add(TachiyomiImageDecoder(this@App.resources))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder(this@App))
} else {

View file

@ -29,7 +29,6 @@ class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-anime/"
private const val COVER_NAME = "cover.jpg"
private val SUPPORTED_FILE_TYPES = setOf("mp4", "m3u8", "mkv")
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
@ -40,18 +39,29 @@ class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource {
input.close()
return null
}
val cover = File("${dir.absolutePath}/${anime.url}", COVER_NAME)
val cover = getCoverFile(File("${dir.absolutePath}/${anime.url}"))
// It might not exist if using the external SD card
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
if (cover != null && cover.exists()) {
// It might not exist if using the external SD card
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
}
return cover
}
/**
* Returns valid cover file inside [parent] directory.
*/
private fun getCoverFile(parent: File): File? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
}
}
private fun getBaseDirectories(context: Context): List<File> {
val c = context.getString(R.string.app_name) + File.separator + "localanime"
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
@ -105,8 +115,8 @@ class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource {
// Try to find the cover
for (dir in baseDirs) {
val cover = File("${dir.absolutePath}/$url", COVER_NAME)
if (cover.exists()) {
val cover = getCoverFile(File("${dir.absolutePath}/$url"))
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
break
}

View file

@ -64,22 +64,25 @@ class AnimelibUpdateNotifier(private val context: Context) {
/**
* Shows the notification containing the currently updating anime and the progress.
*
* @param anime the anime that's being updated.
* @param anime the anime that are being updated.
* @param current the current progress.
* @param total the total progress.
*/
fun showProgressNotification(anime: Anime, current: Int, total: Int) {
val title = if (preferences.hideNotificationContent()) {
context.getString(R.string.notification_check_updates)
fun showProgressNotification(anime: List<Anime>, current: Int, total: Int) {
if (preferences.hideNotificationContent()) {
progressNotificationBuilder
.setContentTitle(context.getString(R.string.notification_check_updates))
.setContentText("($current/$total)")
} else {
anime.title
val updatingText = anime.joinToString("\n") { it.title }
progressNotificationBuilder
.setContentTitle(context.getString(R.string.notification_updating, current, total))
.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
}
context.notificationManager.notify(
Notifications.ID_LIBRARY_PROGRESS,
progressNotificationBuilder
.setContentTitle(title.chop(40))
.setContentText("($current/$total)")
.setProgress(total, current, false)
.build()
)

View file

@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.data.track.UnattendedTrackService
import eu.kanade.tachiyomi.util.episode.NoEpisodesException
import eu.kanade.tachiyomi.util.episode.syncEpisodesWithSource
import eu.kanade.tachiyomi.util.episode.syncEpisodesWithTrackServiceTwoWay
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.shouldDownloadNewEpisodes
import eu.kanade.tachiyomi.util.storage.getUriCompat
@ -47,10 +48,14 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
@ -270,50 +275,76 @@ class AnimelibUpdateService(
* @return an observable delivering the progress of each update.
*/
suspend fun updateEpisodeList() {
val semaphore = Semaphore(5)
val progressCount = AtomicInteger(0)
val newUpdates = mutableListOf<Pair<AnimelibAnime, Array<Episode>>>()
val failedUpdates = mutableListOf<Pair<Anime, String?>>()
var hasDownloads = false
val currentlyUpdatingAnime = CopyOnWriteArrayList<AnimelibAnime>()
val newUpdates = CopyOnWriteArrayList<Pair<AnimelibAnime, Array<Episode>>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val hasDownloads = AtomicBoolean(false)
val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
animeToUpdate.forEach { anime ->
if (updateJob?.isActive != true) {
return
}
withIOContext {
animeToUpdate.groupBy { it.source }
.values
.map { animeInSource ->
async {
semaphore.withPermit {
animeInSource.forEach { anime ->
if (updateJob?.isActive != true) {
return@async
}
notifier.showProgressNotification(anime, progressCount.andIncrement, animeToUpdate.size)
currentlyUpdatingAnime.add(anime)
progressCount.andIncrement
notifier.showProgressNotification(
currentlyUpdatingAnime,
progressCount.get(),
animeToUpdate.size
)
try {
val (newEpisodes, _) = updateAnime(anime)
try {
val (newEpisodes, _) = updateAnime(anime)
if (newEpisodes.isNotEmpty()) {
if (anime.shouldDownloadNewEpisodes(db, preferences)) {
downloadEpisodes(anime, newEpisodes)
hasDownloads = true
if (newEpisodes.isNotEmpty()) {
if (anime.shouldDownloadNewEpisodes(db, preferences)) {
downloadEpisodes(anime, newEpisodes)
hasDownloads.set(true)
}
// Convert to the manga that contains new chapters
newUpdates.add(anime to newEpisodes.sortedByDescending { ep -> ep.source_order }.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = if (e is NoEpisodesException) {
getString(R.string.no_chapters_error)
} else {
e.message
}
failedUpdates.add(anime to errorMessage)
}
if (preferences.autoUpdateTrackers()) {
updateTrackings(anime, loggedServices)
}
currentlyUpdatingAnime.remove(anime)
notifier.showProgressNotification(
currentlyUpdatingAnime,
progressCount.get(),
animeToUpdate.size
)
}
}
}
// Convert to the anime that contains new chapters
newUpdates.add(anime to newEpisodes.sortedByDescending { ep -> ep.source_order }.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = if (e is NoEpisodesException) {
getString(R.string.no_chapters_error)
} else {
e.message
}
failedUpdates.add(anime to errorMessage)
}
if (preferences.autoUpdateTrackers()) {
updateTrackings(anime, loggedServices)
}
.awaitAll()
}
notifier.cancelProgressNotification()
if (newUpdates.isNotEmpty()) {
notifier.showUpdateNotifications(newUpdates)
if (hasDownloads) {
if (hasDownloads.get()) {
AnimeDownloadService.start(this)
}
}
@ -369,30 +400,58 @@ class AnimelibUpdateService(
}
private suspend fun updateCovers() {
var progressCount = 0
val semaphore = Semaphore(5)
val progressCount = AtomicInteger(0)
val currentlyUpdatingAnime = CopyOnWriteArrayList<AnimelibAnime>()
animeToUpdate.forEach { anime ->
if (updateJob?.isActive != true) {
return
}
withIOContext {
animeToUpdate.groupBy { it.source }
.values
.map { animeInSource ->
async {
semaphore.withPermit {
animeInSource.forEach { anime ->
if (updateJob?.isActive != true) {
return@async
}
notifier.showProgressNotification(anime, progressCount++, animeToUpdate.size)
currentlyUpdatingAnime.add(anime)
progressCount.andIncrement
notifier.showProgressNotification(
currentlyUpdatingAnime,
progressCount.get(),
animeToUpdate.size
)
sourceManager.get(anime.source)?.let { source ->
try {
val networkAnime = source.getAnimeDetails(anime.toAnimeInfo())
val sAnime = networkAnime.toSAnime()
anime.prepUpdateCover(coverCache, sAnime, true)
sAnime.thumbnail_url?.let {
anime.thumbnail_url = it
db.insertAnime(anime).executeAsBlocking()
sourceManager.get(anime.source)?.let { source ->
try {
val networkAnime =
source.getAnimeDetails(anime.toAnimeInfo())
val sAnime = networkAnime.toSAnime()
anime.prepUpdateCover(coverCache, sAnime, true)
sAnime.thumbnail_url?.let {
anime.thumbnail_url = it
db.insertAnime(anime).executeAsBlocking()
}
} catch (e: Throwable) {
// Ignore errors and continue
Timber.e(e)
}
}
currentlyUpdatingAnime.remove(anime)
notifier.showProgressNotification(
currentlyUpdatingAnime,
progressCount.get(),
animeToUpdate.size
)
}
}
}
} catch (e: Throwable) {
// Ignore errors and continue
Timber.e(e)
}
}
.awaitAll()
}
coverCache.clearMemoryCache()
notifier.cancelProgressNotification()
}
@ -410,8 +469,7 @@ class AnimelibUpdateService(
return
}
// Notify anime that will update.
notifier.showProgressNotification(anime, progressCount++, animeToUpdate.size)
notifier.showProgressNotification(listOf(anime), progressCount++, animeToUpdate.size)
// Update the tracking details.
updateTrackings(anime, loggedServices)

View file

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.data.coil
import android.content.res.Resources
import android.os.Build
import androidx.core.graphics.drawable.toDrawable
import coil.bitmap.BitmapPool
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.decode.Options
import coil.size.Size
import eu.kanade.tachiyomi.util.system.ImageUtil
import okio.BufferedSource
import tachiyomi.decoder.ImageDecoder
/**
* A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
*/
class TachiyomiImageDecoder(private val resources: Resources) : Decoder {
override fun handles(source: BufferedSource, mimeType: String?): Boolean {
val type = source.peek().inputStream().use {
ImageUtil.findImageType(it)
}
return when (type) {
ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL -> true
ImageUtil.ImageType.HEIF -> Build.VERSION.SDK_INT < Build.VERSION_CODES.O
else -> false
}
}
override suspend fun decode(
pool: BitmapPool,
source: BufferedSource,
size: Size,
options: Options
): DecodeResult {
val decoder = source.use {
ImageDecoder.newInstance(it.inputStream())
}
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder." }
val bitmap = decoder.decode(rgb565 = options.allowRgb565)
decoder.recycle()
check(bitmap != null) { "Failed to decode image." }
return DecodeResult(
drawable = bitmap.toDrawable(resources),
isSampled = false
)
}
}

View file

@ -64,22 +64,25 @@ class LibraryUpdateNotifier(private val context: Context) {
/**
* Shows the notification containing the currently updating manga and the progress.
*
* @param manga the manga that's being updated.
* @param manga the manga that are being updated.
* @param current the current progress.
* @param total the total progress.
*/
fun showProgressNotification(manga: Manga, current: Int, total: Int) {
val title = if (preferences.hideNotificationContent()) {
context.getString(R.string.notification_check_updates)
fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) {
if (preferences.hideNotificationContent()) {
progressNotificationBuilder
.setContentTitle(context.getString(R.string.notification_check_updates))
.setContentText("($current/$total)")
} else {
manga.title
val updatingText = manga.joinToString("\n") { it.title }
progressNotificationBuilder
.setContentTitle(context.getString(R.string.notification_updating, current, total))
.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
}
context.notificationManager.notify(
Notifications.ID_LIBRARY_PROGRESS,
progressNotificationBuilder
.setContentTitle(title.chop(40))
.setContentText("($current/$total)")
.setProgress(total, current, false)
.build()
)

View file

@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.storage.getUriCompat
@ -47,10 +48,14 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
@ -270,50 +275,76 @@ class LibraryUpdateService(
* @return an observable delivering the progress of each update.
*/
suspend fun updateChapterList() {
val semaphore = Semaphore(5)
val progressCount = AtomicInteger(0)
val newUpdates = mutableListOf<Pair<LibraryManga, Array<Chapter>>>()
val failedUpdates = mutableListOf<Pair<Manga, String?>>()
var hasDownloads = false
val currentlyUpdatingManga = CopyOnWriteArrayList<LibraryManga>()
val newUpdates = CopyOnWriteArrayList<Pair<LibraryManga, Array<Chapter>>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
mangaToUpdate.forEach { manga ->
if (updateJob?.isActive != true) {
return
}
withIOContext {
mangaToUpdate.groupBy { it.source }
.values
.map { mangaInSource ->
async {
semaphore.withPermit {
mangaInSource.forEach { manga ->
if (updateJob?.isActive != true) {
return@async
}
notifier.showProgressNotification(manga, progressCount.andIncrement, mangaToUpdate.size)
currentlyUpdatingManga.add(manga)
progressCount.andIncrement
notifier.showProgressNotification(
currentlyUpdatingManga,
progressCount.get(),
mangaToUpdate.size
)
try {
val (newChapters, _) = updateManga(manga)
try {
val (newChapters, _) = updateManga(manga)
if (newChapters.isNotEmpty()) {
if (manga.shouldDownloadNewChapters(db, preferences)) {
downloadChapters(manga, newChapters)
hasDownloads = true
if (newChapters.isNotEmpty()) {
if (manga.shouldDownloadNewChapters(db, preferences)) {
downloadChapters(manga, newChapters)
hasDownloads.set(true)
}
// Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.sortedByDescending { ch -> ch.source_order }.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = if (e is NoChaptersException) {
getString(R.string.no_chapters_error)
} else {
e.message
}
failedUpdates.add(manga to errorMessage)
}
if (preferences.autoUpdateTrackers()) {
updateTrackings(manga, loggedServices)
}
currentlyUpdatingManga.remove(manga)
notifier.showProgressNotification(
currentlyUpdatingManga,
progressCount.get(),
mangaToUpdate.size
)
}
}
}
// Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.sortedByDescending { ch -> ch.source_order }.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = if (e is NoChaptersException) {
getString(R.string.no_chapters_error)
} else {
e.message
}
failedUpdates.add(manga to errorMessage)
}
if (preferences.autoUpdateTrackers()) {
updateTrackings(manga, loggedServices)
}
.awaitAll()
}
notifier.cancelProgressNotification()
if (newUpdates.isNotEmpty()) {
notifier.showUpdateNotifications(newUpdates)
if (hasDownloads) {
if (hasDownloads.get()) {
DownloadService.start(this)
}
}
@ -369,29 +400,56 @@ class LibraryUpdateService(
}
private suspend fun updateCovers() {
var progressCount = 0
val semaphore = Semaphore(5)
val progressCount = AtomicInteger(0)
val currentlyUpdatingManga = CopyOnWriteArrayList<LibraryManga>()
mangaToUpdate.forEach { manga ->
if (updateJob?.isActive != true) {
return
}
withIOContext {
mangaToUpdate.groupBy { it.source }
.values
.map { mangaInSource ->
async {
semaphore.withPermit {
mangaInSource.forEach { manga ->
if (updateJob?.isActive != true) {
return@async
}
notifier.showProgressNotification(manga, progressCount++, mangaToUpdate.size)
currentlyUpdatingManga.add(manga)
progressCount.andIncrement
notifier.showProgressNotification(
currentlyUpdatingManga,
progressCount.get(),
mangaToUpdate.size
)
sourceManager.get(manga.source)?.let { source ->
try {
val networkManga = source.getMangaDetails(manga.toMangaInfo())
val sManga = networkManga.toSManga()
manga.prepUpdateCover(coverCache, sManga, true)
sManga.thumbnail_url?.let {
manga.thumbnail_url = it
db.insertManga(manga).executeAsBlocking()
sourceManager.get(manga.source)?.let { source ->
try {
val networkManga =
source.getMangaDetails(manga.toMangaInfo())
val sManga = networkManga.toSManga()
manga.prepUpdateCover(coverCache, sManga, true)
sManga.thumbnail_url?.let {
manga.thumbnail_url = it
db.insertManga(manga).executeAsBlocking()
}
} catch (e: Throwable) {
// Ignore errors and continue
Timber.e(e)
}
}
currentlyUpdatingManga.remove(manga)
notifier.showProgressNotification(
currentlyUpdatingManga,
progressCount.get(),
mangaToUpdate.size
)
}
}
}
} catch (e: Throwable) {
// Ignore errors and continue
Timber.e(e)
}
}
.awaitAll()
}
coverCache.clearMemoryCache()
@ -411,8 +469,7 @@ class LibraryUpdateService(
return
}
// Notify manga that will update.
notifier.showProgressNotification(manga, progressCount++, mangaToUpdate.size)
notifier.showProgressNotification(listOf(manga), progressCount++, mangaToUpdate.size)
// Update the tracking details.
updateTrackings(manga, loggedServices)

View file

@ -28,6 +28,7 @@ object PreferenceValues {
MIDNIGHT_DUSK(R.string.theme_midnightdusk),
STRAWBERRY_DAIQUIRI(R.string.theme_strawberrydaiquiri),
YOTSUBA(R.string.theme_yotsuba),
YINYANG(R.string.theme_yinyang),
// Deprecated
DARK_BLUE(null),

View file

@ -22,11 +22,23 @@ internal class AnimeExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
private var requiresFallbackSource = false
suspend fun findExtensions(): List<AnimeExtension.Available> {
return withIOContext {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
val response = try {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
} catch (e: Throwable) {
requiresFallbackSource = true
networkService.client
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
.await()
}
response
.parseAs<JsonArray>()
.let { parseResponse(it) }
}
@ -59,7 +71,7 @@ internal class AnimeExtensionGithubApi {
return json
.filter { element ->
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content
val libVersion = versionName.substringBeforeLast('.').toInt()
val libVersion = versionName.substringBeforeLast('.').toDouble()
libVersion >= AnimeExtensionLoader.LIB_VERSION_MIN && libVersion <= AnimeExtensionLoader.LIB_VERSION_MAX
}
.map { element ->
@ -70,18 +82,23 @@ internal class AnimeExtensionGithubApi {
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}"
val icon = "${getUrlPrefix()}icon/${apkName.replace(".apk", ".png")}"
AnimeExtension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
}
}
fun getApkUrl(extension: AnimeExtension.Available): String {
return "${REPO_URL_PREFIX}apk/${extension.apkName}"
return "${getUrlPrefix()}apk/${extension.apkName}"
}
companion object {
const val BASE_URL = "https://raw.githubusercontent.com/"
const val REPO_URL_PREFIX = "${BASE_URL}jmir1/aniyomi-extensions/repo/"
private fun getUrlPrefix(): String {
return when (requiresFallbackSource) {
true -> FALLBACK_REPO_URL_PREFIX
false -> REPO_URL_PREFIX
}
}
}
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/jmir1/aniyomi-extensions/repo/"
private const val FALLBACK_REPO_URL_PREFIX = "https://cdn.jsdelivr.net/gh/jmir1/aniyomi-extensions@repo/"

View file

@ -22,11 +22,23 @@ internal class ExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
private var requiresFallbackSource = false
suspend fun findExtensions(): List<Extension.Available> {
return withIOContext {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
val response = try {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
} catch (e: Throwable) {
requiresFallbackSource = true
networkService.client
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
.await()
}
response
.parseAs<JsonArray>()
.let { parseResponse(it) }
}
@ -70,18 +82,23 @@ internal class ExtensionGithubApi {
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}"
val icon = "${getUrlPrefix()}icon/${apkName.replace(".apk", ".png")}"
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "${REPO_URL_PREFIX}apk/${extension.apkName}"
return "${getUrlPrefix()}apk/${extension.apkName}"
}
companion object {
const val BASE_URL = "https://raw.githubusercontent.com/"
const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/"
private fun getUrlPrefix(): String {
return when (requiresFallbackSource) {
true -> FALLBACK_REPO_URL_PREFIX
false -> REPO_URL_PREFIX
}
}
}
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
private const val FALLBACK_REPO_URL_PREFIX = "https://cdn.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/"

View file

@ -29,7 +29,6 @@ class LocalSource(private val context: Context) : CatalogueSource {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val COVER_NAME = "cover.jpg"
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
@ -40,18 +39,29 @@ class LocalSource(private val context: Context) : CatalogueSource {
input.close()
return null
}
val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
val cover = getCoverFile(File("${dir.absolutePath}/${manga.url}"))
// It might not exist if using the external SD card
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
if (cover != null && cover.exists()) {
// It might not exist if using the external SD card
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
}
return cover
}
/**
* Returns valid cover file inside [parent] directory.
*/
private fun getCoverFile(parent: File): File? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
}
}
private fun getBaseDirectories(context: Context): List<File> {
val c = context.getString(R.string.app_name) + File.separator + "local"
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
@ -105,8 +115,8 @@ class LocalSource(private val context: Context) : CatalogueSource {
// Try to find the cover
for (dir in baseDirs) {
val cover = File("${dir.absolutePath}/$url", COVER_NAME)
if (cover.exists()) {
val cover = getCoverFile(File("${dir.absolutePath}/$url"))
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
break
}

View file

@ -655,6 +655,29 @@ class AnimeController :
}
}
/**
* Performs a genre search using the provided genre name.
*
* @param genreName the search genre to the parent controller
*/
fun performGenreSearch(genreName: String) {
if (router.backstackSize < 2) {
return
}
val previousController = router.backstack[router.backstackSize - 2].controller
val presenterSource = presenter.source
if (previousController is BrowseAnimeSourceController &&
presenterSource is AnimeHttpSource
) {
router.handleBack()
previousController.searchWithGenre(genreName)
} else {
performSearch(genreName)
}
}
private fun shareCover() {
try {
val activity = activity!!

View file

@ -262,11 +262,11 @@ class AnimeInfoHeaderAdapter(
if (!anime.genre.isNullOrBlank()) {
binding.mangaGenresTagsCompactChips.setChips(
anime.getGenres(),
controller::performSearch
controller::performGenreSearch
)
binding.mangaGenresTagsFullChips.setChips(
anime.getGenres(),
controller::performSearch
controller::performGenreSearch
)
} else {
binding.mangaGenresTagsCompactChips.isVisible = false

View file

@ -56,6 +56,9 @@ abstract class BaseThemedActivity : AppCompatActivity() {
PreferenceValues.AppTheme.YOTSUBA -> {
resIds += R.style.Theme_Tachiyomi_Yotsuba
}
PreferenceValues.AppTheme.YINYANG -> {
resIds += R.style.Theme_Tachiyomi_YinYang
}
else -> {
resIds += R.style.Theme_Tachiyomi
}

View file

@ -60,7 +60,7 @@ class BrowseController :
binding.pager.adapter = adapter
if (toExtensions) {
binding.pager.currentItem = EXTENSIONS_CONTROLLER
binding.pager.currentItem = ANIMEEXTENSIONS_CONTROLLER
}
}

View file

@ -24,6 +24,7 @@ import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.data.database.models.Anime
@ -335,6 +336,54 @@ open class BrowseAnimeSourceController(bundle: Bundle) :
presenter.restartPager(newQuery)
}
/**
* Attempts to restart the request with a new genre-filtered query.
* If the genre name can't be found the filters,
* the standard searchWithQuery search method is used instead.
*
* @param genreName the name of the genre
*/
fun searchWithGenre(genreName: String) {
presenter.sourceFilters = presenter.source.getFilterList()
var filterList: AnimeFilterList? = null
filter@ for (sourceFilter in presenter.sourceFilters) {
if (sourceFilter is AnimeFilter.Group<*>) {
for (filter in sourceFilter.state) {
if (filter is AnimeFilter<*> && filter.name.equals(genreName, true)) {
when (filter) {
is AnimeFilter.TriState -> filter.state = 1
is AnimeFilter.CheckBox -> filter.state = true
}
filterList = presenter.sourceFilters
break@filter
}
}
} else if (sourceFilter is AnimeFilter.Select<*>) {
val index = sourceFilter.values.filterIsInstance<String>()
.indexOfFirst { it.equals(genreName, true) }
if (index != -1) {
sourceFilter.state = index
filterList = presenter.sourceFilters
break
}
}
}
if (filterList != null) {
filterSheet?.setFilters(presenter.filterItems)
showProgressBar()
adapter?.clear()
presenter.restartPager("", filterList)
} else {
searchWithQuery(genreName)
}
}
/**
* Called from the presenter when the network request is received.
*

View file

@ -29,6 +29,7 @@ import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.databinding.SourceControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.FabController
@ -335,6 +336,54 @@ open class BrowseSourceController(bundle: Bundle) :
presenter.restartPager(newQuery)
}
/**
* Attempts to restart the request with a new genre-filtered query.
* If the genre name can't be found the filters,
* the standard searchWithQuery search method is used instead.
*
* @param genreName the name of the genre
*/
fun searchWithGenre(genreName: String) {
presenter.sourceFilters = presenter.source.getFilterList()
var filterList: FilterList? = null
filter@ for (sourceFilter in presenter.sourceFilters) {
if (sourceFilter is Filter.Group<*>) {
for (filter in sourceFilter.state) {
if (filter is Filter<*> && filter.name.equals(genreName, true)) {
when (filter) {
is Filter.TriState -> filter.state = 1
is Filter.CheckBox -> filter.state = true
}
filterList = presenter.sourceFilters
break@filter
}
}
} else if (sourceFilter is Filter.Select<*>) {
val index = sourceFilter.values.filterIsInstance<String>()
.indexOfFirst { it.equals(genreName, true) }
if (index != -1) {
sourceFilter.state = index
filterList = presenter.sourceFilters
break
}
}
}
if (filterList != null) {
filterSheet?.setFilters(presenter.filterItems)
showProgressBar()
adapter?.clear()
presenter.restartPager("", filterList)
} else {
searchWithQuery(genreName)
}
}
/**
* Called from the presenter when the network request is received.
*

View file

@ -19,7 +19,6 @@ import com.bluelinelabs.conductor.Conductor
import com.bluelinelabs.conductor.Controller
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.Router
import com.bluelinelabs.conductor.RouterTransaction
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.behavior.HideBottomViewOnScrollBehavior
import com.google.android.material.navigation.NavigationBarView
@ -44,6 +43,7 @@ import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.animesource.browse.BrowseAnimeSourceController
import eu.kanade.tachiyomi.ui.browse.animesource.globalsearch.GlobalAnimeSearchController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.download.DownloadTabsController
@ -331,7 +331,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
router.popToRoot()
}
setSelectedNavItem(R.id.nav_browse)
router.pushController(BrowseController(true).withFadeTransaction())
router.pushController(BrowseController(toExtensions = true).withFadeTransaction())
}
SHORTCUT_MANGA -> {
val extras = intent.extras ?: return false
@ -339,7 +339,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
router.popToRoot()
}
setSelectedNavItem(R.id.nav_library)
router.pushController(RouterTransaction.with(MangaController(extras)))
router.pushController(MangaController(extras).withFadeTransaction())
}
SHORTCUT_ANIME -> {
val extras = intent.extras ?: return false
@ -347,21 +347,21 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
router.popToRoot()
}
setSelectedNavItem(R.id.nav_animelib)
router.pushController(RouterTransaction.with(AnimeController(extras)))
router.pushController(AnimeController(extras).withFadeTransaction())
}
SHORTCUT_DOWNLOADS -> {
if (router.backstackSize > 1) {
router.popToRoot()
}
setSelectedNavItem(R.id.nav_updates)
router.pushController(RouterTransaction.with(MangaDownloadController()))
setSelectedNavItem(R.id.nav_more)
router.pushController(MangaDownloadController().withFadeTransaction())
}
SHORTCUT_ANIME_DOWNLOADS -> {
if (router.backstackSize > 1) {
router.popToRoot()
}
setSelectedNavItem(R.id.nav_updates)
router.pushController(RouterTransaction.with(AnimeDownloadController()))
setSelectedNavItem(R.id.nav_more)
router.pushController(AnimeDownloadController().withFadeTransaction())
}
Intent.ACTION_SEARCH, Intent.ACTION_SEND, "com.google.android.gms.actions.SEARCH_ACTION" -> {
// If the intent match the "standard" Android search intent
@ -378,14 +378,24 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
}
INTENT_SEARCH -> {
val query = intent.getStringExtra(INTENT_SEARCH_QUERY)
val filter = intent.getStringExtra(INTENT_SEARCH_FILTER)
if (query != null && query.isNotEmpty()) {
val filter = intent.getStringExtra(INTENT_SEARCH_FILTER)
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(GlobalSearchController(query, filter).withFadeTransaction())
}
}
INTENT_ANIMESEARCH -> {
val query = intent.getStringExtra(INTENT_SEARCH_QUERY)
if (query != null && query.isNotEmpty()) {
val filter = intent.getStringExtra(INTENT_SEARCH_FILTER)
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(GlobalAnimeSearchController(query, filter).withFadeTransaction())
}
}
else -> {
isHandlingShortcut = false
return false
@ -576,6 +586,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
const val SHORTCUT_EXTENSIONS = "eu.kanade.tachiyomi.EXTENSIONS"
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_ANIMESEARCH = "eu.kanade.tachiyomi.ANIMESEARCH"
const val INTENT_SEARCH_QUERY = "query"
const val INTENT_SEARCH_FILTER = "filter"
}

View file

@ -638,6 +638,29 @@ class MangaController :
}
}
/**
* Performs a genre search using the provided genre name.
*
* @param genreName the search genre to the parent controller
*/
fun performGenreSearch(genreName: String) {
if (router.backstackSize < 2) {
return
}
val previousController = router.backstack[router.backstackSize - 2].controller
val presenterSource = presenter.source
if (previousController is BrowseSourceController &&
presenterSource is HttpSource
) {
router.handleBack()
previousController.searchWithGenre(genreName)
} else {
performSearch(genreName)
}
}
private fun shareCover() {
try {
val activity = activity!!

View file

@ -262,11 +262,11 @@ class MangaInfoHeaderAdapter(
if (!manga.genre.isNullOrBlank()) {
binding.mangaGenresTagsCompactChips.setChips(
manga.getGenres(),
controller::performSearch
controller::performGenreSearch
)
binding.mangaGenresTagsFullChips.setChips(
manga.getGenres(),
controller::performSearch
controller::performGenreSearch
)
} else {
binding.mangaGenresTagsCompactChips.isVisible = false

View file

@ -8,6 +8,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.documentfile.provider.DocumentFile
@ -35,6 +36,7 @@ import eu.kanade.tachiyomi.util.preference.preference
import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.MiuiUtil
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -76,6 +78,11 @@ class SettingsBackupController : SettingsController() {
summaryRes = R.string.pref_restore_backup_summ
onClick {
if (MiuiUtil.isMiui() && MiuiUtil.isMiuiOptimizationDisabled()) {
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
return@onClick
}
if (!BackupRestoreService.isRunning(context)) {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)

View file

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.util.system
import android.annotation.SuppressLint
import timber.log.Timber
object MiuiUtil {
fun isMiui(): Boolean {
return getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false
}
@SuppressLint("PrivateApi")
fun isMiuiOptimizationDisabled(): Boolean {
if ("0" == getSystemProperty("persist.sys.miui_optimization")) {
return true
}
return try {
Class.forName("android.miui.AppOpsUtils")
.getDeclaredMethod("isXOptMode")
.invoke(null) as Boolean
} catch (e: Exception) {
false
}
}
@SuppressLint("PrivateApi")
private fun getSystemProperty(key: String?): String? {
return try {
Class.forName("android.os.SystemProperties")
.getDeclaredMethod("get", String::class.java)
.invoke(null, key) as String
} catch (e: Exception) {
Timber.w(e, "Unable to use SystemProperties.get")
null
}
}
}

View file

@ -2,9 +2,15 @@
<resources>
<color name="splash">@color/background_default</color>
<!-- Default Theme -->
<color name="accent_default">#3399FF</color>
<color name="divider_default">@color/md_white_1000_12</color>
<color name="surface_default">#242529</color>
<color name="background_default">#202125</color>
<color name="ripple_colored_default">#1F3399FF</color>
<!-- Yin Yang Theme -->
<color name="accent_yinyang">#FFFFFF</color>
<color name="color_on_secondary_yinyang">#000000</color>
<color name="ripple_colored_yinyang">#777777</color>
</resources>

View file

@ -19,4 +19,14 @@
<item name="android:colorBackground">@color/background_midnightdusk</item>
</style>
<!--== Green Apple theme ==-->
<style name="Theme.Tachiyomi.GreenApple">
<!-- Theme colors -->
<item name="colorPrimary">@color/accent_greenapple</item>
<item name="colorOnPrimary">@color/md_black_1000</item>
<item name="colorTertiary">@color/md_blue_A400</item>
<item name="colorControlHighlight">@color/ripple_colored_greenapple</item>
<item name="lightSystemBarsOnPrimary">true</item>
</style>
</resources>

View file

@ -23,8 +23,14 @@
<!-- Green Apple Theme -->
<color name="accent_greenapple">#48E484</color>
<color name="accent_greenapple_variant">#188140</color>
<color name="ripple_colored_greenapple">#1F48E484</color>
<!-- Yin Yang Theme -->
<color name="accent_yinyang">#000000</color>
<color name="color_on_secondary_yinyang">#FFFFFF</color>
<color name="ripple_colored_yinyang">#999999</color>
<!-- Midnight Dusk Theme -->
<color name="accent_midnightdusk">#F02475</color>
<color name="background_midnightdusk">#16151D</color>

View file

@ -176,6 +176,7 @@
<string name="theme_yotsuba">Yotsuba</string>
<string name="theme_blue">Blue</string>
<string name="theme_greenapple">Green Apple</string>
<string name="theme_yinyang">Yin &amp; Yang</string>
<string name="theme_midnightdusk">Midnight Dusk</string>
<string name="pref_dark_theme_pure_black">Pure black dark mode</string>
<string name="pref_start_screen">Start screen</string>
@ -401,9 +402,9 @@
<string name="pref_download_directory">Download location</string>
<string name="pref_download_only_over_wifi">Only download over Wi-Fi</string>
<string name="pref_category_delete_chapters">Delete chapters</string>
<string name="pref_remove_after_marked_as_read">After manually marked as read</string>
<string name="pref_remove_after_read">After reading</string>
<string name="pref_remove_bookmarked_chapters">Delete bookmarked chapters</string>
<string name="pref_remove_after_marked_as_read">After marked as read</string>
<string name="pref_remove_after_read">Automatically after reading</string>
<string name="pref_remove_bookmarked_chapters">Allow deleting bookmarked chapters</string>
<string name="custom_dir">Custom location</string>
<string name="disabled">Disabled</string>
<string name="last_read_chapter">Last read chapter</string>
@ -458,6 +459,7 @@
<string name="backup_choice">What do you want to backup?</string>
<string name="creating_backup">Creating backup</string>
<string name="creating_backup_error">Backup failed</string>
<string name="restore_miui_warning">MIUI Optimization must be enabled for restore to work correctly.</string>
<string name="restore_in_progress">Restore is already in progress</string>
<string name="restoring_backup">Restoring backup</string>
<string name="restoring_backup_error">Restoring backup failed</string>
@ -743,6 +745,7 @@
<!-- Library update service notifications -->
<string name="notification_check_updates">Checking for new chapters</string>
<string name="notification_updating">Updating library… (%1$d/%2$d)</string>
<string name="notification_new_chapters">New chapters found</string>
<string name="notification_new_episodes">New episodes found</string>
<plurals name="notification_new_chapters_summary">

View file

@ -104,13 +104,23 @@
<!--== Green Apple theme ==-->
<style name="Theme.Tachiyomi.GreenApple">
<!-- Theme colors -->
<item name="colorPrimary">@color/accent_greenapple</item>
<item name="colorOnPrimary">@color/md_black_1000</item>
<item name="colorPrimary">@color/accent_greenapple_variant</item>
<item name="colorOnPrimary">@color/md_white_1000</item>
<item name="colorTertiary">@color/md_blue_A400</item>
<item name="colorControlHighlight">@color/ripple_colored_greenapple</item>
<item name="lightSystemBarsOnPrimary">true</item>
</style>
<!--== Yin Yang theme ==-->
<style name="Theme.Tachiyomi.YinYang">
<!-- Theme colors -->
<item name="colorPrimary">@color/accent_yinyang</item>
<item name="colorOnSecondary">@color/color_on_secondary_yinyang</item>
<item name="colorTertiary">@color/color_on_secondary_yinyang</item>
<item name="colorOnTertiary">@color/accent_yinyang</item>
<item name="colorControlHighlight">@color/ripple_colored_yinyang</item>
</style>
<!--== Midnight Dusk theme ==-->
<style name="Theme.Tachiyomi.MidnightDusk">
<!-- Theme colors -->

View file

@ -1,7 +1,7 @@
object BuildPluginsVersion {
const val AGP = "4.2.2"
const val KOTLIN = "1.5.10"
const val KOTLINTER = "3.4.4"
const val KOTLIN = "1.5.20"
const val KOTLINTER = "3.4.5"
const val VERSIONS_PLUGIN = "0.39.0"
const val ABOUTLIB_PLUGIN = "8.9.0"
}