lots of background work for manga extensions
This commit is contained in:
parent
dbe573131e
commit
57a584a820
123 changed files with 2676 additions and 553 deletions
|
@ -0,0 +1,38 @@
|
|||
package eu.kanade.core.preference
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import tachiyomi.core.preference.Preference
|
||||
|
||||
class PreferenceMutableState<T>(
|
||||
private val preference: Preference<T>,
|
||||
scope: CoroutineScope,
|
||||
) : MutableState<T> {
|
||||
|
||||
private val state = mutableStateOf(preference.get())
|
||||
|
||||
init {
|
||||
preference.changes()
|
||||
.onEach { state.value = it }
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
override var value: T
|
||||
get() = state.value
|
||||
set(value) {
|
||||
preference.set(value)
|
||||
}
|
||||
|
||||
override fun component1(): T {
|
||||
return state.value
|
||||
}
|
||||
|
||||
override fun component2(): (T) -> Unit {
|
||||
return { preference.set(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Preference<T>.asState(scope: CoroutineScope) = PreferenceMutableState(this, scope)
|
30
app/src/main/java/eu/kanade/domain/base/BasePreferences.kt
Normal file
30
app/src/main/java/eu/kanade/domain/base/BasePreferences.kt
Normal file
|
@ -0,0 +1,30 @@
|
|||
package eu.kanade.domain.base
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
|
||||
class BasePreferences(
|
||||
val context: Context,
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
|
||||
fun confirmExit() = preferenceStore.getBoolean("pref_confirm_exit", false)
|
||||
|
||||
fun downloadedOnly() = preferenceStore.getBoolean("pref_downloaded_only", false)
|
||||
|
||||
fun incognitoMode() = preferenceStore.getBoolean("incognito_mode", false)
|
||||
|
||||
fun extensionInstaller() = ExtensionInstallerPreference(context, preferenceStore)
|
||||
|
||||
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", true)
|
||||
|
||||
fun deviceHasPip() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
|
||||
enum class ExtensionInstaller(val titleResId: String) {
|
||||
LEGACY("Legacy"),
|
||||
PACKAGEINSTALLER("PackageInstaller"),
|
||||
SHIZUKU("Shizuku"),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package eu.kanade.domain.base
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.util.system.hasMiuiPackageInstaller
|
||||
import eu.kanade.domain.base.BasePreferences.ExtensionInstaller
|
||||
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import tachiyomi.core.preference.Preference
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.preference.getEnum
|
||||
|
||||
class ExtensionInstallerPreference(
|
||||
private val context: Context,
|
||||
preferenceStore: PreferenceStore
|
||||
) : Preference<ExtensionInstaller> {
|
||||
|
||||
private val basePref = preferenceStore.getEnum(key(), defaultValue())
|
||||
|
||||
override fun key() = "extension_installer"
|
||||
|
||||
|
||||
|
||||
val entries get() = BasePreferences.ExtensionInstaller.values().run {
|
||||
if (context.hasMiuiPackageInstaller) {
|
||||
filter { it != BasePreferences.ExtensionInstaller.PACKAGEINSTALLER }
|
||||
} else {
|
||||
toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun defaultValue() = if (context.hasMiuiPackageInstaller) {
|
||||
ExtensionInstaller.LEGACY
|
||||
} else {
|
||||
ExtensionInstaller.PACKAGEINSTALLER
|
||||
}
|
||||
|
||||
private fun check(value: ExtensionInstaller): ExtensionInstaller {
|
||||
when (value) {
|
||||
ExtensionInstaller.PACKAGEINSTALLER -> {
|
||||
if (context.hasMiuiPackageInstaller) return ExtensionInstaller.LEGACY
|
||||
}
|
||||
ExtensionInstaller.SHIZUKU -> {
|
||||
if (!context.isShizukuInstalled) return defaultValue()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
override fun get(): ExtensionInstaller {
|
||||
val value = basePref.get()
|
||||
val checkedValue = check(value)
|
||||
if (value != checkedValue) {
|
||||
basePref.set(checkedValue)
|
||||
}
|
||||
return checkedValue
|
||||
}
|
||||
|
||||
override fun set(value: ExtensionInstaller) {
|
||||
basePref.set(check(value))
|
||||
}
|
||||
|
||||
override fun isSet() = basePref.isSet()
|
||||
|
||||
override fun delete() = basePref.delete()
|
||||
|
||||
override fun changes() = basePref.changes()
|
||||
|
||||
override fun stateIn(scope: CoroutineScope) = basePref.stateIn(scope)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package eu.kanade.domain.source.service
|
||||
|
||||
class SetMigrateSorting(
|
||||
private val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
fun await(mode: Mode, direction: Direction) {
|
||||
preferences.migrationSortingMode().set(mode)
|
||||
preferences.migrationSortingDirection().set(direction)
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
ALPHABETICAL,
|
||||
TOTAL,
|
||||
}
|
||||
|
||||
enum class Direction {
|
||||
ASCENDING,
|
||||
DESCENDING,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package eu.kanade.domain.source.service
|
||||
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.preference.getEnum
|
||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||
|
||||
class SourcePreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
|
||||
// Common options
|
||||
|
||||
fun sourceDisplayMode() = preferenceStore.getObject("pref_display_mode_catalogue", LibraryDisplayMode.default, LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::deserialize)
|
||||
|
||||
fun enabledLanguages() = preferenceStore.getStringSet("source_languages", LocaleHelper.getDefaultEnabledLanguages())
|
||||
|
||||
fun showNsfwSource() = preferenceStore.getBoolean("show_nsfw_source", true)
|
||||
|
||||
fun migrationSortingMode() = preferenceStore.getEnum("pref_migration_sorting", SetMigrateSorting.Mode.ALPHABETICAL)
|
||||
|
||||
fun migrationSortingDirection() = preferenceStore.getEnum("pref_migration_direction", SetMigrateSorting.Direction.ASCENDING)
|
||||
|
||||
fun trustedSignatures() = preferenceStore.getStringSet("trusted_signatures", emptySet())
|
||||
|
||||
// Mixture Sources
|
||||
|
||||
fun disabledAnimeSources() = preferenceStore.getStringSet("hidden_anime_catalogues", emptySet())
|
||||
fun disabledMangaSources() = preferenceStore.getStringSet("hidden_catalogues", emptySet())
|
||||
|
||||
fun pinnedAnimeSources() = preferenceStore.getStringSet("pinned_anime_catalogues", emptySet())
|
||||
fun pinnedMangaSources() = preferenceStore.getStringSet("pinned_catalogues", emptySet())
|
||||
|
||||
fun lastUsedAnimeSource() = preferenceStore.getLong("last_anime_catalogue_source", -1)
|
||||
fun lastUsedMangaSource() = preferenceStore.getLong("last_catalogue_source", -1)
|
||||
|
||||
fun animeExtensionUpdatesCount() = preferenceStore.getInt("animeext_updates_count", 0)
|
||||
fun mangaExtensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
|
||||
|
||||
fun searchPinnedAnimeSourcesOnly() = preferenceStore.getBoolean("search_pinned_anime_sources_only", false)
|
||||
fun searchPinnedMangaSourcesOnly() = preferenceStore.getBoolean("search_pinned_sources_only", false)
|
||||
|
||||
fun hideInAnimeLibraryItems() = preferenceStore.getBoolean("browse_hide_in_anime_library_items", false)
|
||||
|
||||
fun hideInMangaLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", false)
|
||||
|
||||
// SY -->
|
||||
|
||||
// fun enableSourceBlacklist() = preferenceStore.getBoolean("eh_enable_source_blacklist", true)
|
||||
|
||||
// fun sourcesTabCategories() = preferenceStore.getStringSet("sources_tab_categories", mutableSetOf())
|
||||
|
||||
// fun sourcesTabCategoriesFilter() = preferenceStore.getBoolean("sources_tab_categories_filter", false)
|
||||
|
||||
// fun sourcesTabSourcesInCategories() = preferenceStore.getStringSet("sources_tab_source_categories", mutableSetOf())
|
||||
|
||||
fun dataSaver() = preferenceStore.getEnum("data_saver", DataSaver.NONE)
|
||||
|
||||
fun dataSaverIgnoreJpeg() = preferenceStore.getBoolean("ignore_jpeg", false)
|
||||
|
||||
fun dataSaverIgnoreGif() = preferenceStore.getBoolean("ignore_gif", true)
|
||||
|
||||
fun dataSaverImageQuality() = preferenceStore.getInt("data_saver_image_quality", 80)
|
||||
|
||||
fun dataSaverImageFormatJpeg() = preferenceStore.getBoolean("data_saver_image_format_jpeg", false)
|
||||
|
||||
fun dataSaverServer() = preferenceStore.getString("data_saver_server", "")
|
||||
|
||||
fun dataSaverColorBW() = preferenceStore.getBoolean("data_saver_color_bw", false)
|
||||
|
||||
fun dataSaverExcludedSources() = preferenceStore.getStringSet("data_saver_excluded", emptySet())
|
||||
|
||||
fun dataSaverDownloader() = preferenceStore.getBoolean("data_saver_downloader", true)
|
||||
|
||||
enum class DataSaver {
|
||||
NONE,
|
||||
BANDWIDTH_HERO,
|
||||
WSRV_NL,
|
||||
RESMUSH_IT,
|
||||
}
|
||||
// SY <--
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package eu.kanade.domain.source.service
|
||||
|
||||
import tachiyomi.core.preference.getAndSet
|
||||
|
||||
class ToggleLanguage(
|
||||
val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
fun await(language: String) {
|
||||
val isEnabled = language in preferences.enabledLanguages().get()
|
||||
preferences.enabledLanguages().getAndSet { enabled ->
|
||||
if (isEnabled) enabled.minus(language) else enabled.plus(language)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package eu.kanade.tachiyomi
|
||||
|
||||
typealias PreferenceScreen = androidx.preference.PreferenceScreen
|
|
@ -0,0 +1,46 @@
|
|||
package eu.kanade.tachiyomi.animesource
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import rx.Observable
|
||||
|
||||
interface AnimeCatalogueSource : AnimeSource {
|
||||
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang: String
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
val supportsLatest: Boolean
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of anime.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchPopularAnime(page: Int): Observable<AnimesPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of anime.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest anime updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchLatestUpdates(page: Int): Observable<AnimesPage>
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
fun getFilterList(): AnimeFilterList
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package eu.kanade.tachiyomi.animesource
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.util.lang.awaitSingle
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc.
|
||||
*/
|
||||
interface AnimeSource {
|
||||
|
||||
/**
|
||||
* ID for the source. Must be unique.
|
||||
*/
|
||||
val id: Long
|
||||
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
val lang: String
|
||||
get() = ""
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a anime.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getAnimeDetails"),
|
||||
)
|
||||
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available episodes for a anime.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getEpisodeList"),
|
||||
)
|
||||
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of videos a episode has. Videos should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*
|
||||
* @param episode the episode.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x 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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package eu.kanade.tachiyomi.animesource
|
||||
|
||||
/**
|
||||
* A factory for creating sources at runtime.
|
||||
*/
|
||||
interface AnimeSourceFactory {
|
||||
/**
|
||||
* Create a new copy of the sources
|
||||
* @return The created sources
|
||||
*/
|
||||
fun createSources(): List<AnimeSource>
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
package eu.kanade.tachiyomi.animesource
|
||||
|
||||
import ani.dantotsu.aniyomi.animesource.AnimeSource
|
||||
import ani.dantotsu.aniyomi.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.PreferenceScreen
|
||||
|
||||
interface ConfigurableAnimeSource : AnimeSource {
|
||||
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
sealed class AnimeFilter<T>(val name: String, var state: T) {
|
||||
open class Header(name: String) : AnimeFilter<Any>(name, 0)
|
||||
open class Separator(name: String = "") : AnimeFilter<Any>(name, 0)
|
||||
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : AnimeFilter<Int>(name, state)
|
||||
abstract class Text(name: String, state: String = "") : AnimeFilter<String>(name, state)
|
||||
abstract class CheckBox(name: String, state: Boolean = false) : AnimeFilter<Boolean>(name, state)
|
||||
abstract class TriState(name: String, state: Int = STATE_IGNORE) : AnimeFilter<Int>(name, state) {
|
||||
fun isIgnored() = state == STATE_IGNORE
|
||||
fun isIncluded() = state == STATE_INCLUDE
|
||||
fun isExcluded() = state == STATE_EXCLUDE
|
||||
|
||||
companion object {
|
||||
const val STATE_IGNORE = 0
|
||||
const val STATE_INCLUDE = 1
|
||||
const val STATE_EXCLUDE = 2
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Group<V>(name: String, state: List<V>) : AnimeFilter<List<V>>(name, state)
|
||||
|
||||
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
|
||||
AnimeFilter<Sort.Selection?>(name, state) {
|
||||
data class Selection(val index: Int, val ascending: Boolean)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AnimeFilter<*>) return false
|
||||
|
||||
return name == other.name && state == other.state
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = name.hashCode()
|
||||
result = 31 * result + (state?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
|
||||
data class AnimeFilterList(val list: List<AnimeFilter<*>>) : List<AnimeFilter<*>> by list {
|
||||
|
||||
constructor(vararg fs: AnimeFilter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return list.hashCode()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
|
||||
data class AnimesPage(val animes: List<SAnime>, val hasNextPage: Boolean)
|
|
@ -0,0 +1,89 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import java.io.Serializable
|
||||
|
||||
interface SAnime : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var title: String
|
||||
|
||||
var artist: String?
|
||||
|
||||
var author: String?
|
||||
|
||||
var description: String?
|
||||
|
||||
var genre: String?
|
||||
|
||||
var status: Int
|
||||
|
||||
var thumbnail_url: String?
|
||||
|
||||
var update_strategy: UpdateStrategy
|
||||
|
||||
var initialized: Boolean
|
||||
|
||||
fun getGenres(): List<String>? {
|
||||
if (genre.isNullOrBlank()) return null
|
||||
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
|
||||
}
|
||||
|
||||
fun copyFrom(other: SAnime) {
|
||||
if (other.author != null) {
|
||||
author = other.author
|
||||
}
|
||||
|
||||
if (other.artist != null) {
|
||||
artist = other.artist
|
||||
}
|
||||
|
||||
if (other.description != null) {
|
||||
description = other.description
|
||||
}
|
||||
|
||||
if (other.genre != null) {
|
||||
genre = other.genre
|
||||
}
|
||||
|
||||
if (other.thumbnail_url != null) {
|
||||
thumbnail_url = other.thumbnail_url
|
||||
}
|
||||
|
||||
status = other.status
|
||||
|
||||
update_strategy = other.update_strategy
|
||||
|
||||
if (!initialized) {
|
||||
initialized = other.initialized
|
||||
}
|
||||
}
|
||||
|
||||
fun copy() = create().also {
|
||||
it.url = url
|
||||
it.title = title
|
||||
it.artist = artist
|
||||
it.author = author
|
||||
it.description = description
|
||||
it.genre = genre
|
||||
it.status = status
|
||||
it.thumbnail_url = thumbnail_url
|
||||
it.update_strategy = update_strategy
|
||||
it.initialized = initialized
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val UNKNOWN = 0
|
||||
const val ONGOING = 1
|
||||
const val COMPLETED = 2
|
||||
const val LICENSED = 3
|
||||
const val PUBLISHING_FINISHED = 4
|
||||
const val CANCELLED = 5
|
||||
const val ON_HIATUS = 6
|
||||
|
||||
fun create(): SAnime {
|
||||
return SAnimeImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
|
||||
class SAnimeImpl : SAnime {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var artist: String? = null
|
||||
|
||||
override var author: String? = null
|
||||
|
||||
override var description: String? = null
|
||||
|
||||
override var genre: String? = null
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var thumbnail_url: String? = null
|
||||
|
||||
override var initialized: Boolean = false
|
||||
|
||||
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface SEpisode : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var name: String
|
||||
|
||||
var date_upload: Long
|
||||
|
||||
var episode_number: Float
|
||||
|
||||
var scanlator: String?
|
||||
|
||||
fun copyFrom(other: SEpisode) {
|
||||
name = other.name
|
||||
url = other.url
|
||||
date_upload = other.date_upload
|
||||
episode_number = other.episode_number
|
||||
scanlator = other.scanlator
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(): SEpisode {
|
||||
return SEpisodeImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
|
||||
class SEpisodeImpl : SEpisode {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var date_upload: Long = 0
|
||||
|
||||
override var episode_number: Float = -1f
|
||||
|
||||
override var scanlator: String? = null
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.Headers
|
||||
import rx.subjects.Subject
|
||||
|
||||
data class Track(val url: String, val lang: String)
|
||||
|
||||
open class Video(
|
||||
val url: String = "",
|
||||
val quality: String = "",
|
||||
var videoUrl: String? = null,
|
||||
val headers: Headers? = null,
|
||||
// "url", "language-label-2", "url2", "language-label-2"
|
||||
val subtitleTracks: List<Track> = emptyList(),
|
||||
val audioTracks: List<Track> = emptyList(),
|
||||
) : ProgressListener {
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
constructor(
|
||||
url: String,
|
||||
quality: String,
|
||||
videoUrl: String?,
|
||||
uri: Uri? = null,
|
||||
headers: Headers? = null,
|
||||
) : this(url, quality, videoUrl, headers)
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var status: State = State.QUEUE
|
||||
set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
private val _progressFlow = MutableStateFlow(0)
|
||||
|
||||
@Transient
|
||||
val progressFlow = _progressFlow.asStateFlow()
|
||||
var progress: Int
|
||||
get() = _progressFlow.value
|
||||
set(value) {
|
||||
_progressFlow.value = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var totalBytesDownloaded: Long = 0L
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var totalContentLength: Long = 0L
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var bytesDownloaded: Long = 0L
|
||||
set(value) {
|
||||
totalBytesDownloaded += if (value < field) {
|
||||
value
|
||||
} else {
|
||||
value - field
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
var progressSubject: Subject<State, State>? = null
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
bytesDownloaded = bytesRead
|
||||
if (contentLength > totalContentLength) {
|
||||
totalContentLength = contentLength
|
||||
}
|
||||
val newProgress = if (totalContentLength > 0) {
|
||||
(100 * totalBytesDownloaded / totalContentLength).toInt()
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
if (progress != newProgress) progress = newProgress
|
||||
}
|
||||
|
||||
enum class State {
|
||||
QUEUE,
|
||||
LOAD_VIDEO,
|
||||
DOWNLOAD_IMAGE,
|
||||
READY,
|
||||
ERROR,
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package eu.kanade.tachiyomi.animesource.online
|
||||
|
||||
import ani.dantotsu.aniyomi.animesource.AnimeCatalogueSource
|
||||
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
|
|
24
app/src/main/java/eu/kanade/tachiyomi/core/Constants.kt
Normal file
24
app/src/main/java/eu/kanade/tachiyomi/core/Constants.kt
Normal file
|
@ -0,0 +1,24 @@
|
|||
package eu.kanade.tachiyomi.core
|
||||
|
||||
object Constants {
|
||||
const val URL_HELP = "https://aniyomi.org/help/"
|
||||
|
||||
const val MANGA_EXTRA = "manga"
|
||||
|
||||
const val ANIME_EXTRA = "anime"
|
||||
|
||||
const val MAIN_ACTIVITY = "eu.kanade.tachiyomi.ui.main.MainActivity"
|
||||
|
||||
// Shortcut actions
|
||||
const val SHORTCUT_ANIMELIB = "eu.kanade.tachiyomi.SHOW_ANIMELIB"
|
||||
const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
|
||||
const val SHORTCUT_ANIME = "eu.kanade.tachiyomi.SHOW_ANIME"
|
||||
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
|
||||
const val SHORTCUT_UPDATES = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
|
||||
const val SHORTCUT_HISTORY = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
|
||||
const val SHORTCUT_SOURCES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
|
||||
const val SHORTCUT_ANIMEEXTENSIONS = "eu.kanade.tachiyomi.ANIMEEXTENSIONS"
|
||||
const val SHORTCUT_EXTENSIONS = "eu.kanade.tachiyomi.EXTENSIONS"
|
||||
const val SHORTCUT_ANIME_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_ANIME_DOWNLOADS"
|
||||
const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS"
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
package eu.kanade.tachiyomi.core.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.Editor
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import tachiyomi.core.preference.Preference
|
||||
|
||||
sealed class AndroidPreference<T>(
|
||||
private val preferences: SharedPreferences,
|
||||
private val keyFlow: Flow<String?>,
|
||||
private val key: String,
|
||||
private val defaultValue: T,
|
||||
) : Preference<T> {
|
||||
|
||||
abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T
|
||||
|
||||
abstract fun write(key: String, value: T): Editor.() -> Unit
|
||||
|
||||
override fun key(): String {
|
||||
return key
|
||||
}
|
||||
|
||||
override fun get(): T {
|
||||
return read(preferences, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun set(value: T) {
|
||||
preferences.edit(action = write(key, value))
|
||||
}
|
||||
|
||||
override fun isSet(): Boolean {
|
||||
return preferences.contains(key)
|
||||
}
|
||||
|
||||
override fun delete() {
|
||||
preferences.edit {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun defaultValue(): T {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun changes(): Flow<T> {
|
||||
return keyFlow
|
||||
.filter { it == key || it == null }
|
||||
.onStart { emit("ignition") }
|
||||
.map { get() }
|
||||
.conflate()
|
||||
}
|
||||
|
||||
override fun stateIn(scope: CoroutineScope): StateFlow<T> {
|
||||
return changes().stateIn(scope, SharingStarted.Eagerly, get())
|
||||
}
|
||||
|
||||
class StringPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
) : AndroidPreference<String>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: String): String {
|
||||
return try {
|
||||
preferences.getString(key, defaultValue) ?: defaultValue
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: String): Editor.() -> Unit = {
|
||||
putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class LongPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Long,
|
||||
) : AndroidPreference<Long>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long {
|
||||
return try {
|
||||
preferences.getLong(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Long): Editor.() -> Unit = {
|
||||
putLong(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class IntPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
) : AndroidPreference<Int>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int {
|
||||
return try {
|
||||
preferences.getInt(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Int): Editor.() -> Unit = {
|
||||
putInt(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class FloatPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Float,
|
||||
) : AndroidPreference<Float>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float {
|
||||
return try {
|
||||
preferences.getFloat(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Float): Editor.() -> Unit = {
|
||||
putFloat(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
) : AndroidPreference<Boolean>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Boolean): Boolean {
|
||||
return try {
|
||||
preferences.getBoolean(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Boolean): Editor.() -> Unit = {
|
||||
putBoolean(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class StringSetPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Set<String>,
|
||||
) : AndroidPreference<Set<String>>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Set<String>): Set<String> {
|
||||
return try {
|
||||
preferences.getStringSet(key, defaultValue) ?: defaultValue
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Set<String>): Editor.() -> Unit = {
|
||||
putStringSet(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class Object<T>(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
val serializer: (T) -> String,
|
||||
val deserializer: (String) -> T,
|
||||
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
|
||||
return try {
|
||||
preferences.getString(key, null)?.let(deserializer) ?: defaultValue
|
||||
} catch (e: Exception) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: T): Editor.() -> Unit = {
|
||||
putString(key, serializer(value))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package eu.kanade.tachiyomi.core.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreference.BooleanPrimitive
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreference.FloatPrimitive
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreference.IntPrimitive
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreference.LongPrimitive
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreference.Object
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreference.StringPrimitive
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreference.StringSetPrimitive
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import tachiyomi.core.preference.Preference
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
|
||||
class AndroidPreferenceStore(
|
||||
context: Context,
|
||||
) : PreferenceStore {
|
||||
|
||||
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
private val keyFlow = sharedPreferences.keyFlow
|
||||
|
||||
override fun getString(key: String, defaultValue: String): Preference<String> {
|
||||
return StringPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Preference<Long> {
|
||||
return LongPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Preference<Int> {
|
||||
return IntPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Preference<Float> {
|
||||
return FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> {
|
||||
return BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> {
|
||||
return StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun <T> getObject(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T> {
|
||||
return Object(
|
||||
preferences = sharedPreferences,
|
||||
keyFlow = keyFlow,
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = serializer,
|
||||
deserializer = deserializer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val SharedPreferences.keyFlow
|
||||
get() = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> trySend(key) }
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package eu.kanade.tachiyomi.data.notification
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import ani.dantotsu.MainActivity
|
||||
import eu.kanade.tachiyomi.core.Constants
|
||||
/**
|
||||
* Global [BroadcastReceiver] that runs on UI thread
|
||||
* Pending Broadcasts should be made from here.
|
||||
* NOTE: Use local broadcasts if possible.
|
||||
*/
|
||||
class NotificationReceiver {
|
||||
|
||||
companion object {
|
||||
private const val NAME = "NotificationReceiver"
|
||||
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that opens the extensions controller.
|
||||
*
|
||||
* @param context context of application
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun openExtensionsPendingActivity(context: Context): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
action = Constants.SHORTCUT_EXTENSIONS
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
package eu.kanade.tachiyomi.data.notification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW
|
||||
import eu.kanade.tachiyomi.util.system.buildNotificationChannel
|
||||
import eu.kanade.tachiyomi.util.system.buildNotificationChannelGroup
|
||||
|
||||
/**
|
||||
* Class to manage the basic information of all the notifications used in the app.
|
||||
*/
|
||||
object Notifications {
|
||||
|
||||
/**
|
||||
* Common notification channel and ids used anywhere.
|
||||
*/
|
||||
const val CHANNEL_COMMON = "common_channel"
|
||||
const val ID_DOWNLOAD_IMAGE = 2
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the library updater.
|
||||
*/
|
||||
private const val GROUP_LIBRARY = "group_library"
|
||||
const val CHANNEL_LIBRARY_PROGRESS = "library_progress_channel"
|
||||
const val ID_LIBRARY_PROGRESS = -101
|
||||
const val ID_LIBRARY_SIZE_WARNING = -103
|
||||
const val CHANNEL_LIBRARY_ERROR = "library_errors_channel"
|
||||
const val ID_LIBRARY_ERROR = -102
|
||||
const val CHANNEL_LIBRARY_SKIPPED = "library_skipped_channel"
|
||||
const val ID_LIBRARY_SKIPPED = -104
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the downloader.
|
||||
*/
|
||||
private const val GROUP_DOWNLOADER = "group_downloader"
|
||||
const val CHANNEL_DOWNLOADER_PROGRESS = "downloader_progress_channel"
|
||||
const val ID_DOWNLOAD_CHAPTER_PROGRESS = -201
|
||||
const val ID_DOWNLOAD_EPISODE_PROGRESS = -203
|
||||
const val CHANNEL_DOWNLOADER_ERROR = "downloader_error_channel"
|
||||
const val ID_DOWNLOAD_CHAPTER_ERROR = -202
|
||||
const val ID_DOWNLOAD_EPISODE_ERROR = -204
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the library updater.
|
||||
*/
|
||||
const val CHANNEL_NEW_CHAPTERS_EPISODES = "new_chapters_episodes_channel"
|
||||
const val ID_NEW_CHAPTERS = -301
|
||||
const val ID_NEW_EPISODES = -1301
|
||||
const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS"
|
||||
const val GROUP_NEW_EPISODES = "eu.kanade.tachiyomi.NEW_EPISODES"
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the backup/restore system.
|
||||
*/
|
||||
private const val GROUP_BACKUP_RESTORE = "group_backup_restore"
|
||||
const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel"
|
||||
const val ID_BACKUP_PROGRESS = -501
|
||||
const val ID_RESTORE_PROGRESS = -503
|
||||
const val CHANNEL_BACKUP_RESTORE_COMPLETE = "backup_restore_complete_channel_v2"
|
||||
const val ID_BACKUP_COMPLETE = -502
|
||||
const val ID_RESTORE_COMPLETE = -504
|
||||
|
||||
/**
|
||||
* Notification channel used for Incognito Mode
|
||||
*/
|
||||
const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel"
|
||||
const val ID_INCOGNITO_MODE = -701
|
||||
|
||||
/**
|
||||
* Notification channel and ids used for app and extension updates.
|
||||
*/
|
||||
private const val GROUP_APK_UPDATES = "group_apk_updates"
|
||||
const val CHANNEL_APP_UPDATE = "app_apk_update_channel"
|
||||
const val ID_APP_UPDATER = 1
|
||||
const val ID_APP_UPDATE_PROMPT = 2
|
||||
const val CHANNEL_EXTENSIONS_UPDATE = "ext_apk_update_channel"
|
||||
const val ID_UPDATES_TO_EXTS = -401
|
||||
const val ID_EXTENSION_INSTALLER = -402
|
||||
|
||||
private val deprecatedChannels = listOf(
|
||||
"downloader_channel",
|
||||
"downloader_complete_channel",
|
||||
"backup_restore_complete_channel",
|
||||
"library_channel",
|
||||
"library_progress_channel",
|
||||
"updates_ext_channel",
|
||||
"downloader_cache_renewal",
|
||||
"crash_logs_channel",
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates the notification channels introduced in Android Oreo.
|
||||
* This won't do anything on Android versions that don't support notification channels.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun createChannels(context: Context) {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
// Delete old notification channels
|
||||
deprecatedChannels.forEach(notificationManager::deleteNotificationChannel)
|
||||
|
||||
notificationManager.createNotificationChannelGroupsCompat(
|
||||
listOf(
|
||||
buildNotificationChannelGroup(GROUP_BACKUP_RESTORE) {
|
||||
setName("Backup & Restore")
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_DOWNLOADER) {
|
||||
setName("Downloader")
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_LIBRARY) {
|
||||
setName("Library")
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_APK_UPDATES) {
|
||||
setName("App & Extension Updates")
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
notificationManager.createNotificationChannelsCompat(
|
||||
listOf(
|
||||
buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) {
|
||||
setName("Common")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName("Library Progress")
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_ERROR, IMPORTANCE_LOW) {
|
||||
setName("Library Errors")
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_SKIPPED, IMPORTANCE_LOW) {
|
||||
setName("Library Skipped")
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_NEW_CHAPTERS_EPISODES, IMPORTANCE_DEFAULT) {
|
||||
setName("New Chapters & Episodes")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_DOWNLOADER_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName("Downloader Progress")
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_DOWNLOADER_ERROR, IMPORTANCE_LOW) {
|
||||
setName("Downloader Errors")
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName("Backup & Restore Progress")
|
||||
setGroup(GROUP_BACKUP_RESTORE)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_COMPLETE, IMPORTANCE_HIGH) {
|
||||
setName("Backup & Restore Complete")
|
||||
setGroup(GROUP_BACKUP_RESTORE)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) {
|
||||
setName("Incognito Mode")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) {
|
||||
setGroup(GROUP_APK_UPDATES)
|
||||
setName("App Updates")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_EXTENSIONS_UPDATE, IMPORTANCE_DEFAULT) {
|
||||
setGroup(GROUP_APK_UPDATES)
|
||||
setName("Extension Updates")
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package eu.kanade.tachiyomi.extension
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import ani.dantotsu.R
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.util.system.notify
|
||||
|
||||
class ExtensionUpdateNotifier(private val context: Context) {
|
||||
|
||||
fun promptUpdates(names: List<String>) {
|
||||
context.notify(
|
||||
Notifications.ID_UPDATES_TO_EXTS,
|
||||
Notifications.CHANNEL_EXTENSIONS_UPDATE,
|
||||
) {
|
||||
setContentTitle(
|
||||
"Extension updates available"
|
||||
)
|
||||
val extNames = names.joinToString(", ")
|
||||
setContentText(extNames)
|
||||
setStyle(NotificationCompat.BigTextStyle().bigText(extNames))
|
||||
setSmallIcon(R.drawable.ic_round_favorite_24)
|
||||
setContentIntent(NotificationReceiver.openExtensionsPendingActivity(context))
|
||||
setAutoCancel(true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package eu.kanade.tachiyomi.extension
|
||||
|
||||
enum class InstallStep {
|
||||
Idle, Pending, Downloading, Installing, Installed, Error;
|
||||
|
||||
fun isCompleted(): Boolean {
|
||||
return this == Installed || this == Error || this == Idle
|
||||
}
|
||||
}
|
|
@ -0,0 +1,365 @@
|
|||
package eu.kanade.tachiyomi.extension.anime
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.anime.api.AnimeExtensionGithubApi
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AvailableAnimeSources
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionInstallReceiver
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionInstaller
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||
import eu.kanade.tachiyomi.util.preference.plusAssign
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import tachiyomi.core.util.lang.launchNow
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.source.anime.model.AnimeSourceData
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The manager of anime extensions installed as another apk which extend the available sources. It handles
|
||||
* the retrieval of remotely available anime extensions as well as installing, updating and removing them.
|
||||
* To avoid malicious distribution, every anime extension must be signed and it will only be loaded if its
|
||||
* signature is trusted, otherwise the user will be prompted with a warning to trust it before being
|
||||
* loaded.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param preferences The application preferences.
|
||||
*/
|
||||
class AnimeExtensionManager(
|
||||
private val context: Context,
|
||||
private val preferences: SourcePreferences = Injekt.get(),
|
||||
) {
|
||||
|
||||
var isInitialized = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* API where all the available anime extensions can be found.
|
||||
*/
|
||||
private val api = AnimeExtensionGithubApi()
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the anime extensions.
|
||||
*/
|
||||
private val installer by lazy { AnimeExtensionInstaller(context) }
|
||||
|
||||
private val iconMap = mutableMapOf<String, Drawable>()
|
||||
|
||||
private val _installedAnimeExtensionsFlow = MutableStateFlow(emptyList<AnimeExtension.Installed>())
|
||||
val installedExtensionsFlow = _installedAnimeExtensionsFlow.asStateFlow()
|
||||
|
||||
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet()
|
||||
|
||||
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 null
|
||||
}
|
||||
|
||||
private val _availableAnimeExtensionsFlow = MutableStateFlow(emptyList<AnimeExtension.Available>())
|
||||
val availableExtensionsFlow = _availableAnimeExtensionsFlow.asStateFlow()
|
||||
|
||||
private var availableAnimeExtensionsSourcesData: Map<Long, AnimeSourceData> = emptyMap()
|
||||
|
||||
private fun setupAvailableAnimeExtensionsSourcesDataMap(animeextensions: List<AnimeExtension.Available>) {
|
||||
if (animeextensions.isEmpty()) return
|
||||
availableAnimeExtensionsSourcesData = animeextensions
|
||||
.flatMap { ext -> ext.sources.map { it.toAnimeSourceData() } }
|
||||
.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun getSourceData(id: Long) = availableAnimeExtensionsSourcesData[id]
|
||||
|
||||
private val _untrustedAnimeExtensionsFlow = MutableStateFlow(emptyList<AnimeExtension.Untrusted>())
|
||||
val untrustedExtensionsFlow = _untrustedAnimeExtensionsFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
initAnimeExtensions()
|
||||
AnimeExtensionInstallReceiver(AnimeInstallationListener()).register(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and registers the installed animeextensions.
|
||||
*/
|
||||
private fun initAnimeExtensions() {
|
||||
val animeextensions = AnimeExtensionLoader.loadExtensions(context)
|
||||
|
||||
_installedAnimeExtensionsFlow.value = animeextensions
|
||||
.filterIsInstance<AnimeLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
_untrustedAnimeExtensionsFlow.value = animeextensions
|
||||
.filterIsInstance<AnimeLoadResult.Untrusted>()
|
||||
.map { it.extension }
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the available anime extensions in the [api] and updates [availableExtensions].
|
||||
*/
|
||||
suspend fun findAvailableExtensions() {
|
||||
val extensions: List<AnimeExtension.Available> = try {
|
||||
api.findExtensions()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
withUIContext { context.toast("Failed to get extensions list") }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
enableAdditionalSubLanguages(extensions)
|
||||
|
||||
_availableAnimeExtensionsFlow.value = extensions
|
||||
updatedInstalledAnimeExtensionsStatuses(extensions)
|
||||
setupAvailableAnimeExtensionsSourcesDataMap(extensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the additional sub-languages in the app first run. This addresses
|
||||
* the issue where users still need to enable some specific languages even when
|
||||
* the device language is inside that major group. As an example, if a user
|
||||
* has a zh device language, the app will also enable zh-Hans and zh-Hant.
|
||||
*
|
||||
* If the user have already changed the enabledLanguages preference value once,
|
||||
* the new languages will not be added to respect the user enabled choices.
|
||||
*/
|
||||
private fun enableAdditionalSubLanguages(animeextensions: List<AnimeExtension.Available>) {
|
||||
if (subLanguagesEnabledOnFirstRun || animeextensions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the source lang as some aren't present on the animeextension level.
|
||||
val availableLanguages = animeextensions
|
||||
.flatMap(AnimeExtension.Available::sources)
|
||||
.distinctBy(AvailableAnimeSources::lang)
|
||||
.map(AvailableAnimeSources::lang)
|
||||
|
||||
val deviceLanguage = Locale.getDefault().language
|
||||
val defaultLanguages = preferences.enabledLanguages().defaultValue()
|
||||
val languagesToEnable = availableLanguages.filter {
|
||||
it != deviceLanguage && it.startsWith(deviceLanguage)
|
||||
}
|
||||
|
||||
preferences.enabledLanguages().set(defaultLanguages + languagesToEnable)
|
||||
subLanguagesEnabledOnFirstRun = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the update field of the installed animeextensions with the given [availableAnimeExtensions].
|
||||
*
|
||||
* @param availableAnimeExtensions The list of animeextensions given by the [api].
|
||||
*/
|
||||
private fun updatedInstalledAnimeExtensionsStatuses(availableAnimeExtensions: List<AnimeExtension.Available>) {
|
||||
if (availableAnimeExtensions.isEmpty()) {
|
||||
preferences.animeExtensionUpdatesCount().set(0)
|
||||
return
|
||||
}
|
||||
|
||||
val mutInstalledAnimeExtensions = _installedAnimeExtensionsFlow.value.toMutableList()
|
||||
var changed = false
|
||||
|
||||
for ((index, installedExt) in mutInstalledAnimeExtensions.withIndex()) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = availableAnimeExtensions.find { it.pkgName == pkgName }
|
||||
|
||||
if (!installedExt.isUnofficial && availableExt == null && !installedExt.isObsolete) {
|
||||
mutInstalledAnimeExtensions[index] = installedExt.copy(isObsolete = true)
|
||||
changed = true
|
||||
} else if (availableExt != null) {
|
||||
val hasUpdate = installedExt.updateExists(availableExt)
|
||||
|
||||
if (installedExt.hasUpdate != hasUpdate) {
|
||||
mutInstalledAnimeExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_installedAnimeExtensionsFlow.value = mutInstalledAnimeExtensions
|
||||
}
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given anime extension. It will complete
|
||||
* once the anime extension is installed or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The anime extension to be installed.
|
||||
*/
|
||||
fun installExtension(extension: AnimeExtension.Available): Observable<InstallStep> {
|
||||
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given anime extension. It will complete
|
||||
* once the anime extension is updated or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The anime extension to be updated.
|
||||
*/
|
||||
fun updateExtension(extension: AnimeExtension.Installed): Observable<InstallStep> {
|
||||
val availableExt = _availableAnimeExtensionsFlow.value.find { it.pkgName == extension.pkgName }
|
||||
?: return Observable.empty()
|
||||
return installExtension(availableExt)
|
||||
}
|
||||
|
||||
fun cancelInstallUpdateExtension(extension: AnimeExtension) {
|
||||
installer.cancelInstall(extension.pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets to "installing" status of an anime extension installation.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
*/
|
||||
fun setInstalling(downloadId: Long) {
|
||||
installer.updateInstallStep(downloadId, InstallStep.Installing)
|
||||
}
|
||||
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
installer.updateInstallStep(downloadId, step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalls the anime extension that matches the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the application to uninstall.
|
||||
*/
|
||||
fun uninstallExtension(pkgName: String) {
|
||||
installer.uninstallApk(pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given signature to the list of trusted signatures. It also loads in background the
|
||||
* anime extensions that match this signature.
|
||||
*
|
||||
* @param signature The signature to whitelist.
|
||||
*/
|
||||
fun trustSignature(signature: String) {
|
||||
val untrustedSignatures = _untrustedAnimeExtensionsFlow.value.map { it.signatureHash }.toSet()
|
||||
if (signature !in untrustedSignatures) return
|
||||
|
||||
AnimeExtensionLoader.trustedSignatures += signature
|
||||
preferences.trustedSignatures() += signature
|
||||
|
||||
val nowTrustedAnimeExtensions = _untrustedAnimeExtensionsFlow.value.filter { it.signatureHash == signature }
|
||||
_untrustedAnimeExtensionsFlow.value -= nowTrustedAnimeExtensions
|
||||
|
||||
val ctx = context
|
||||
launchNow {
|
||||
nowTrustedAnimeExtensions
|
||||
.map { animeextension ->
|
||||
async { AnimeExtensionLoader.loadExtensionFromPkgName(ctx, animeextension.pkgName) }
|
||||
}
|
||||
.map { it.await() }
|
||||
.forEach { result ->
|
||||
if (result is AnimeLoadResult.Success) {
|
||||
registerNewExtension(result.extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given anime extension in this and the source managers.
|
||||
*
|
||||
* @param extension The anime extension to be registered.
|
||||
*/
|
||||
private fun registerNewExtension(extension: AnimeExtension.Installed) {
|
||||
_installedAnimeExtensionsFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given updated anime extension in this and the source managers previously removing
|
||||
* the outdated ones.
|
||||
*
|
||||
* @param extension The anime extension to be registered.
|
||||
*/
|
||||
private fun registerUpdatedExtension(extension: AnimeExtension.Installed) {
|
||||
val mutInstalledAnimeExtensions = _installedAnimeExtensionsFlow.value.toMutableList()
|
||||
val oldAnimeExtension = mutInstalledAnimeExtensions.find { it.pkgName == extension.pkgName }
|
||||
if (oldAnimeExtension != null) {
|
||||
mutInstalledAnimeExtensions -= oldAnimeExtension
|
||||
}
|
||||
mutInstalledAnimeExtensions += extension
|
||||
_installedAnimeExtensionsFlow.value = mutInstalledAnimeExtensions
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the animeextension in this and the source managers given its package name. Note this
|
||||
* method is called for every uninstalled application in the system.
|
||||
*
|
||||
* @param pkgName The package name of the uninstalled application.
|
||||
*/
|
||||
private fun unregisterAnimeExtension(pkgName: String) {
|
||||
val installedAnimeExtension = _installedAnimeExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (installedAnimeExtension != null) {
|
||||
_installedAnimeExtensionsFlow.value -= installedAnimeExtension
|
||||
}
|
||||
val untrustedAnimeExtension = _untrustedAnimeExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (untrustedAnimeExtension != null) {
|
||||
_untrustedAnimeExtensionsFlow.value -= untrustedAnimeExtension
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener which receives events of the anime extensions being installed, updated or removed.
|
||||
*/
|
||||
private inner class AnimeInstallationListener : AnimeExtensionInstallReceiver.Listener {
|
||||
|
||||
override fun onExtensionInstalled(extension: AnimeExtension.Installed) {
|
||||
registerNewExtension(extension.withUpdateCheck())
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUpdated(extension: AnimeExtension.Installed) {
|
||||
registerUpdatedExtension(extension.withUpdateCheck())
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUntrusted(extension: AnimeExtension.Untrusted) {
|
||||
_untrustedAnimeExtensionsFlow.value += extension
|
||||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
unregisterAnimeExtension(pkgName)
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimeExtension method to set the update field of an installed anime extension.
|
||||
*/
|
||||
private fun AnimeExtension.Installed.withUpdateCheck(): AnimeExtension.Installed {
|
||||
return if (updateExists()) {
|
||||
copy(hasUpdate = true)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnimeExtension.Installed.updateExists(availableAnimeExtension: AnimeExtension.Available? = null): Boolean {
|
||||
val availableExt = availableAnimeExtension ?: _availableAnimeExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (isUnofficial || availableExt == null) return false
|
||||
|
||||
return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion)
|
||||
}
|
||||
|
||||
private fun updatePendingUpdatesCount() {
|
||||
preferences.animeExtensionUpdatesCount().set(_installedAnimeExtensionsFlow.value.count { it.hasUpdate })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.api
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier
|
||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AvailableAnimeSources
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.Preference
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Date
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
internal class AnimeExtensionGithubApi {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val preferenceStore: PreferenceStore by injectLazy()
|
||||
private val animeExtensionManager: AnimeExtensionManager by injectLazy()
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val lastExtCheck: Preference<Long> by lazy {
|
||||
preferenceStore.getLong("last_ext_check", 0)
|
||||
}
|
||||
|
||||
private var requiresFallbackSource = false
|
||||
|
||||
suspend fun findExtensions(): List<AnimeExtension.Available> {
|
||||
return withIOContext {
|
||||
val githubResponse = if (requiresFallbackSource) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
networkService.client
|
||||
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
|
||||
requiresFallbackSource = true
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val response = githubResponse ?: run {
|
||||
networkService.client
|
||||
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
}
|
||||
|
||||
val extensions = with(json) {
|
||||
response
|
||||
.parseAs<List<AnimeExtensionJsonObject>>()
|
||||
.toExtensions()
|
||||
}
|
||||
|
||||
// Sanity check - a small number of extensions probably means something broke
|
||||
// with the repo generator
|
||||
if (extensions.size < 10) {
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
extensions
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<AnimeExtension.Installed>? {
|
||||
// Limit checks to once a day at most
|
||||
if (fromAvailableExtensionList && Date().time < lastExtCheck.get() + 1.days.inWholeMilliseconds) {
|
||||
return null
|
||||
}
|
||||
|
||||
val extensions = if (fromAvailableExtensionList) {
|
||||
animeExtensionManager.availableExtensionsFlow.value
|
||||
} else {
|
||||
findExtensions().also { lastExtCheck.set(Date().time) }
|
||||
}
|
||||
|
||||
val installedExtensions = AnimeExtensionLoader.loadExtensions(context)
|
||||
.filterIsInstance<AnimeLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
val extensionsWithUpdate = mutableListOf<AnimeExtension.Installed>()
|
||||
for (installedExt in installedExtensions) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
|
||||
|
||||
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
|
||||
val hasUpdatedLib = availableExt.libVersion > installedExt.libVersion
|
||||
val hasUpdate = installedExt.isUnofficial.not() && (hasUpdatedVer || hasUpdatedLib)
|
||||
if (hasUpdate) {
|
||||
extensionsWithUpdate.add(installedExt)
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionsWithUpdate.isNotEmpty()) {
|
||||
ExtensionUpdateNotifier(context).promptUpdates(extensionsWithUpdate.map { it.name })
|
||||
}
|
||||
|
||||
return extensionsWithUpdate
|
||||
}
|
||||
|
||||
private fun List<AnimeExtensionJsonObject>.toExtensions(): List<AnimeExtension.Available> {
|
||||
return this
|
||||
.filter {
|
||||
val libVersion = it.extractLibVersion()
|
||||
libVersion >= AnimeExtensionLoader.LIB_VERSION_MIN && libVersion <= AnimeExtensionLoader.LIB_VERSION_MAX
|
||||
}
|
||||
.map {
|
||||
AnimeExtension.Available(
|
||||
name = it.name.substringAfter("Aniyomi: "),
|
||||
pkgName = it.pkg,
|
||||
versionName = it.version,
|
||||
versionCode = it.code,
|
||||
libVersion = it.extractLibVersion(),
|
||||
lang = it.lang,
|
||||
isNsfw = it.nsfw == 1,
|
||||
hasReadme = it.hasReadme == 1,
|
||||
hasChangelog = it.hasChangelog == 1,
|
||||
sources = it.sources?.toAnimeExtensionSources().orEmpty(),
|
||||
apkName = it.apk,
|
||||
iconUrl = "${getUrlPrefix()}icon/${it.apk.replace(".apk", ".png")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<AnimeExtensionSourceJsonObject>.toAnimeExtensionSources(): List<AvailableAnimeSources> {
|
||||
return this.map {
|
||||
AvailableAnimeSources(
|
||||
id = it.id,
|
||||
lang = it.lang,
|
||||
name = it.name,
|
||||
baseUrl = it.baseUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getApkUrl(extension: AnimeExtension.Available): String {
|
||||
return "${getUrlPrefix()}apk/${extension.apkName}"
|
||||
}
|
||||
|
||||
private fun getUrlPrefix(): String {
|
||||
return if (requiresFallbackSource) {
|
||||
FALLBACK_REPO_URL_PREFIX
|
||||
} else {
|
||||
REPO_URL_PREFIX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnimeExtensionJsonObject.extractLibVersion(): Double {
|
||||
return version.substringBeforeLast('.').toDouble()
|
||||
}
|
||||
|
||||
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/aniyomiorg/aniyomi-extensions/repo/"
|
||||
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/aniyomiorg/aniyomi-extensions@repo/"
|
||||
|
||||
@Serializable
|
||||
private data class AnimeExtensionJsonObject(
|
||||
val name: String,
|
||||
val pkg: String,
|
||||
val apk: String,
|
||||
val lang: String,
|
||||
val code: Long,
|
||||
val version: String,
|
||||
val nsfw: Int,
|
||||
val hasReadme: Int = 0,
|
||||
val hasChangelog: Int = 0,
|
||||
val sources: List<AnimeExtensionSourceJsonObject>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AnimeExtensionSourceJsonObject(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
)
|
|
@ -0,0 +1,170 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.installer
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Base implementation class for extension installer. To be used inside a foreground [Service].
|
||||
*/
|
||||
abstract class InstallerAnime(private val service: Service) {
|
||||
|
||||
private val extensionManager: AnimeExtensionManager by injectLazy()
|
||||
|
||||
private var waitingInstall = AtomicReference<Entry>(null)
|
||||
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
|
||||
|
||||
private val cancelReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1).takeIf { it >= 0 } ?: return
|
||||
cancelQueue(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installer readiness. If false, queue check will not run.
|
||||
*
|
||||
* @see checkQueue
|
||||
*/
|
||||
abstract var ready: Boolean
|
||||
|
||||
/**
|
||||
* Add an item to install queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
fun addToQueue(downloadId: Long, uri: Uri) {
|
||||
queue.add(Entry(downloadId, uri))
|
||||
checkQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceeds to install the APK of this entry inside this method. Call [continueQueue]
|
||||
* when the install process for this entry is finished to continue the queue.
|
||||
*
|
||||
* @param entry The [Entry] of item to process
|
||||
* @see continueQueue
|
||||
*/
|
||||
@CallSuper
|
||||
open fun processEntry(entry: Entry) {
|
||||
extensionManager.setInstalling(entry.downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before queue continues. Override this to handle when the removed entry is
|
||||
* currently being processed.
|
||||
*
|
||||
* @return true if this entry can be removed from queue.
|
||||
*/
|
||||
open fun cancelEntry(entry: Entry): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the queue to continue processing the next entry and updates the install step
|
||||
* of the completed entry ([waitingInstall]) to [ExtensionManager].
|
||||
*
|
||||
* @param resultStep new install step for the processed entry.
|
||||
* @see waitingInstall
|
||||
*/
|
||||
fun continueQueue(resultStep: InstallStep) {
|
||||
val completedEntry = waitingInstall.getAndSet(null)
|
||||
if (completedEntry != null) {
|
||||
extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
|
||||
checkQueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the queue. The provided service will be stopped if the queue is empty.
|
||||
* Will not be run when not ready.
|
||||
*
|
||||
* @see ready
|
||||
*/
|
||||
fun checkQueue() {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
service.stopSelf()
|
||||
return
|
||||
}
|
||||
val nextEntry = queue.first()
|
||||
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
||||
queue.removeFirst()
|
||||
processEntry(nextEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method when the provided service is destroyed.
|
||||
*/
|
||||
@CallSuper
|
||||
open fun onDestroy() {
|
||||
LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
|
||||
queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
|
||||
queue.clear()
|
||||
waitingInstall.set(null)
|
||||
}
|
||||
|
||||
protected fun getActiveEntry(): Entry? = waitingInstall.get()
|
||||
|
||||
/**
|
||||
* Cancels queue for the provided download ID if exists.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
*/
|
||||
private fun cancelQueue(downloadId: Long) {
|
||||
val waitingInstall = this.waitingInstall.get()
|
||||
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
|
||||
if (cancelEntry(toCancel)) {
|
||||
queue.remove(toCancel)
|
||||
if (waitingInstall == toCancel) {
|
||||
// Currently processing removed entry, continue queue
|
||||
this.waitingInstall.set(null)
|
||||
checkQueue()
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install item to queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
data class Entry(val downloadId: Long, val uri: Uri)
|
||||
|
||||
init {
|
||||
val filter = IntentFilter(ACTION_CANCEL_QUEUE)
|
||||
LocalBroadcastManager.getInstance(service).registerReceiver(cancelReceiver, filter)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_CANCEL_QUEUE = "InstallerAnime.action.CANCEL_QUEUE"
|
||||
private const val EXTRA_DOWNLOAD_ID = "InstallerAnime.extra.DOWNLOAD_ID"
|
||||
|
||||
/**
|
||||
* Attempts to cancel the installation entry for the provided download ID.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
*/
|
||||
fun cancelInstallQueue(context: Context, downloadId: Long) {
|
||||
val intent = Intent(ACTION_CANCEL_QUEUE)
|
||||
intent.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.installer
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.util.lang.use
|
||||
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
||||
import eu.kanade.tachiyomi.util.system.getUriSize
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
class PackageInstallerInstallerAnime(private val service: Service) : InstallerAnime(service) {
|
||||
|
||||
private val packageInstaller = service.packageManager.packageInstaller
|
||||
|
||||
private val packageActionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val userAction = intent.getParcelableExtraCompat<Intent>(Intent.EXTRA_INTENT)
|
||||
if (userAction == null) {
|
||||
logcat(LogPriority.ERROR) { "Fatal error for $intent" }
|
||||
continueQueue(InstallStep.Error)
|
||||
return
|
||||
}
|
||||
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
service.startActivity(userAction)
|
||||
}
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
continueQueue(InstallStep.Idle)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
|
||||
else -> continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSession: Pair<Entry, Int>? = null
|
||||
|
||||
// Always ready
|
||||
override var ready = true
|
||||
|
||||
override fun processEntry(entry: Entry) {
|
||||
super.processEntry(entry)
|
||||
activeSession = null
|
||||
try {
|
||||
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
activeSession = entry to packageInstaller.createSession(installParams)
|
||||
val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException()
|
||||
installParams.setSize(fileSize)
|
||||
|
||||
val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException()
|
||||
val session = packageInstaller.openSession(activeSession!!.second)
|
||||
val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize)
|
||||
session.use {
|
||||
arrayOf(inputStream, outputStream).use {
|
||||
inputStream.copyTo(outputStream)
|
||||
session.fsync(outputStream)
|
||||
}
|
||||
|
||||
val intentSender = PendingIntent.getBroadcast(
|
||||
service,
|
||||
activeSession!!.second,
|
||||
Intent(INSTALL_ACTION),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
|
||||
).intentSender
|
||||
session.commit(intentSender)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
||||
activeSession?.let { (_, sessionId) ->
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelEntry(entry: Entry): Boolean {
|
||||
activeSession?.let { (activeEntry, sessionId) ->
|
||||
if (activeEntry == entry) {
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
service.unregisterReceiver(packageActionReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
init {
|
||||
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_ACTION = "PackageInstallerInstaller.INSTALL_ACTION"
|
|
@ -0,0 +1,79 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.model
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import eu.kanade.tachiyomi.animesource.AnimeSource
|
||||
import tachiyomi.domain.source.anime.model.AnimeSourceData
|
||||
|
||||
sealed class AnimeExtension {
|
||||
|
||||
abstract val name: String
|
||||
abstract val pkgName: String
|
||||
abstract val versionName: String
|
||||
abstract val versionCode: Long
|
||||
abstract val libVersion: Double
|
||||
abstract val lang: String?
|
||||
abstract val isNsfw: Boolean
|
||||
abstract val hasReadme: Boolean
|
||||
abstract val hasChangelog: Boolean
|
||||
|
||||
data class Installed(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
override val hasReadme: Boolean,
|
||||
override val hasChangelog: Boolean,
|
||||
val pkgFactory: String?,
|
||||
val sources: List<AnimeSource>,
|
||||
val icon: Drawable?,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
) : AnimeExtension()
|
||||
|
||||
data class Available(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
override val hasReadme: Boolean,
|
||||
override val hasChangelog: Boolean,
|
||||
val sources: List<AvailableAnimeSources>,
|
||||
val apkName: String,
|
||||
val iconUrl: String,
|
||||
) : AnimeExtension()
|
||||
|
||||
data class Untrusted(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
val signatureHash: String,
|
||||
override val lang: String? = null,
|
||||
override val isNsfw: Boolean = false,
|
||||
override val hasReadme: Boolean = false,
|
||||
override val hasChangelog: Boolean = false,
|
||||
) : AnimeExtension()
|
||||
}
|
||||
|
||||
data class AvailableAnimeSources(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
) {
|
||||
fun toAnimeSourceData(): AnimeSourceData {
|
||||
return AnimeSourceData(
|
||||
id = this.id,
|
||||
lang = this.lang,
|
||||
name = this.name,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.model
|
||||
|
||||
sealed class AnimeLoadResult {
|
||||
class Success(val extension: AnimeExtension.Installed) : AnimeLoadResult()
|
||||
class Untrusted(val extension: AnimeExtension.Untrusted) : AnimeLoadResult()
|
||||
object Error : AnimeLoadResult()
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||
import eu.kanade.tachiyomi.util.system.hasMiuiPackageInstaller
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Activity used to install extensions, because we can only receive the result of the installation
|
||||
* with [startActivityForResult], which we need to update the UI.
|
||||
*/
|
||||
class AnimeExtensionInstallActivity : Activity() {
|
||||
|
||||
// MIUI package installer bug workaround
|
||||
private var ignoreUntil = 0L
|
||||
private var ignoreResult = false
|
||||
private var hasIgnoredResult = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
.setDataAndType(intent.data, intent.type)
|
||||
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
if (hasMiuiPackageInstaller) {
|
||||
ignoreResult = true
|
||||
ignoreUntil = System.nanoTime() + 1.seconds.inWholeNanoseconds
|
||||
}
|
||||
|
||||
try {
|
||||
startActivityForResult(installIntent, INSTALL_REQUEST_CODE)
|
||||
} catch (error: Exception) {
|
||||
// Either install package can't be found (probably bots) or there's a security exception
|
||||
// with the download manager. Nothing we can workaround.
|
||||
toast(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (ignoreResult && System.nanoTime() < ignoreUntil) {
|
||||
hasIgnoredResult = true
|
||||
return
|
||||
}
|
||||
if (requestCode == INSTALL_REQUEST_CODE) {
|
||||
checkInstallationResult(resultCode)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (hasIgnoredResult) {
|
||||
checkInstallationResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkInstallationResult(resultCode: Int) {
|
||||
val downloadId = intent.extras!!.getLong(AnimeExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||
val extensionManager = Injekt.get<AnimeExtensionManager>()
|
||||
val newStep = when (resultCode) {
|
||||
RESULT_OK -> InstallStep.Installed
|
||||
RESULT_CANCELED -> InstallStep.Idle
|
||||
else -> InstallStep.Error
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, newStep)
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_REQUEST_CODE = 500
|
|
@ -0,0 +1,130 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.util
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.lang.launchNow
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
/**
|
||||
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
|
||||
* notifies the given [listener] when the package is an extension.
|
||||
*
|
||||
* @param listener The listener that should be notified of extension installation events.
|
||||
*/
|
||||
internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
||||
BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intent filter this receiver should subscribe to.
|
||||
*/
|
||||
private val filter
|
||||
get() = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when one of the events of the [filter] is received. When the package is an extension,
|
||||
* it's loaded in background and it notifies the [listener] when finished.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is AnimeLoadResult.Success -> listener.onExtensionInstalled(result.extension)
|
||||
|
||||
is AnimeLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is AnimeLoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
// Not needed as a package can't be upgraded if the signature is different
|
||||
// is LoadResult.Untrusted -> {}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName != null) {
|
||||
listener.onPackageUninstalled(pkgName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this package is performing an update.
|
||||
*
|
||||
* @param intent The intent that triggered the event.
|
||||
*/
|
||||
private fun isReplacing(intent: Intent): Boolean {
|
||||
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the extension triggered by the given intent.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param intent The intent containing the package name of the extension.
|
||||
*/
|
||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): AnimeLoadResult {
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName == null) {
|
||||
logcat(LogPriority.WARN) { "Package name not found" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) {
|
||||
AnimeExtensionLoader.loadExtensionFromPkgName(
|
||||
context,
|
||||
pkgName,
|
||||
)
|
||||
}.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package name of the installed, updated or removed application.
|
||||
*/
|
||||
private fun getPackageNameFromIntent(intent: Intent?): String? {
|
||||
return intent?.data?.encodedSchemeSpecificPart ?: return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener that receives extension installation events.
|
||||
*/
|
||||
interface Listener {
|
||||
fun onExtensionInstalled(extension: AnimeExtension.Installed)
|
||||
fun onExtensionUpdated(extension: AnimeExtension.Installed)
|
||||
fun onExtensionUntrusted(extension: AnimeExtension.Untrusted)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.util
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import ani.dantotsu.R
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.extension.anime.installer.InstallerAnime
|
||||
import eu.kanade.tachiyomi.extension.anime.installer.PackageInstallerInstallerAnime
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
|
||||
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
|
||||
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
class AnimeExtensionInstallService : Service() {
|
||||
|
||||
private var installer: InstallerAnime? = null
|
||||
|
||||
override fun onCreate() {
|
||||
val notification = notificationBuilder(Notifications.CHANNEL_EXTENSIONS_UPDATE) {
|
||||
setSmallIcon(R.drawable.spinner_icon)
|
||||
setAutoCancel(false)
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
setContentTitle("Installing Anime Extension...")
|
||||
setProgress(100, 100, true)
|
||||
}.build()
|
||||
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val uri = intent?.data
|
||||
val id = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.takeIf { it != -1L }
|
||||
val installerUsed = intent?.getSerializableExtraCompat<BasePreferences.ExtensionInstaller>(
|
||||
EXTRA_INSTALLER,
|
||||
)
|
||||
if (uri == null || id == null || installerUsed == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (installer == null) {
|
||||
installer = when (installerUsed) {
|
||||
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstallerAnime(this)
|
||||
else -> {
|
||||
logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" }
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
installer!!.addToQueue(id, uri)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
installer?.onDestroy()
|
||||
installer = null
|
||||
}
|
||||
|
||||
override fun onBind(i: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_INSTALLER = "EXTRA_INSTALLER"
|
||||
|
||||
fun getIntent(
|
||||
context: Context,
|
||||
downloadId: Long,
|
||||
uri: Uri,
|
||||
installer: BasePreferences.ExtensionInstaller,
|
||||
): Intent {
|
||||
return Intent(context, AnimeExtensionInstallService::class.java)
|
||||
.setDataAndType(uri, AnimeExtensionInstaller.APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.putExtra(EXTRA_INSTALLER, installer)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,266 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.util
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.anime.installer.InstallerAnime
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the extensions.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
internal class AnimeExtensionInstaller(private val context: Context) {
|
||||
|
||||
/**
|
||||
* The system's download manager
|
||||
*/
|
||||
private val downloadManager = context.getSystemService<DownloadManager>()!!
|
||||
|
||||
/**
|
||||
* The broadcast receiver which listens to download completion events.
|
||||
*/
|
||||
private val downloadReceiver = DownloadCompletionReceiver()
|
||||
|
||||
/**
|
||||
* The currently requested downloads, with the package name (unique id) as key, and the id
|
||||
* returned by the download manager.
|
||||
*/
|
||||
private val activeDownloads = hashMapOf<String, Long>()
|
||||
|
||||
/**
|
||||
* Relay used to notify the installation step of every download.
|
||||
*/
|
||||
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||
|
||||
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
||||
|
||||
/**
|
||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||
* step in the installation process.
|
||||
*
|
||||
* @param url The url of the apk.
|
||||
* @param extension The extension to install.
|
||||
*/
|
||||
fun downloadAndInstall(url: String, extension: AnimeExtension) = Observable.defer {
|
||||
val pkgName = extension.pkgName
|
||||
|
||||
val oldDownload = activeDownloads[pkgName]
|
||||
if (oldDownload != null) {
|
||||
deleteDownload(pkgName)
|
||||
}
|
||||
|
||||
// Register the receiver after removing (and unregistering) the previous download
|
||||
downloadReceiver.register()
|
||||
|
||||
val downloadUri = url.toUri()
|
||||
val request = DownloadManager.Request(downloadUri)
|
||||
.setTitle(extension.name)
|
||||
.setMimeType(APK_MIME)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
|
||||
val id = downloadManager.enqueue(request)
|
||||
activeDownloads[pkgName] = id
|
||||
|
||||
downloadsRelay.filter { it.first == id }
|
||||
.map { it.second }
|
||||
// Poll download status
|
||||
.mergeWith(pollStatus(id))
|
||||
// Stop when the application is installed or errors
|
||||
.takeUntil { it.isCompleted() }
|
||||
// Always notify on main thread
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Always remove the download when unsubscribed
|
||||
.doOnUnsubscribe { deleteDownload(pkgName) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that polls the given download id for its status every second, as the
|
||||
* manager doesn't have any notification system. It'll stop once the download finishes.
|
||||
*
|
||||
* @param id The id of the download to poll.
|
||||
*/
|
||||
private fun pollStatus(id: Long): Observable<InstallStep> {
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
|
||||
return Observable.interval(0, 1, TimeUnit.SECONDS)
|
||||
// Get the current download status
|
||||
.map {
|
||||
downloadManager.query(query).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
}
|
||||
}
|
||||
// Ignore duplicate results
|
||||
.distinctUntilChanged()
|
||||
// Stop polling when the download fails or finishes
|
||||
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
|
||||
// Map to our model
|
||||
.flatMap { status ->
|
||||
when (status) {
|
||||
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
|
||||
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
|
||||
else -> Observable.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to install the extension at the given uri.
|
||||
*
|
||||
* @param uri The uri of the extension to install.
|
||||
*/
|
||||
fun installApk(downloadId: Long, uri: Uri) {
|
||||
when (val installer = extensionInstaller.get()) {
|
||||
BasePreferences.ExtensionInstaller.LEGACY -> {
|
||||
val intent = Intent(context, AnimeExtensionInstallActivity::class.java)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
else -> {
|
||||
val intent =
|
||||
AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels extension install and remove from download manager and installer.
|
||||
*/
|
||||
fun cancelInstall(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
||||
downloadManager.remove(downloadId)
|
||||
InstallerAnime.cancelInstallQueue(context, downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to uninstall the extension by the given package name.
|
||||
*
|
||||
* @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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the step of the installation of an extension.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
* @param step New install step.
|
||||
*/
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
downloadsRelay.call(downloadId to step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the download for the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the download to delete.
|
||||
*/
|
||||
private fun deleteDownload(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName)
|
||||
if (downloadId != null) {
|
||||
downloadManager.remove(downloadId)
|
||||
}
|
||||
if (activeDownloads.isEmpty()) {
|
||||
downloadReceiver.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver that listens to download status events.
|
||||
*/
|
||||
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Whether this receiver is currently registered.
|
||||
*/
|
||||
private var isRegistered = false
|
||||
|
||||
/**
|
||||
* Registers this receiver if it's not already.
|
||||
*/
|
||||
fun register() {
|
||||
if (isRegistered) return
|
||||
isRegistered = true
|
||||
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this receiver if it's not already.
|
||||
*/
|
||||
fun unregister() {
|
||||
if (!isRegistered) return
|
||||
isRegistered = false
|
||||
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a download event is received. It looks for the download in the current active
|
||||
* downloads and notifies its installation step.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
||||
|
||||
// Avoid events for downloads we didn't request
|
||||
if (id !in activeDownloads.values) return
|
||||
|
||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||
|
||||
// Set next installation step
|
||||
if (uri == null) {
|
||||
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
|
||||
downloadsRelay.call(id to InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val localUri = cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
|
||||
).removePrefix(FILE_SCHEME)
|
||||
|
||||
installApk(id, File(localUri).getUriCompat(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APK_MIME = "application/vnd.android.package-archive"
|
||||
const val EXTRA_DOWNLOAD_ID = "AnimeExtensionInstaller.extra.DOWNLOAD_ID"
|
||||
const val FILE_SCHEME = "file://"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
package eu.kanade.tachiyomi.extension.anime.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
|
||||
import eu.kanade.tachiyomi.animesource.AnimeSource
|
||||
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.runBlocking
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Class that handles the loading of the extensions installed in the system.
|
||||
*/
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
internal object AnimeExtensionLoader {
|
||||
|
||||
private val preferences: SourcePreferences by injectLazy()
|
||||
private val loadNsfwSource by lazy {
|
||||
preferences.showNsfwSource().get()
|
||||
}
|
||||
|
||||
private const val EXTENSION_FEATURE = "tachiyomi.animeextension"
|
||||
private const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class"
|
||||
private const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory"
|
||||
private const val METADATA_NSFW = "tachiyomi.animeextension.nsfw"
|
||||
private const val METADATA_HAS_README = "tachiyomi.animeextension.hasReadme"
|
||||
private const val METADATA_HAS_CHANGELOG = "tachiyomi.animeextension.hasChangelog"
|
||||
const val LIB_VERSION_MIN = 12
|
||||
const val LIB_VERSION_MAX = 15
|
||||
|
||||
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
|
||||
// jmir1's key
|
||||
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c"
|
||||
|
||||
/**
|
||||
* List of the trusted signatures.
|
||||
*/
|
||||
var trustedSignatures = mutableSetOf<String>() + preferences.trustedSignatures().get() + officialSignature
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun loadExtensions(context: Context): List<AnimeLoadResult> {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong()))
|
||||
} else {
|
||||
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
}
|
||||
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
|
||||
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) }
|
||||
}
|
||||
deferred.map { it.await() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load an extension from the given package name. It checks if the extension
|
||||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): AnimeLoadResult {
|
||||
val pkgInfo = try {
|
||||
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
return loadExtension(context, pkgName, pkgInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
*/
|
||||
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): 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 extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Aniyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
|
||||
|
||||
if (versionName.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Missing versionName for extension $extName" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
// Validate lib version
|
||||
val libVersion = versionName.substringBeforeLast('.').toDoubleOrNull()
|
||||
if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||
logcat(LogPriority.WARN) {
|
||||
"Lib version is $libVersion, while only versions " +
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
|
||||
}
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(pkgInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
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)
|
||||
logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" })
|
||||
return AnimeLoadResult.Untrusted(extension)
|
||||
}
|
||||
|
||||
val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1
|
||||
if (!loadNsfwSource && isNsfw) {
|
||||
logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
|
||||
val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1
|
||||
|
||||
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
||||
|
||||
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
|
||||
.split(";")
|
||||
.map {
|
||||
val sourceClass = it.trim()
|
||||
if (sourceClass.startsWith(".")) {
|
||||
pkgInfo.packageName + sourceClass
|
||||
} else {
|
||||
sourceClass
|
||||
}
|
||||
}
|
||||
.flatMap {
|
||||
try {
|
||||
when (val obj = Class.forName(it, false, classLoader).newInstance()) {
|
||||
is AnimeSource -> listOf(obj)
|
||||
is AnimeSourceFactory -> obj.createSources()
|
||||
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
val langs = sources.filterIsInstance<AnimeCatalogueSource>()
|
||||
.map { it.lang }
|
||||
.toSet()
|
||||
val lang = when (langs.size) {
|
||||
0 -> ""
|
||||
1 -> langs.first()
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
val extension = AnimeExtension.Installed(
|
||||
name = extName,
|
||||
pkgName = pkgName,
|
||||
versionName = versionName,
|
||||
versionCode = versionCode,
|
||||
libVersion = libVersion,
|
||||
lang = lang,
|
||||
isNsfw = isNsfw,
|
||||
hasReadme = hasReadme,
|
||||
hasChangelog = hasChangelog,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
isUnofficial = signatureHash != officialSignature,
|
||||
icon = context.getApplicationIcon(pkgName),
|
||||
)
|
||||
return AnimeLoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is an extension.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
|
||||
return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||
val signatures = pkgInfo.signatures
|
||||
return if (signatures != null && signatures.isNotEmpty()) {
|
||||
Hash.sha256(signatures.first().toByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,365 @@
|
|||
package eu.kanade.tachiyomi.extension.manga
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.manga.api.MangaExtensionGithubApi
|
||||
import eu.kanade.tachiyomi.extension.manga.model.AvailableMangaSources
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult
|
||||
import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionInstallReceiver
|
||||
import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionInstaller
|
||||
import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionLoader
|
||||
import eu.kanade.tachiyomi.util.preference.plusAssign
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import tachiyomi.core.util.lang.launchNow
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.source.manga.model.MangaSourceData
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The manager of extensions installed as another apk which extend the available sources. It handles
|
||||
* the retrieval of remotely available extensions as well as installing, updating and removing them.
|
||||
* To avoid malicious distribution, every extension must be signed and it will only be loaded if its
|
||||
* signature is trusted, otherwise the user will be prompted with a warning to trust it before being
|
||||
* loaded.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param preferences The application preferences.
|
||||
*/
|
||||
class MangaExtensionManager(
|
||||
private val context: Context,
|
||||
private val preferences: SourcePreferences = Injekt.get(),
|
||||
) {
|
||||
|
||||
var isInitialized = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* API where all the available extensions can be found.
|
||||
*/
|
||||
private val api = MangaExtensionGithubApi()
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the extensions.
|
||||
*/
|
||||
private val installer by lazy { MangaExtensionInstaller(context) }
|
||||
|
||||
private val iconMap = mutableMapOf<String, Drawable>()
|
||||
|
||||
private val _installedExtensionsFlow = MutableStateFlow(emptyList<MangaExtension.Installed>())
|
||||
val installedExtensionsFlow = _installedExtensionsFlow.asStateFlow()
|
||||
|
||||
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet()
|
||||
|
||||
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 null
|
||||
}
|
||||
|
||||
private val _availableExtensionsFlow = MutableStateFlow(emptyList<MangaExtension.Available>())
|
||||
val availableExtensionsFlow = _availableExtensionsFlow.asStateFlow()
|
||||
|
||||
private var availableExtensionsSourcesData: Map<Long, MangaSourceData> = emptyMap()
|
||||
|
||||
private fun setupAvailableExtensionsSourcesDataMap(extensions: List<MangaExtension.Available>) {
|
||||
if (extensions.isEmpty()) return
|
||||
availableExtensionsSourcesData = extensions
|
||||
.flatMap { ext -> ext.sources.map { it.toSourceData() } }
|
||||
.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun getSourceData(id: Long) = availableExtensionsSourcesData[id]
|
||||
|
||||
private val _untrustedExtensionsFlow = MutableStateFlow(emptyList<MangaExtension.Untrusted>())
|
||||
val untrustedExtensionsFlow = _untrustedExtensionsFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
initExtensions()
|
||||
MangaExtensionInstallReceiver(InstallationListener()).register(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and registers the installed extensions.
|
||||
*/
|
||||
private fun initExtensions() {
|
||||
val extensions = MangaExtensionLoader.loadMangaExtensions(context)
|
||||
|
||||
_installedExtensionsFlow.value = extensions
|
||||
.filterIsInstance<MangaLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
_untrustedExtensionsFlow.value = extensions
|
||||
.filterIsInstance<MangaLoadResult.Untrusted>()
|
||||
.map { it.extension }
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the available extensions in the [api] and updates [availableExtensions].
|
||||
*/
|
||||
suspend fun findAvailableExtensions() {
|
||||
val extensions: List<MangaExtension.Available> = try {
|
||||
api.findExtensions()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
withUIContext { context.toast("Failed to get manga extensions") }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
enableAdditionalSubLanguages(extensions)
|
||||
|
||||
_availableExtensionsFlow.value = extensions
|
||||
updatedInstalledExtensionsStatuses(extensions)
|
||||
setupAvailableExtensionsSourcesDataMap(extensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the additional sub-languages in the app first run. This addresses
|
||||
* the issue where users still need to enable some specific languages even when
|
||||
* the device language is inside that major group. As an example, if a user
|
||||
* has a zh device language, the app will also enable zh-Hans and zh-Hant.
|
||||
*
|
||||
* If the user have already changed the enabledLanguages preference value once,
|
||||
* the new languages will not be added to respect the user enabled choices.
|
||||
*/
|
||||
private fun enableAdditionalSubLanguages(extensions: List<MangaExtension.Available>) {
|
||||
if (subLanguagesEnabledOnFirstRun || extensions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the source lang as some aren't present on the extension level.
|
||||
val availableLanguages = extensions
|
||||
.flatMap(MangaExtension.Available::sources)
|
||||
.distinctBy(AvailableMangaSources::lang)
|
||||
.map(AvailableMangaSources::lang)
|
||||
|
||||
val deviceLanguage = Locale.getDefault().language
|
||||
val defaultLanguages = preferences.enabledLanguages().defaultValue()
|
||||
val languagesToEnable = availableLanguages.filter {
|
||||
it != deviceLanguage && it.startsWith(deviceLanguage)
|
||||
}
|
||||
|
||||
preferences.enabledLanguages().set(defaultLanguages + languagesToEnable)
|
||||
subLanguagesEnabledOnFirstRun = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the update field of the installed extensions with the given [availableExtensions].
|
||||
*
|
||||
* @param availableExtensions The list of extensions given by the [api].
|
||||
*/
|
||||
private fun updatedInstalledExtensionsStatuses(availableExtensions: List<MangaExtension.Available>) {
|
||||
if (availableExtensions.isEmpty()) {
|
||||
preferences.mangaExtensionUpdatesCount().set(0)
|
||||
return
|
||||
}
|
||||
|
||||
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList()
|
||||
var changed = false
|
||||
|
||||
for ((index, installedExt) in mutInstalledExtensions.withIndex()) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = availableExtensions.find { it.pkgName == pkgName }
|
||||
|
||||
if (!installedExt.isUnofficial && availableExt == null && !installedExt.isObsolete) {
|
||||
mutInstalledExtensions[index] = installedExt.copy(isObsolete = true)
|
||||
changed = true
|
||||
} else if (availableExt != null) {
|
||||
val hasUpdate = installedExt.updateExists(availableExt)
|
||||
|
||||
if (installedExt.hasUpdate != hasUpdate) {
|
||||
mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_installedExtensionsFlow.value = mutInstalledExtensions
|
||||
}
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given extension. It will complete
|
||||
* once the extension is installed or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The extension to be installed.
|
||||
*/
|
||||
fun installExtension(extension: MangaExtension.Available): Observable<InstallStep> {
|
||||
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given extension. It will complete
|
||||
* once the extension is updated or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The extension to be updated.
|
||||
*/
|
||||
fun updateExtension(extension: MangaExtension.Installed): Observable<InstallStep> {
|
||||
val availableExt = _availableExtensionsFlow.value.find { it.pkgName == extension.pkgName }
|
||||
?: return Observable.empty()
|
||||
return installExtension(availableExt)
|
||||
}
|
||||
|
||||
fun cancelInstallUpdateExtension(extension: MangaExtension) {
|
||||
installer.cancelInstall(extension.pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets to "installing" status of an extension installation.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
*/
|
||||
fun setInstalling(downloadId: Long) {
|
||||
installer.updateInstallStep(downloadId, InstallStep.Installing)
|
||||
}
|
||||
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
installer.updateInstallStep(downloadId, step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalls the extension that matches the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the application to uninstall.
|
||||
*/
|
||||
fun uninstallExtension(pkgName: String) {
|
||||
installer.uninstallApk(pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given signature to the list of trusted signatures. It also loads in background the
|
||||
* extensions that match this signature.
|
||||
*
|
||||
* @param signature The signature to whitelist.
|
||||
*/
|
||||
fun trustSignature(signature: String) {
|
||||
val untrustedSignatures = _untrustedExtensionsFlow.value.map { it.signatureHash }.toSet()
|
||||
if (signature !in untrustedSignatures) return
|
||||
|
||||
MangaExtensionLoader.trustedSignatures += signature
|
||||
preferences.trustedSignatures() += signature
|
||||
|
||||
val nowTrustedExtensions = _untrustedExtensionsFlow.value.filter { it.signatureHash == signature }
|
||||
_untrustedExtensionsFlow.value -= nowTrustedExtensions
|
||||
|
||||
val ctx = context
|
||||
launchNow {
|
||||
nowTrustedExtensions
|
||||
.map { extension ->
|
||||
async { MangaExtensionLoader.loadMangaExtensionFromPkgName(ctx, extension.pkgName) }
|
||||
}
|
||||
.map { it.await() }
|
||||
.forEach { result ->
|
||||
if (result is MangaLoadResult.Success) {
|
||||
registerNewExtension(result.extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given extension in this and the source managers.
|
||||
*
|
||||
* @param extension The extension to be registered.
|
||||
*/
|
||||
private fun registerNewExtension(extension: MangaExtension.Installed) {
|
||||
_installedExtensionsFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given updated extension in this and the source managers previously removing
|
||||
* the outdated ones.
|
||||
*
|
||||
* @param extension The extension to be registered.
|
||||
*/
|
||||
private fun registerUpdatedExtension(extension: MangaExtension.Installed) {
|
||||
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList()
|
||||
val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName }
|
||||
if (oldExtension != null) {
|
||||
mutInstalledExtensions -= oldExtension
|
||||
}
|
||||
mutInstalledExtensions += extension
|
||||
_installedExtensionsFlow.value = mutInstalledExtensions
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the extension in this and the source managers given its package name. Note this
|
||||
* method is called for every uninstalled application in the system.
|
||||
*
|
||||
* @param pkgName The package name of the uninstalled application.
|
||||
*/
|
||||
private fun unregisterExtension(pkgName: String) {
|
||||
val installedExtension = _installedExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (installedExtension != null) {
|
||||
_installedExtensionsFlow.value -= installedExtension
|
||||
}
|
||||
val untrustedExtension = _untrustedExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (untrustedExtension != null) {
|
||||
_untrustedExtensionsFlow.value -= untrustedExtension
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener which receives events of the extensions being installed, updated or removed.
|
||||
*/
|
||||
private inner class InstallationListener : MangaExtensionInstallReceiver.Listener {
|
||||
|
||||
override fun onExtensionInstalled(extension: MangaExtension.Installed) {
|
||||
registerNewExtension(extension.withUpdateCheck())
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUpdated(extension: MangaExtension.Installed) {
|
||||
registerUpdatedExtension(extension.withUpdateCheck())
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUntrusted(extension: MangaExtension.Untrusted) {
|
||||
_untrustedExtensionsFlow.value += extension
|
||||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
unregisterExtension(pkgName)
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension method to set the update field of an installed extension.
|
||||
*/
|
||||
private fun MangaExtension.Installed.withUpdateCheck(): MangaExtension.Installed {
|
||||
return if (updateExists()) {
|
||||
copy(hasUpdate = true)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaExtension.Installed.updateExists(availableExtension: MangaExtension.Available? = null): Boolean {
|
||||
val availableExt = availableExtension ?: _availableExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (isUnofficial || availableExt == null) return false
|
||||
|
||||
return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion)
|
||||
}
|
||||
|
||||
private fun updatePendingUpdatesCount() {
|
||||
preferences.mangaExtensionUpdatesCount().set(_installedExtensionsFlow.value.count { it.hasUpdate })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.api
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier
|
||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.manga.model.AvailableMangaSources
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult
|
||||
import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionLoader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.Preference
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Date
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
internal class MangaExtensionGithubApi {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val preferenceStore: PreferenceStore by injectLazy()
|
||||
private val extensionManager: MangaExtensionManager by injectLazy()
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val lastExtCheck: Preference<Long> by lazy {
|
||||
preferenceStore.getLong("last_ext_check", 0)
|
||||
}
|
||||
|
||||
private var requiresFallbackSource = false
|
||||
|
||||
suspend fun findExtensions(): List<MangaExtension.Available> {
|
||||
return withIOContext {
|
||||
val githubResponse = if (requiresFallbackSource) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
networkService.client
|
||||
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
|
||||
requiresFallbackSource = true
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val response = githubResponse ?: run {
|
||||
networkService.client
|
||||
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
}
|
||||
|
||||
val extensions = with(json) {
|
||||
response
|
||||
.parseAs<List<ExtensionJsonObject>>()
|
||||
.toExtensions()
|
||||
}
|
||||
|
||||
// Sanity check - a small number of extensions probably means something broke
|
||||
// with the repo generator
|
||||
if (extensions.size < 100) {
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
extensions
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<MangaExtension.Installed>? {
|
||||
// Limit checks to once a day at most
|
||||
if (fromAvailableExtensionList && Date().time < lastExtCheck.get() + 1.days.inWholeMilliseconds) {
|
||||
return null
|
||||
}
|
||||
|
||||
val extensions = if (fromAvailableExtensionList) {
|
||||
extensionManager.availableExtensionsFlow.value
|
||||
} else {
|
||||
findExtensions().also { lastExtCheck.set(Date().time) }
|
||||
}
|
||||
|
||||
val installedExtensions = MangaExtensionLoader.loadMangaExtensions(context)
|
||||
.filterIsInstance<MangaLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
val extensionsWithUpdate = mutableListOf<MangaExtension.Installed>()
|
||||
for (installedExt in installedExtensions) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
|
||||
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
|
||||
val hasUpdatedLib = availableExt.libVersion > installedExt.libVersion
|
||||
val hasUpdate = installedExt.isUnofficial.not() && (hasUpdatedVer || hasUpdatedLib)
|
||||
if (hasUpdate) {
|
||||
extensionsWithUpdate.add(installedExt)
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionsWithUpdate.isNotEmpty()) {
|
||||
ExtensionUpdateNotifier(context).promptUpdates(extensionsWithUpdate.map { it.name })
|
||||
}
|
||||
|
||||
return extensionsWithUpdate
|
||||
}
|
||||
|
||||
private fun List<ExtensionJsonObject>.toExtensions(): List<MangaExtension.Available> {
|
||||
return this
|
||||
.filter {
|
||||
val libVersion = it.extractLibVersion()
|
||||
libVersion >= MangaExtensionLoader.LIB_VERSION_MIN && libVersion <= MangaExtensionLoader.LIB_VERSION_MAX
|
||||
}
|
||||
.map {
|
||||
MangaExtension.Available(
|
||||
name = it.name.substringAfter("Tachiyomi: "),
|
||||
pkgName = it.pkg,
|
||||
versionName = it.version,
|
||||
versionCode = it.code,
|
||||
libVersion = it.extractLibVersion(),
|
||||
lang = it.lang,
|
||||
isNsfw = it.nsfw == 1,
|
||||
hasReadme = it.hasReadme == 1,
|
||||
hasChangelog = it.hasChangelog == 1,
|
||||
sources = it.sources?.toExtensionSources().orEmpty(),
|
||||
apkName = it.apk,
|
||||
iconUrl = "${getUrlPrefix()}icon/${it.apk.replace(".apk", ".png")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ExtensionSourceJsonObject>.toExtensionSources(): List<AvailableMangaSources> {
|
||||
return this.map {
|
||||
AvailableMangaSources(
|
||||
id = it.id,
|
||||
lang = it.lang,
|
||||
name = it.name,
|
||||
baseUrl = it.baseUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getApkUrl(extension: MangaExtension.Available): String {
|
||||
return "${getUrlPrefix()}apk/${extension.apkName}"
|
||||
}
|
||||
|
||||
private fun getUrlPrefix(): String {
|
||||
return if (requiresFallbackSource) {
|
||||
FALLBACK_REPO_URL_PREFIX
|
||||
} else {
|
||||
REPO_URL_PREFIX
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExtensionJsonObject.extractLibVersion(): Double {
|
||||
return version.substringBeforeLast('.').toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
|
||||
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/"
|
||||
|
||||
@Serializable
|
||||
private data class ExtensionJsonObject(
|
||||
val name: String,
|
||||
val pkg: String,
|
||||
val apk: String,
|
||||
val lang: String,
|
||||
val code: Long,
|
||||
val version: String,
|
||||
val nsfw: Int,
|
||||
val hasReadme: Int = 0,
|
||||
val hasChangelog: Int = 0,
|
||||
val sources: List<ExtensionSourceJsonObject>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ExtensionSourceJsonObject(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
)
|
|
@ -0,0 +1,170 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.installer
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Base implementation class for extension installer. To be used inside a foreground [Service].
|
||||
*/
|
||||
abstract class InstallerManga(private val service: Service) {
|
||||
|
||||
private val extensionManager: MangaExtensionManager by injectLazy()
|
||||
|
||||
private var waitingInstall = AtomicReference<Entry>(null)
|
||||
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
|
||||
|
||||
private val cancelReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1).takeIf { it >= 0 } ?: return
|
||||
cancelQueue(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installer readiness. If false, queue check will not run.
|
||||
*
|
||||
* @see checkQueue
|
||||
*/
|
||||
abstract var ready: Boolean
|
||||
|
||||
/**
|
||||
* Add an item to install queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [MangaExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
fun addToQueue(downloadId: Long, uri: Uri) {
|
||||
queue.add(Entry(downloadId, uri))
|
||||
checkQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceeds to install the APK of this entry inside this method. Call [continueQueue]
|
||||
* when the install process for this entry is finished to continue the queue.
|
||||
*
|
||||
* @param entry The [Entry] of item to process
|
||||
* @see continueQueue
|
||||
*/
|
||||
@CallSuper
|
||||
open fun processEntry(entry: Entry) {
|
||||
extensionManager.setInstalling(entry.downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before queue continues. Override this to handle when the removed entry is
|
||||
* currently being processed.
|
||||
*
|
||||
* @return true if this entry can be removed from queue.
|
||||
*/
|
||||
open fun cancelEntry(entry: Entry): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the queue to continue processing the next entry and updates the install step
|
||||
* of the completed entry ([waitingInstall]) to [MangaExtensionManager].
|
||||
*
|
||||
* @param resultStep new install step for the processed entry.
|
||||
* @see waitingInstall
|
||||
*/
|
||||
fun continueQueue(resultStep: InstallStep) {
|
||||
val completedEntry = waitingInstall.getAndSet(null)
|
||||
if (completedEntry != null) {
|
||||
extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
|
||||
checkQueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the queue. The provided service will be stopped if the queue is empty.
|
||||
* Will not be run when not ready.
|
||||
*
|
||||
* @see ready
|
||||
*/
|
||||
fun checkQueue() {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
service.stopSelf()
|
||||
return
|
||||
}
|
||||
val nextEntry = queue.first()
|
||||
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
||||
queue.removeFirst()
|
||||
processEntry(nextEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method when the provided service is destroyed.
|
||||
*/
|
||||
@CallSuper
|
||||
open fun onDestroy() {
|
||||
LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
|
||||
queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
|
||||
queue.clear()
|
||||
waitingInstall.set(null)
|
||||
}
|
||||
|
||||
protected fun getActiveEntry(): Entry? = waitingInstall.get()
|
||||
|
||||
/**
|
||||
* Cancels queue for the provided download ID if exists.
|
||||
*
|
||||
* @param downloadId Download ID as known by [MangaExtensionManager]
|
||||
*/
|
||||
private fun cancelQueue(downloadId: Long) {
|
||||
val waitingInstall = this.waitingInstall.get()
|
||||
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
|
||||
if (cancelEntry(toCancel)) {
|
||||
queue.remove(toCancel)
|
||||
if (waitingInstall == toCancel) {
|
||||
// Currently processing removed entry, continue queue
|
||||
this.waitingInstall.set(null)
|
||||
checkQueue()
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install item to queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [MangaExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
data class Entry(val downloadId: Long, val uri: Uri)
|
||||
|
||||
init {
|
||||
val filter = IntentFilter(ACTION_CANCEL_QUEUE)
|
||||
LocalBroadcastManager.getInstance(service).registerReceiver(cancelReceiver, filter)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_CANCEL_QUEUE = "Installer.action.CANCEL_QUEUE"
|
||||
private const val EXTRA_DOWNLOAD_ID = "Installer.extra.DOWNLOAD_ID"
|
||||
|
||||
/**
|
||||
* Attempts to cancel the installation entry for the provided download ID.
|
||||
*
|
||||
* @param downloadId Download ID as known by [MangaExtensionManager]
|
||||
*/
|
||||
fun cancelInstallQueue(context: Context, downloadId: Long) {
|
||||
val intent = Intent(ACTION_CANCEL_QUEUE)
|
||||
intent.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.installer
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.util.lang.use
|
||||
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
||||
import eu.kanade.tachiyomi.util.system.getUriSize
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
class PackageInstallerInstallerManga(private val service: Service) : InstallerManga(service) {
|
||||
|
||||
private val packageInstaller = service.packageManager.packageInstaller
|
||||
|
||||
private val packageActionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val userAction = intent.getParcelableExtraCompat<Intent>(Intent.EXTRA_INTENT)
|
||||
if (userAction == null) {
|
||||
logcat(LogPriority.ERROR) { "Fatal error for $intent" }
|
||||
continueQueue(InstallStep.Error)
|
||||
return
|
||||
}
|
||||
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
service.startActivity(userAction)
|
||||
}
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
continueQueue(InstallStep.Idle)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
|
||||
else -> continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSession: Pair<Entry, Int>? = null
|
||||
|
||||
// Always ready
|
||||
override var ready = true
|
||||
|
||||
override fun processEntry(entry: Entry) {
|
||||
super.processEntry(entry)
|
||||
activeSession = null
|
||||
try {
|
||||
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
activeSession = entry to packageInstaller.createSession(installParams)
|
||||
val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException()
|
||||
installParams.setSize(fileSize)
|
||||
|
||||
val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException()
|
||||
val session = packageInstaller.openSession(activeSession!!.second)
|
||||
val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize)
|
||||
session.use {
|
||||
arrayOf(inputStream, outputStream).use {
|
||||
inputStream.copyTo(outputStream)
|
||||
session.fsync(outputStream)
|
||||
}
|
||||
|
||||
val intentSender = PendingIntent.getBroadcast(
|
||||
service,
|
||||
activeSession!!.second,
|
||||
Intent(INSTALL_ACTION),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
|
||||
).intentSender
|
||||
session.commit(intentSender)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
||||
activeSession?.let { (_, sessionId) ->
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelEntry(entry: Entry): Boolean {
|
||||
activeSession?.let { (activeEntry, sessionId) ->
|
||||
if (activeEntry == entry) {
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
service.unregisterReceiver(packageActionReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
init {
|
||||
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_ACTION = "PackageInstallerInstaller.INSTALL_ACTION"
|
|
@ -0,0 +1,79 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.model
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import eu.kanade.tachiyomi.source.MangaSource
|
||||
import tachiyomi.domain.source.manga.model.MangaSourceData
|
||||
|
||||
sealed class MangaExtension {
|
||||
|
||||
abstract val name: String
|
||||
abstract val pkgName: String
|
||||
abstract val versionName: String
|
||||
abstract val versionCode: Long
|
||||
abstract val libVersion: Double
|
||||
abstract val lang: String?
|
||||
abstract val isNsfw: Boolean
|
||||
abstract val hasReadme: Boolean
|
||||
abstract val hasChangelog: Boolean
|
||||
|
||||
data class Installed(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
override val hasReadme: Boolean,
|
||||
override val hasChangelog: Boolean,
|
||||
val pkgFactory: String?,
|
||||
val sources: List<MangaSource>,
|
||||
val icon: Drawable?,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
) : MangaExtension()
|
||||
|
||||
data class Available(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
override val hasReadme: Boolean,
|
||||
override val hasChangelog: Boolean,
|
||||
val sources: List<AvailableMangaSources>,
|
||||
val apkName: String,
|
||||
val iconUrl: String,
|
||||
) : MangaExtension()
|
||||
|
||||
data class Untrusted(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
val signatureHash: String,
|
||||
override val lang: String? = null,
|
||||
override val isNsfw: Boolean = false,
|
||||
override val hasReadme: Boolean = false,
|
||||
override val hasChangelog: Boolean = false,
|
||||
) : MangaExtension()
|
||||
}
|
||||
|
||||
data class AvailableMangaSources(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
) {
|
||||
fun toSourceData(): MangaSourceData {
|
||||
return MangaSourceData(
|
||||
id = this.id,
|
||||
lang = this.lang,
|
||||
name = this.name,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.model
|
||||
|
||||
sealed class MangaLoadResult {
|
||||
class Success(val extension: MangaExtension.Installed) : MangaLoadResult()
|
||||
class Untrusted(val extension: MangaExtension.Untrusted) : MangaLoadResult()
|
||||
object Error : MangaLoadResult()
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||
import eu.kanade.tachiyomi.util.system.hasMiuiPackageInstaller
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Activity used to install extensions, because we can only receive the result of the installation
|
||||
* with [startActivityForResult], which we need to update the UI.
|
||||
*/
|
||||
class MangaExtensionInstallActivity : Activity() {
|
||||
|
||||
// MIUI package installer bug workaround
|
||||
private var ignoreUntil = 0L
|
||||
private var ignoreResult = false
|
||||
private var hasIgnoredResult = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
.setDataAndType(intent.data, intent.type)
|
||||
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
if (hasMiuiPackageInstaller) {
|
||||
ignoreResult = true
|
||||
ignoreUntil = System.nanoTime() + 1.seconds.inWholeNanoseconds
|
||||
}
|
||||
|
||||
try {
|
||||
startActivityForResult(installIntent, INSTALL_REQUEST_CODE)
|
||||
} catch (error: Exception) {
|
||||
// Either install package can't be found (probably bots) or there's a security exception
|
||||
// with the download manager. Nothing we can workaround.
|
||||
toast(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (ignoreResult && System.nanoTime() < ignoreUntil) {
|
||||
hasIgnoredResult = true
|
||||
return
|
||||
}
|
||||
if (requestCode == INSTALL_REQUEST_CODE) {
|
||||
checkInstallationResult(resultCode)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (hasIgnoredResult) {
|
||||
checkInstallationResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkInstallationResult(resultCode: Int) {
|
||||
val downloadId = intent.extras!!.getLong(MangaExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||
val extensionManager = Injekt.get<MangaExtensionManager>()
|
||||
val newStep = when (resultCode) {
|
||||
RESULT_OK -> InstallStep.Installed
|
||||
RESULT_CANCELED -> InstallStep.Idle
|
||||
else -> InstallStep.Error
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, newStep)
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_REQUEST_CODE = 500
|
|
@ -0,0 +1,130 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.util
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.lang.launchNow
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
/**
|
||||
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
|
||||
* notifies the given [listener] when the package is an extension.
|
||||
*
|
||||
* @param listener The listener that should be notified of extension installation events.
|
||||
*/
|
||||
internal class MangaExtensionInstallReceiver(private val listener: Listener) :
|
||||
BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intent filter this receiver should subscribe to.
|
||||
*/
|
||||
private val filter
|
||||
get() = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when one of the events of the [filter] is received. When the package is an extension,
|
||||
* it's loaded in background and it notifies the [listener] when finished.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is MangaLoadResult.Success -> listener.onExtensionInstalled(result.extension)
|
||||
|
||||
is MangaLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is MangaLoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
// Not needed as a package can't be upgraded if the signature is different
|
||||
// is LoadResult.Untrusted -> {}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName != null) {
|
||||
listener.onPackageUninstalled(pkgName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this package is performing an update.
|
||||
*
|
||||
* @param intent The intent that triggered the event.
|
||||
*/
|
||||
private fun isReplacing(intent: Intent): Boolean {
|
||||
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the extension triggered by the given intent.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param intent The intent containing the package name of the extension.
|
||||
*/
|
||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): MangaLoadResult {
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName == null) {
|
||||
logcat(LogPriority.WARN) { "Package name not found" }
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) {
|
||||
MangaExtensionLoader.loadMangaExtensionFromPkgName(
|
||||
context,
|
||||
pkgName,
|
||||
)
|
||||
}.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package name of the installed, updated or removed application.
|
||||
*/
|
||||
private fun getPackageNameFromIntent(intent: Intent?): String? {
|
||||
return intent?.data?.encodedSchemeSpecificPart ?: return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener that receives extension installation events.
|
||||
*/
|
||||
interface Listener {
|
||||
fun onExtensionInstalled(extension: MangaExtension.Installed)
|
||||
fun onExtensionUpdated(extension: MangaExtension.Installed)
|
||||
fun onExtensionUntrusted(extension: MangaExtension.Untrusted)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.util
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import ani.dantotsu.R
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.extension.manga.installer.InstallerManga
|
||||
import eu.kanade.tachiyomi.extension.manga.installer.PackageInstallerInstallerManga
|
||||
import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
|
||||
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
|
||||
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
class MangaExtensionInstallService : Service() {
|
||||
|
||||
private var installer: InstallerManga? = null
|
||||
|
||||
override fun onCreate() {
|
||||
val notification = notificationBuilder(Notifications.CHANNEL_EXTENSIONS_UPDATE) {
|
||||
setSmallIcon(R.drawable.spinner_icon)
|
||||
setAutoCancel(false)
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
setContentTitle("Installing manga extension...")
|
||||
setProgress(100, 100, true)
|
||||
}.build()
|
||||
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val uri = intent?.data
|
||||
val id = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.takeIf { it != -1L }
|
||||
val installerUsed = intent?.getSerializableExtraCompat<BasePreferences.ExtensionInstaller>(
|
||||
EXTRA_INSTALLER,
|
||||
)
|
||||
if (uri == null || id == null || installerUsed == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (installer == null) {
|
||||
installer = when (installerUsed) {
|
||||
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstallerManga(this)
|
||||
else -> {
|
||||
logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" }
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
installer!!.addToQueue(id, uri)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
installer?.onDestroy()
|
||||
installer = null
|
||||
}
|
||||
|
||||
override fun onBind(i: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_INSTALLER = "EXTRA_INSTALLER"
|
||||
|
||||
fun getIntent(
|
||||
context: Context,
|
||||
downloadId: Long,
|
||||
uri: Uri,
|
||||
installer: BasePreferences.ExtensionInstaller,
|
||||
): Intent {
|
||||
return Intent(context, MangaExtensionInstallService::class.java)
|
||||
.setDataAndType(uri, MangaExtensionInstaller.APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.putExtra(EXTRA_INSTALLER, installer)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,266 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.util
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.manga.installer.InstallerManga
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the extensions.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
internal class MangaExtensionInstaller(private val context: Context) {
|
||||
|
||||
/**
|
||||
* The system's download manager
|
||||
*/
|
||||
private val downloadManager = context.getSystemService<DownloadManager>()!!
|
||||
|
||||
/**
|
||||
* The broadcast receiver which listens to download completion events.
|
||||
*/
|
||||
private val downloadReceiver = DownloadCompletionReceiver()
|
||||
|
||||
/**
|
||||
* The currently requested downloads, with the package name (unique id) as key, and the id
|
||||
* returned by the download manager.
|
||||
*/
|
||||
private val activeDownloads = hashMapOf<String, Long>()
|
||||
|
||||
/**
|
||||
* Relay used to notify the installation step of every download.
|
||||
*/
|
||||
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||
|
||||
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
||||
|
||||
/**
|
||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||
* step in the installation process.
|
||||
*
|
||||
* @param url The url of the apk.
|
||||
* @param extension The extension to install.
|
||||
*/
|
||||
fun downloadAndInstall(url: String, extension: MangaExtension) = Observable.defer {
|
||||
val pkgName = extension.pkgName
|
||||
|
||||
val oldDownload = activeDownloads[pkgName]
|
||||
if (oldDownload != null) {
|
||||
deleteDownload(pkgName)
|
||||
}
|
||||
|
||||
// Register the receiver after removing (and unregistering) the previous download
|
||||
downloadReceiver.register()
|
||||
|
||||
val downloadUri = url.toUri()
|
||||
val request = DownloadManager.Request(downloadUri)
|
||||
.setTitle(extension.name)
|
||||
.setMimeType(APK_MIME)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
|
||||
val id = downloadManager.enqueue(request)
|
||||
activeDownloads[pkgName] = id
|
||||
|
||||
downloadsRelay.filter { it.first == id }
|
||||
.map { it.second }
|
||||
// Poll download status
|
||||
.mergeWith(pollStatus(id))
|
||||
// Stop when the application is installed or errors
|
||||
.takeUntil { it.isCompleted() }
|
||||
// Always notify on main thread
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Always remove the download when unsubscribed
|
||||
.doOnUnsubscribe { deleteDownload(pkgName) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that polls the given download id for its status every second, as the
|
||||
* manager doesn't have any notification system. It'll stop once the download finishes.
|
||||
*
|
||||
* @param id The id of the download to poll.
|
||||
*/
|
||||
private fun pollStatus(id: Long): Observable<InstallStep> {
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
|
||||
return Observable.interval(0, 1, TimeUnit.SECONDS)
|
||||
// Get the current download status
|
||||
.map {
|
||||
downloadManager.query(query).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
}
|
||||
}
|
||||
// Ignore duplicate results
|
||||
.distinctUntilChanged()
|
||||
// Stop polling when the download fails or finishes
|
||||
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
|
||||
// Map to our model
|
||||
.flatMap { status ->
|
||||
when (status) {
|
||||
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
|
||||
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
|
||||
else -> Observable.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to install the extension at the given uri.
|
||||
*
|
||||
* @param uri The uri of the extension to install.
|
||||
*/
|
||||
fun installApk(downloadId: Long, uri: Uri) {
|
||||
when (val installer = extensionInstaller.get()) {
|
||||
BasePreferences.ExtensionInstaller.LEGACY -> {
|
||||
val intent = Intent(context, MangaExtensionInstallActivity::class.java)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
else -> {
|
||||
val intent =
|
||||
MangaExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels extension install and remove from download manager and installer.
|
||||
*/
|
||||
fun cancelInstall(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
||||
downloadManager.remove(downloadId)
|
||||
InstallerManga.cancelInstallQueue(context, downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to uninstall the extension by the given package name.
|
||||
*
|
||||
* @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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the step of the installation of an extension.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
* @param step New install step.
|
||||
*/
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
downloadsRelay.call(downloadId to step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the download for the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the download to delete.
|
||||
*/
|
||||
private fun deleteDownload(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName)
|
||||
if (downloadId != null) {
|
||||
downloadManager.remove(downloadId)
|
||||
}
|
||||
if (activeDownloads.isEmpty()) {
|
||||
downloadReceiver.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver that listens to download status events.
|
||||
*/
|
||||
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Whether this receiver is currently registered.
|
||||
*/
|
||||
private var isRegistered = false
|
||||
|
||||
/**
|
||||
* Registers this receiver if it's not already.
|
||||
*/
|
||||
fun register() {
|
||||
if (isRegistered) return
|
||||
isRegistered = true
|
||||
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this receiver if it's not already.
|
||||
*/
|
||||
fun unregister() {
|
||||
if (!isRegistered) return
|
||||
isRegistered = false
|
||||
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a download event is received. It looks for the download in the current active
|
||||
* downloads and notifies its installation step.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
||||
|
||||
// Avoid events for downloads we didn't request
|
||||
if (id !in activeDownloads.values) return
|
||||
|
||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||
|
||||
// Set next installation step
|
||||
if (uri == null) {
|
||||
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
|
||||
downloadsRelay.call(id to InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val localUri = cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
|
||||
).removePrefix(FILE_SCHEME)
|
||||
|
||||
installApk(id, File(localUri).getUriCompat(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APK_MIME = "application/vnd.android.package-archive"
|
||||
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
|
||||
const val FILE_SCHEME = "file://"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
package eu.kanade.tachiyomi.extension.manga.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult
|
||||
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.runBlocking
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Class that handles the loading of the extensions installed in the system.
|
||||
*/
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
internal object MangaExtensionLoader {
|
||||
|
||||
private val preferences: SourcePreferences by injectLazy()
|
||||
private val loadNsfwSource by lazy {
|
||||
preferences.showNsfwSource().get()
|
||||
}
|
||||
|
||||
private const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||
private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
|
||||
private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
||||
private const val METADATA_HAS_README = "tachiyomi.extension.hasReadme"
|
||||
private const val METADATA_HAS_CHANGELOG = "tachiyomi.extension.hasChangelog"
|
||||
const val LIB_VERSION_MIN = 1.2
|
||||
const val LIB_VERSION_MAX = 1.5
|
||||
|
||||
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
|
||||
// inorichi's key
|
||||
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
||||
|
||||
/**
|
||||
* List of the trusted signatures.
|
||||
*/
|
||||
var trustedSignatures = mutableSetOf<String>() + preferences.trustedSignatures().get() + officialSignature
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun loadMangaExtensions(context: Context): List<MangaLoadResult> {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong()))
|
||||
} else {
|
||||
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
}
|
||||
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
|
||||
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) }
|
||||
}
|
||||
deferred.map { it.await() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load an extension from the given package name. It checks if the extension
|
||||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadMangaExtensionFromPkgName(context: Context, pkgName: String): MangaLoadResult {
|
||||
val pkgInfo = try {
|
||||
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
return loadMangaExtension(context, pkgName, pkgInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
*/
|
||||
private fun loadMangaExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): 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 extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
|
||||
|
||||
if (versionName.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Missing versionName for extension $extName" }
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
|
||||
// Validate lib version
|
||||
val libVersion = versionName.substringBeforeLast('.').toDoubleOrNull()
|
||||
if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||
logcat(LogPriority.WARN) {
|
||||
"Lib version is $libVersion, while only versions " +
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
|
||||
}
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(pkgInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
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)
|
||||
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
||||
return MangaLoadResult.Untrusted(extension)
|
||||
}
|
||||
|
||||
val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1
|
||||
if (!loadNsfwSource && isNsfw) {
|
||||
logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" }
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
|
||||
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
|
||||
val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1
|
||||
|
||||
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
||||
|
||||
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
|
||||
.split(";")
|
||||
.map {
|
||||
val sourceClass = it.trim()
|
||||
if (sourceClass.startsWith(".")) {
|
||||
pkgInfo.packageName + sourceClass
|
||||
} else {
|
||||
sourceClass
|
||||
}
|
||||
}
|
||||
.flatMap {
|
||||
try {
|
||||
when (val obj = Class.forName(it, false, classLoader).newInstance()) {
|
||||
is MangaSource -> listOf(obj)
|
||||
is SourceFactory -> obj.createSources()
|
||||
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" }
|
||||
return MangaLoadResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
val langs = sources.filterIsInstance<CatalogueSource>()
|
||||
.map { it.lang }
|
||||
.toSet()
|
||||
val lang = when (langs.size) {
|
||||
0 -> ""
|
||||
1 -> langs.first()
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
val extension = MangaExtension.Installed(
|
||||
name = extName,
|
||||
pkgName = pkgName,
|
||||
versionName = versionName,
|
||||
versionCode = versionCode,
|
||||
libVersion = libVersion,
|
||||
lang = lang,
|
||||
isNsfw = isNsfw,
|
||||
hasReadme = hasReadme,
|
||||
hasChangelog = hasChangelog,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
isUnofficial = signatureHash != officialSignature,
|
||||
icon = context.getApplicationIcon(pkgName),
|
||||
)
|
||||
return MangaLoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is an extension.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
|
||||
return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||
val signatures = pkgInfo.signatures
|
||||
return if (signatures != null && signatures.isNotEmpty()) {
|
||||
Hash.sha256(signatures.first().toByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class AndroidCookieJar : CookieJar {
|
||||
|
||||
private val manager = CookieManager.getInstance()
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val urlString = url.toString()
|
||||
|
||||
cookies.forEach { manager.setCookie(urlString, it.toString()) }
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return get(url)
|
||||
}
|
||||
|
||||
fun get(url: HttpUrl): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
|
||||
return if (cookies != null && cookies.isNotEmpty()) {
|
||||
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1): Int {
|
||||
val urlString = url.toString()
|
||||
val cookies = manager.getCookie(urlString) ?: return 0
|
||||
|
||||
fun List<String>.filterNames(): List<String> {
|
||||
return if (cookieNames != null) {
|
||||
this.filter { it in cookieNames }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
return cookies.split(";")
|
||||
.map { it.substringBefore("=") }
|
||||
.filterNames()
|
||||
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
|
||||
.count()
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
manager.removeAllCookies {}
|
||||
}
|
||||
}
|
186
app/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt
Normal file
186
app/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt
Normal file
|
@ -0,0 +1,186 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps
|
||||
import java.net.InetAddress
|
||||
|
||||
/**
|
||||
* Based on https://github.com/square/okhttp/blob/ef5d0c83f7bbd3a0c0534e7ca23cbc4ee7550f3b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DohProviders.java
|
||||
*/
|
||||
|
||||
const val PREF_DOH_CLOUDFLARE = 1
|
||||
const val PREF_DOH_GOOGLE = 2
|
||||
const val PREF_DOH_ADGUARD = 3
|
||||
const val PREF_DOH_QUAD9 = 4
|
||||
const val PREF_DOH_ALIDNS = 5
|
||||
const val PREF_DOH_DNSPOD = 6
|
||||
const val PREF_DOH_360 = 7
|
||||
const val PREF_DOH_QUAD101 = 8
|
||||
const val PREF_DOH_MULLVAD = 9
|
||||
const val PREF_DOH_CONTROLD = 10
|
||||
const val PREF_DOH_NJALLA = 11
|
||||
const val PREF_DOH_SHECAN = 12
|
||||
|
||||
fun OkHttpClient.Builder.dohCloudflare() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("162.159.36.1"),
|
||||
InetAddress.getByName("162.159.46.1"),
|
||||
InetAddress.getByName("1.1.1.1"),
|
||||
InetAddress.getByName("1.0.0.1"),
|
||||
InetAddress.getByName("162.159.132.53"),
|
||||
InetAddress.getByName("2606:4700:4700::1111"),
|
||||
InetAddress.getByName("2606:4700:4700::1001"),
|
||||
InetAddress.getByName("2606:4700:4700::0064"),
|
||||
InetAddress.getByName("2606:4700:4700::6400"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohGoogle() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.google/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("8.8.4.4"),
|
||||
InetAddress.getByName("8.8.8.8"),
|
||||
InetAddress.getByName("2001:4860:4860::8888"),
|
||||
InetAddress.getByName("2001:4860:4860::8844"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
// AdGuard "Default" DNS works too but for the sake of making sure no site is blacklisted,
|
||||
// we use "Unfiltered"
|
||||
fun OkHttpClient.Builder.dohAdGuard() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("94.140.14.140"),
|
||||
InetAddress.getByName("94.140.14.141"),
|
||||
InetAddress.getByName("2a10:50c0::1:ff"),
|
||||
InetAddress.getByName("2a10:50c0::2:ff"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohQuad9() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.quad9.net/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("9.9.9.9"),
|
||||
InetAddress.getByName("149.112.112.112"),
|
||||
InetAddress.getByName("2620:fe::fe"),
|
||||
InetAddress.getByName("2620:fe::9"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohAliDNS() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.alidns.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("223.5.5.5"),
|
||||
InetAddress.getByName("223.6.6.6"),
|
||||
InetAddress.getByName("2400:3200::1"),
|
||||
InetAddress.getByName("2400:3200:baba::1"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohDNSPod() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://doh.pub/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("1.12.12.12"),
|
||||
InetAddress.getByName("120.53.53.53"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.doh360() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://doh.360.cn/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("101.226.4.6"),
|
||||
InetAddress.getByName("218.30.118.6"),
|
||||
InetAddress.getByName("123.125.81.6"),
|
||||
InetAddress.getByName("140.207.198.6"),
|
||||
InetAddress.getByName("180.163.249.75"),
|
||||
InetAddress.getByName("101.199.113.208"),
|
||||
InetAddress.getByName("36.99.170.86"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohQuad101() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.twnic.tw/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("101.101.101.101"),
|
||||
InetAddress.getByName("2001:de4::101"),
|
||||
InetAddress.getByName("2001:de4::102"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Mullvad DoH
|
||||
* without ad blocking option
|
||||
* Source : https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohMullvad() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://doh.mullvad.net/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("194.242.2.2"),
|
||||
InetAddress.getByName("193.19.108.2"),
|
||||
InetAddress.getByName("2a07:e340::2"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Control D
|
||||
* unfiltered option
|
||||
* Source : https://controld.com/free-dns/?
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohControlD() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://freedns.controld.com/p0".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("76.76.2.0"),
|
||||
InetAddress.getByName("76.76.10.0"),
|
||||
InetAddress.getByName("2606:1a40::"),
|
||||
InetAddress.getByName("2606:1a40:1::"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Njalla
|
||||
* Non logging and uncensored
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohNajalla() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.njal.la/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("95.215.19.53"),
|
||||
InetAddress.getByName("2001:67c:2354:2::53"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Source: https://shecan.ir/
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohShecan() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://free.shecan.ir/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("178.22.122.100"),
|
||||
InetAddress.getByName("185.51.200.2"),
|
||||
)
|
||||
.build(),
|
||||
)
|
|
@ -0,0 +1,79 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.network.AndroidCookieJar
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE
|
||||
import eu.kanade.tachiyomi.network.dohCloudflare
|
||||
import eu.kanade.tachiyomi.network.dohGoogle
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NetworkHelper(
|
||||
context: Context,
|
||||
) {
|
||||
|
||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||
|
||||
val cookieJar = AndroidCookieJar()
|
||||
|
||||
private val userAgentInterceptor by lazy {
|
||||
UserAgentInterceptor(::defaultUserAgentProvider)
|
||||
}
|
||||
private val cloudflareInterceptor by lazy {
|
||||
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider)
|
||||
}
|
||||
|
||||
private val baseClientBuilder: OkHttpClient.Builder
|
||||
get() {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(2, TimeUnit.MINUTES)
|
||||
.addInterceptor(UncaughtExceptionInterceptor())
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
|
||||
/*if (preferences.verboseLogging().get()) {
|
||||
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
builder.addNetworkInterceptor(httpLoggingInterceptor)
|
||||
}*/
|
||||
|
||||
//when (preferences.dohProvider().get()) {
|
||||
when (PREF_DOH_CLOUDFLARE) {
|
||||
PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()
|
||||
PREF_DOH_GOOGLE -> builder.dohGoogle()
|
||||
/*PREF_DOH_ADGUARD -> builder.dohAdGuard()
|
||||
PREF_DOH_QUAD9 -> builder.dohQuad9()
|
||||
PREF_DOH_ALIDNS -> builder.dohAliDNS()
|
||||
PREF_DOH_DNSPOD -> builder.dohDNSPod()
|
||||
PREF_DOH_360 -> builder.doh360()
|
||||
PREF_DOH_QUAD101 -> builder.dohQuad101()
|
||||
PREF_DOH_MULLVAD -> builder.dohMullvad()
|
||||
PREF_DOH_CONTROLD -> builder.dohControlD()
|
||||
PREF_DOH_NJALLA -> builder.dohNajalla()
|
||||
PREF_DOH_SHECAN -> builder.dohShecan()*/
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
|
||||
|
||||
@Suppress("UNUSED")
|
||||
val cloudflareClient by lazy {
|
||||
client.newBuilder()
|
||||
.addInterceptor(cloudflareInterceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun defaultUserAgentProvider() = "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"//preferences.defaultUserAgent().get().trim()
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.network.ProgressResponseBody
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import kotlinx.serialization.serializer
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
fun Call.asObservable(): Observable<Response> {
|
||||
return Observable.unsafeCreate { subscriber ->
|
||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||
val call = clone()
|
||||
|
||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
||||
override fun request(n: Long) {
|
||||
if (n == 0L || !compareAndSet(false, true)) return
|
||||
|
||||
try {
|
||||
val response = call.execute()
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onNext(response)
|
||||
subscriber.onCompleted()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe() {
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
override fun isUnsubscribed(): Boolean {
|
||||
return call.isCanceled()
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.add(requestArbiter)
|
||||
subscriber.setProducer(requestArbiter)
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback =
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response) {
|
||||
response.body.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(callback)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Call.await(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
return await(callStack)
|
||||
}
|
||||
|
||||
suspend fun Call.awaitSuccess(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
val response = await(callStack)
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code).apply { stackTrace = callStack }
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(originalResponse.body, listener))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
return progressClient.newCall(request)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
inline fun <reified T> Response.parseAs(): T {
|
||||
return decodeFromJsonResponse(serializer(), this)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun <T> decodeFromJsonResponse(
|
||||
deserializer: DeserializationStrategy<T>,
|
||||
response: Response,
|
||||
): T {
|
||||
return response.body.source().use {
|
||||
decodeFromBufferedSource(deserializer, it)
|
||||
}
|
||||
}
|
||||
|
||||
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
|
|
@ -0,0 +1,5 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.io.IOException
|
||||
|
||||
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
|
||||
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
source(responseBody.source()).buffer()
|
||||
}
|
||||
|
||||
override fun contentType(): MediaType? {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
return bufferedSource
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
var totalBytesRead = 0L
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
81
app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt
Normal file
81
app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt
Normal file
|
@ -0,0 +1,81 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import java.util.concurrent.TimeUnit.MINUTES
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
||||
|
||||
fun GET(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
val nUrl = url.toHttpUrl()
|
||||
val g = GET(nUrl, headers, cache)
|
||||
return g
|
||||
}
|
||||
|
||||
/**
|
||||
* @since extensions-lib 1.4
|
||||
*/
|
||||
fun GET(
|
||||
url: HttpUrl,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun POST(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun PUT(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.put(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun DELETE(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.delete(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.kanade.tachiyomi.network.AndroidCookieJar
|
||||
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
|
||||
import eu.kanade.tachiyomi.util.system.isOutdated
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
class CloudflareInterceptor(
|
||||
private val context: Context,
|
||||
private val cookieManager: AndroidCookieJar,
|
||||
defaultUserAgentProvider: () -> String,
|
||||
) : WebViewInterceptor(context, defaultUserAgentProvider) {
|
||||
|
||||
private val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
override fun shouldIntercept(response: Response): Boolean {
|
||||
// Check if Cloudflare anti-bot is on
|
||||
return response.code in ERROR_CODES && response.header("Server") in SERVER_CHECK
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
|
||||
try {
|
||||
response.close()
|
||||
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
||||
val oldCookie = cookieManager.get(request.url)
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
resolveWithWebView(request, oldCookie)
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
// we don't crash the entire app
|
||||
catch (e: CloudflareBypassException) {
|
||||
throw IOException("Failed to bypass Cloudflare")
|
||||
} catch (e: Exception) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) {
|
||||
// We need to lock this thread until the WebView finds the challenge solution url, because
|
||||
// OkHttp doesn't support asynchronous interceptors.
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
var webview: WebView? = null
|
||||
|
||||
var challengeFound = false
|
||||
var cloudflareBypassed = false
|
||||
var isWebViewOutdated = false
|
||||
|
||||
val origRequestUrl = originalRequest.url.toString()
|
||||
val headers = parseHeaders(originalRequest.headers)
|
||||
|
||||
executor.execute {
|
||||
webview = createWebView(originalRequest)
|
||||
|
||||
webview?.webViewClient = object : WebViewClientCompat() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
fun isCloudFlareBypassed(): Boolean {
|
||||
return cookieManager.get(origRequestUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
.let { it != null && it != oldCookie }
|
||||
}
|
||||
|
||||
if (isCloudFlareBypassed()) {
|
||||
cloudflareBypassed = true
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
if (url == origRequestUrl && !challengeFound) {
|
||||
// The first request didn't return the challenge, abort.
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedErrorCompat(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
isMainFrame: Boolean,
|
||||
) {
|
||||
if (isMainFrame) {
|
||||
if (errorCode in ERROR_CODES) {
|
||||
// Found the Cloudflare challenge page.
|
||||
challengeFound = true
|
||||
} else {
|
||||
// Unlock thread, the challenge wasn't found.
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webview?.loadUrl(origRequestUrl, headers)
|
||||
}
|
||||
|
||||
latch.awaitFor30Seconds()
|
||||
|
||||
executor.execute {
|
||||
if (!cloudflareBypassed) {
|
||||
isWebViewOutdated = webview?.isOutdated() == true
|
||||
}
|
||||
|
||||
webview?.run {
|
||||
stopLoading()
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Throw exception if we failed to bypass Cloudflare
|
||||
if (!cloudflareBypassed) {
|
||||
// Prompt user to update WebView if it seems too outdated
|
||||
if (isWebViewOutdated) {
|
||||
context.toast("Please update the webview app for better compatibility", Toast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
throw CloudflareBypassException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ERROR_CODES = listOf(403, 503)
|
||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
private val COOKIE_NAMES = listOf("cf_clearance")
|
||||
|
||||
class CloudflareBypassException : Exception()
|
|
@ -0,0 +1,105 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import android.os.SystemClock
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* permits = 5, period = 1, unit = seconds => 5 requests per second
|
||||
* permits = 10, period = 2, unit = minutes => 10 requests per 2 minutes
|
||||
*
|
||||
* @since extension-lib 1.3
|
||||
*
|
||||
* @param permits {Int} Number of requests allowed within a period of units.
|
||||
* @param period {Long} The limiting duration. Defaults to 1.
|
||||
* @param unit {TimeUnit} The unit of time for the period. Defaults to seconds.
|
||||
*/
|
||||
fun OkHttpClient.Builder.rateLimit(
|
||||
permits: Int,
|
||||
period: Long = 1,
|
||||
unit: TimeUnit = TimeUnit.SECONDS,
|
||||
) = addInterceptor(RateLimitInterceptor(null, permits, period, unit))
|
||||
|
||||
/** We can probably accept domains or wildcards by comparing with [endsWith], etc. */
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||
internal class RateLimitInterceptor(
|
||||
private val host: String?,
|
||||
private val permits: Int,
|
||||
period: Long,
|
||||
unit: TimeUnit,
|
||||
) : Interceptor {
|
||||
|
||||
private val requestQueue = ArrayDeque<Long>(permits)
|
||||
private val rateLimitMillis = unit.toMillis(period)
|
||||
private val fairLock = Semaphore(1, true)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val call = chain.call()
|
||||
if (call.isCanceled()) throw IOException("Canceled")
|
||||
|
||||
val request = chain.request()
|
||||
when (host) {
|
||||
null, request.url.host -> {} // need rate limit
|
||||
else -> return chain.proceed(request)
|
||||
}
|
||||
|
||||
try {
|
||||
fairLock.acquire()
|
||||
} catch (e: InterruptedException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
|
||||
val requestQueue = this.requestQueue
|
||||
val timestamp: Long
|
||||
|
||||
try {
|
||||
synchronized(requestQueue) {
|
||||
while (requestQueue.size >= permits) { // queue is full, remove expired entries
|
||||
val periodStart = SystemClock.elapsedRealtime() - rateLimitMillis
|
||||
var hasRemovedExpired = false
|
||||
while (requestQueue.isEmpty().not() && requestQueue.first <= periodStart) {
|
||||
requestQueue.removeFirst()
|
||||
hasRemovedExpired = true
|
||||
}
|
||||
if (call.isCanceled()) {
|
||||
throw IOException("Canceled")
|
||||
} else if (hasRemovedExpired) {
|
||||
break
|
||||
} else {
|
||||
try { // wait for the first entry to expire, or notified by cached response
|
||||
(requestQueue as Object).wait(requestQueue.first - periodStart)
|
||||
} catch (_: InterruptedException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add request to queue
|
||||
timestamp = SystemClock.elapsedRealtime()
|
||||
requestQueue.addLast(timestamp)
|
||||
}
|
||||
} finally {
|
||||
fairLock.release()
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
if (response.networkResponse == null) { // response is cached, remove it from queue
|
||||
synchronized(requestQueue) {
|
||||
if (requestQueue.isEmpty() || timestamp < requestQueue.first) return@synchronized
|
||||
requestQueue.removeFirstOccurrence(timestamp)
|
||||
(requestQueue as Object).notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles given url host's rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* httpUrl = "api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1, unit = seconds => 5 requests per second to api.manga.com
|
||||
* httpUrl = "imagecdn.manga.com".toHttpUrlOrNull(), permits = 10, period = 2, unit = minutes => 10 requests per 2 minutes to imagecdn.manga.com
|
||||
*
|
||||
* @since extension-lib 1.3
|
||||
*
|
||||
* @param httpUrl {HttpUrl} The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
|
||||
* @param permits {Int} Number of requests allowed within a period of units.
|
||||
* @param period {Long} The limiting duration. Defaults to 1.
|
||||
* @param unit {TimeUnit} The unit of time for the period. Defaults to seconds.
|
||||
*/
|
||||
fun OkHttpClient.Builder.rateLimitHost(
|
||||
httpUrl: HttpUrl,
|
||||
permits: Int,
|
||||
period: Long = 1,
|
||||
unit: TimeUnit = TimeUnit.SECONDS,
|
||||
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period, unit))
|
|
@ -0,0 +1,28 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Catches any uncaught exceptions from later in the chain and rethrows as a non-fatal
|
||||
* IOException to avoid catastrophic failure.
|
||||
*
|
||||
* This should be the first interceptor in the client.
|
||||
*
|
||||
* See https://square.github.io/okhttp/4.x/okhttp/okhttp3/-interceptor/
|
||||
*/
|
||||
class UncaughtExceptionInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class UserAgentInterceptor(
|
||||
private val defaultUserAgentProvider: () -> String,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
return if (originalRequest.header("User-Agent").isNullOrEmpty()) {
|
||||
val newRequest = originalRequest
|
||||
.newBuilder()
|
||||
.removeHeader("User-Agent")
|
||||
.addHeader("User-Agent", defaultUserAgentProvider())
|
||||
.build()
|
||||
chain.proceed(newRequest)
|
||||
} else {
|
||||
chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||
import eu.kanade.tachiyomi.util.system.setDefaultSettings
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import tachiyomi.core.util.lang.launchUI
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class WebViewInterceptor(
|
||||
private val context: Context,
|
||||
private val defaultUserAgentProvider: () -> String,
|
||||
) : Interceptor {
|
||||
|
||||
/**
|
||||
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
|
||||
* blocking the main thread too much. If used too often we could consider moving it to the
|
||||
* Application class.
|
||||
*/
|
||||
private val initWebView by lazy {
|
||||
// Crashes on some devices. We skip this in some cases since the only impact is slower
|
||||
// WebView init in those rare cases.
|
||||
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1279562
|
||||
if (DeviceUtil.isMiui || Build.VERSION.SDK_INT == Build.VERSION_CODES.S && DeviceUtil.isSamsung) {
|
||||
return@lazy
|
||||
}
|
||||
|
||||
try {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
} catch (_: Exception) {
|
||||
// Avoid some crashes like when Chrome/WebView is being updated.
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun shouldIntercept(response: Response): Boolean
|
||||
|
||||
abstract fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
if (!shouldIntercept(response)) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (!WebViewUtil.supportsWebView(context)) {
|
||||
launchUI {
|
||||
context.toast("Webview is required for dantotsu", Toast.LENGTH_LONG)
|
||||
}
|
||||
return response
|
||||
}
|
||||
initWebView
|
||||
|
||||
return intercept(chain, request, response)
|
||||
}
|
||||
|
||||
fun parseHeaders(headers: Headers): Map<String, String> {
|
||||
return headers
|
||||
// Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT]
|
||||
.filter { (name, value) ->
|
||||
isRequestHeaderSafe(name, value)
|
||||
}
|
||||
.groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value }
|
||||
.mapValues { it.value.getOrNull(0).orEmpty() }
|
||||
}
|
||||
|
||||
fun CountDownLatch.awaitFor30Seconds() {
|
||||
await(30, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
fun createWebView(request: Request): WebView {
|
||||
return WebView(context).apply {
|
||||
setDefaultSettings()
|
||||
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
|
||||
settings.userAgentString = request.header("User-Agent") ?: defaultUserAgentProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on [IsRequestHeaderSafe] in https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc
|
||||
private fun isRequestHeaderSafe(_name: String, _value: String): Boolean {
|
||||
val name = _name.lowercase(Locale.ENGLISH)
|
||||
val value = _value.lowercase(Locale.ENGLISH)
|
||||
if (name in unsafeHeaderNames || name.startsWith("proxy-")) return false
|
||||
if (name == "connection" && value == "upgrade") return false
|
||||
return true
|
||||
}
|
||||
private val unsafeHeaderNames = listOf("content-length", "host", "trailer", "te", "upgrade", "cookie2", "keep-alive", "transfer-encoding", "set-cookie")
|
|
@ -0,0 +1,46 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import rx.Observable
|
||||
|
||||
interface CatalogueSource : MangaSource {
|
||||
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang: String
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
val supportsLatest: Boolean
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchPopularManga(page: Int): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
fun getFilterList(): FilterList
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import eu.kanade.tachiyomi.PreferenceScreen
|
||||
|
||||
interface ConfigurableSource : MangaSource {
|
||||
|
||||
fun setupPreferenceScreen(screen: PreferenceScreen)
|
||||
}
|
85
app/src/main/java/eu/kanade/tachiyomi/source/MangaSource.kt
Normal file
85
app/src/main/java/eu/kanade/tachiyomi/source/MangaSource.kt
Normal file
|
@ -0,0 +1,85 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.lang.awaitSingle
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc.
|
||||
*/
|
||||
interface MangaSource {
|
||||
|
||||
/**
|
||||
* ID for the source. Must be unique.
|
||||
*/
|
||||
val id: Long
|
||||
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
val lang: String
|
||||
get() = ""
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getMangaDetails"),
|
||||
)
|
||||
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getChapterList"),
|
||||
)
|
||||
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of pages a chapter has. Pages should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getPageList"),
|
||||
)
|
||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
|
||||
|
||||
/**
|
||||
* [1.x API] Get the updated details for a manga.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
return fetchMangaDetails(manga).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get all the available chapters for a manga.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getChapterList(manga: SManga): List<SChapter> {
|
||||
return fetchChapterList(manga).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get the list of pages a chapter has. Pages should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getPageList(chapter: SChapter): List<Page> {
|
||||
return fetchPageList(chapter).awaitSingle()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
/**
|
||||
* A factory for creating sources at runtime.
|
||||
*/
|
||||
interface SourceFactory {
|
||||
/**
|
||||
* Create a new copy of the sources
|
||||
* @return The created sources
|
||||
*/
|
||||
fun createSources(): List<MangaSource>
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
/**
|
||||
* A source that explicitly doesn't require traffic considerations.
|
||||
*
|
||||
* This typically applies for self-hosted sources.
|
||||
*/
|
||||
interface UnmeteredSource
|
40
app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt
Normal file
40
app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt
Normal file
|
@ -0,0 +1,40 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
sealed class Filter<T>(val name: String, var state: T) {
|
||||
open class Header(name: String) : Filter<Any>(name, 0)
|
||||
open class Separator(name: String = "") : Filter<Any>(name, 0)
|
||||
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
|
||||
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
|
||||
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
|
||||
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
|
||||
fun isIgnored() = state == STATE_IGNORE
|
||||
fun isIncluded() = state == STATE_INCLUDE
|
||||
fun isExcluded() = state == STATE_EXCLUDE
|
||||
|
||||
companion object {
|
||||
const val STATE_IGNORE = 0
|
||||
const val STATE_INCLUDE = 1
|
||||
const val STATE_EXCLUDE = 2
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Group<V>(name: String, state: List<V>) : Filter<List<V>>(name, state)
|
||||
|
||||
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
|
||||
Filter<Sort.Selection?>(name, state) {
|
||||
data class Selection(val index: Int, val ascending: Boolean)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Filter<*>) return false
|
||||
|
||||
return name == other.name && state == other.state
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = name.hashCode()
|
||||
result = 31 * result + (state?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
|
||||
|
||||
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return list.hashCode()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)
|
56
app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt
Normal file
56
app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt
Normal file
|
@ -0,0 +1,56 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.Serializable
|
||||
|
||||
open class Page(
|
||||
val index: Int,
|
||||
val url: String = "",
|
||||
var imageUrl: String? = null,
|
||||
@Transient var uri: Uri? = null, // Deprecated but can't be deleted due to extensions
|
||||
) : Serializable, ProgressListener {
|
||||
|
||||
val number: Int
|
||||
get() = index + 1
|
||||
|
||||
@Transient
|
||||
private val _statusFlow = MutableStateFlow(State.QUEUE)
|
||||
|
||||
@Transient
|
||||
val statusFlow = _statusFlow.asStateFlow()
|
||||
var status: State
|
||||
get() = _statusFlow.value
|
||||
set(value) {
|
||||
_statusFlow.value = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
private val _progressFlow = MutableStateFlow(0)
|
||||
|
||||
@Transient
|
||||
val progressFlow = _progressFlow.asStateFlow()
|
||||
var progress: Int
|
||||
get() = _progressFlow.value
|
||||
set(value) {
|
||||
_progressFlow.value = value
|
||||
}
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
progress = if (contentLength > 0) {
|
||||
(100 * bytesRead / contentLength).toInt()
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
enum class State {
|
||||
QUEUE,
|
||||
LOAD_PAGE,
|
||||
DOWNLOAD_IMAGE,
|
||||
READY,
|
||||
ERROR,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface SChapter : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var name: String
|
||||
|
||||
var date_upload: Long
|
||||
|
||||
var chapter_number: Float
|
||||
|
||||
var scanlator: String?
|
||||
|
||||
fun copyFrom(other: SChapter) {
|
||||
name = other.name
|
||||
url = other.url
|
||||
date_upload = other.date_upload
|
||||
chapter_number = other.chapter_number
|
||||
scanlator = other.scanlator
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(): SChapter {
|
||||
return SChapterImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
class SChapterImpl : SChapter {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var date_upload: Long = 0
|
||||
|
||||
override var chapter_number: Float = -1f
|
||||
|
||||
override var scanlator: String? = null
|
||||
}
|
88
app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt
Normal file
88
app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt
Normal file
|
@ -0,0 +1,88 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface SManga : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var title: String
|
||||
|
||||
var artist: String?
|
||||
|
||||
var author: String?
|
||||
|
||||
var description: String?
|
||||
|
||||
var genre: String?
|
||||
|
||||
var status: Int
|
||||
|
||||
var thumbnail_url: String?
|
||||
|
||||
var update_strategy: UpdateStrategy
|
||||
|
||||
var initialized: Boolean
|
||||
|
||||
fun getGenres(): List<String>? {
|
||||
if (genre.isNullOrBlank()) return null
|
||||
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
|
||||
}
|
||||
|
||||
fun copyFrom(other: SManga) {
|
||||
if (other.author != null) {
|
||||
author = other.author
|
||||
}
|
||||
|
||||
if (other.artist != null) {
|
||||
artist = other.artist
|
||||
}
|
||||
|
||||
if (other.description != null) {
|
||||
description = other.description
|
||||
}
|
||||
|
||||
if (other.genre != null) {
|
||||
genre = other.genre
|
||||
}
|
||||
|
||||
if (other.thumbnail_url != null) {
|
||||
thumbnail_url = other.thumbnail_url
|
||||
}
|
||||
|
||||
status = other.status
|
||||
|
||||
update_strategy = other.update_strategy
|
||||
|
||||
if (!initialized) {
|
||||
initialized = other.initialized
|
||||
}
|
||||
}
|
||||
|
||||
fun copy() = create().also {
|
||||
it.url = url
|
||||
it.title = title
|
||||
it.artist = artist
|
||||
it.author = author
|
||||
it.description = description
|
||||
it.genre = genre
|
||||
it.status = status
|
||||
it.thumbnail_url = thumbnail_url
|
||||
it.update_strategy = update_strategy
|
||||
it.initialized = initialized
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val UNKNOWN = 0
|
||||
const val ONGOING = 1
|
||||
const val COMPLETED = 2
|
||||
const val LICENSED = 3
|
||||
const val PUBLISHING_FINISHED = 4
|
||||
const val CANCELLED = 5
|
||||
const val ON_HIATUS = 6
|
||||
|
||||
fun create(): SManga {
|
||||
return SMangaImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
class SMangaImpl : SManga {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var artist: String? = null
|
||||
|
||||
override var author: String? = null
|
||||
|
||||
override var description: String? = null
|
||||
|
||||
override var genre: String? = null
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var thumbnail_url: String? = null
|
||||
|
||||
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
|
||||
|
||||
override var initialized: Boolean = false
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
/**
|
||||
* Define the update strategy for a single [SManga].
|
||||
* The strategy used will only take effect on the library update.
|
||||
*
|
||||
* @since extensions-lib 1.4
|
||||
*/
|
||||
enum class UpdateStrategy {
|
||||
/**
|
||||
* Series marked as always update will be included in the library
|
||||
* update if they aren't excluded by additional restrictions.
|
||||
*/
|
||||
ALWAYS_UPDATE,
|
||||
|
||||
/**
|
||||
* Series marked as only fetch once will be automatically skipped
|
||||
* during library updates. Useful for cases where the series is previously
|
||||
* known to be finished and have only a single chapter, for example.
|
||||
*/
|
||||
ONLY_FETCH_ONCE,
|
||||
}
|
|
@ -5,12 +5,12 @@ import eu.kanade.tachiyomi.network.NetworkHelper
|
|||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
|
||||
import ani.dantotsu.aniyomi.source.CatalogueSource
|
||||
import ani.dantotsu.aniyomi.source.model.FilterList
|
||||
import ani.dantotsu.aniyomi.source.model.MangasPage
|
||||
import ani.dantotsu.aniyomi.source.model.Page
|
||||
import ani.dantotsu.aniyomi.source.model.SChapter
|
||||
import ani.dantotsu.aniyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package eu.kanade.tachiyomi.source.online
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
|
||||
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||
return Observable.from(pages)
|
||||
.filter { !it.imageUrl.isNullOrEmpty() }
|
||||
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
|
||||
}
|
||||
|
||||
private fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||
return Observable.from(pages)
|
||||
.filter { it.imageUrl.isNullOrEmpty() }
|
||||
.concatMap { getImageUrl(it) }
|
||||
}
|
||||
|
||||
private fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||
page.status = Page.State.LOAD_PAGE
|
||||
return fetchImageUrl(page)
|
||||
.doOnError { page.status = Page.State.ERROR }
|
||||
.onErrorReturn { null }
|
||||
.doOnNext { page.imageUrl = it }
|
||||
.map { page }
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
package eu.kanade.tachiyomi.source.online
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
/**
|
||||
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
abstract class ParsedHttpSource : HttpSource() {
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select(popularMangaSelector()).map { element ->
|
||||
popularMangaFromElement(element)
|
||||
}
|
||||
|
||||
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||
*/
|
||||
protected abstract fun popularMangaSelector(): String
|
||||
|
||||
/**
|
||||
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
||||
* totally fine to fill only those two values.
|
||||
*
|
||||
* @param element an element obtained from [popularMangaSelector].
|
||||
*/
|
||||
protected abstract fun popularMangaFromElement(element: Element): SManga
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||
* there's no next page.
|
||||
*/
|
||||
protected abstract fun popularMangaNextPageSelector(): String?
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select(searchMangaSelector()).map { element ->
|
||||
searchMangaFromElement(element)
|
||||
}
|
||||
|
||||
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||
*/
|
||||
protected abstract fun searchMangaSelector(): String
|
||||
|
||||
/**
|
||||
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
||||
* totally fine to fill only those two values.
|
||||
*
|
||||
* @param element an element obtained from [searchMangaSelector].
|
||||
*/
|
||||
protected abstract fun searchMangaFromElement(element: Element): SManga
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||
* there's no next page.
|
||||
*/
|
||||
protected abstract fun searchMangaNextPageSelector(): String?
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select(latestUpdatesSelector()).map { element ->
|
||||
latestUpdatesFromElement(element)
|
||||
}
|
||||
|
||||
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||
*/
|
||||
protected abstract fun latestUpdatesSelector(): String
|
||||
|
||||
/**
|
||||
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
||||
* totally fine to fill only those two values.
|
||||
*
|
||||
* @param element an element obtained from [latestUpdatesSelector].
|
||||
*/
|
||||
protected abstract fun latestUpdatesFromElement(element: Element): SManga
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||
* there's no next page.
|
||||
*/
|
||||
protected abstract fun latestUpdatesNextPageSelector(): String?
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns the details of a manga.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
return mangaDetailsParse(response.asJsoup())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the details of the manga from the given [document].
|
||||
*
|
||||
* @param document the parsed document.
|
||||
*/
|
||||
protected abstract fun mangaDetailsParse(document: Document): SManga
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of chapters.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
return document.select(chapterListSelector()).map { chapterFromElement(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
|
||||
*/
|
||||
protected abstract fun chapterListSelector(): String
|
||||
|
||||
/**
|
||||
* Returns a chapter from the given element.
|
||||
*
|
||||
* @param element an element obtained from [chapterListSelector].
|
||||
*/
|
||||
protected abstract fun chapterFromElement(element: Element): SChapter
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns the page list.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
return pageListParse(response.asJsoup())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page list from the given document.
|
||||
*
|
||||
* @param document the parsed document.
|
||||
*/
|
||||
protected abstract fun pageListParse(document: Document): List<Page>
|
||||
|
||||
/**
|
||||
* Parse the response from the site and returns the absolute url to the source image.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
return imageUrlParse(response.asJsoup())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute url to the source image from the document.
|
||||
*
|
||||
* @param document the parsed document.
|
||||
*/
|
||||
protected abstract fun imageUrlParse(document: Document): String
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
fun Element.selectText(css: String, defaultValue: String? = null): String? {
|
||||
return select(css).first()?.text() ?: defaultValue
|
||||
}
|
||||
|
||||
fun Element.selectInt(css: String, defaultValue: Int = 0): Int {
|
||||
return select(css).first()?.text()?.toInt() ?: defaultValue
|
||||
}
|
||||
|
||||
fun Element.attrOrText(css: String): String {
|
||||
return if (css != "text") attr(css) else text()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Jsoup document for this response.
|
||||
* @param html the body of the response. Use only if the body was read before calling this method.
|
||||
*/
|
||||
fun Response.asJsoup(html: String? = null): Document {
|
||||
return Jsoup.parse(html ?: body.string(), request.url.toString())
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package eu.kanade.tachiyomi.util
|
||||
|
||||
//expect suspend fun <T> Observable<T>.awaitSingle(): T
|
|
@ -0,0 +1,31 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Executes the given block function on this resources and then closes it down correctly whether an exception is
|
||||
* thrown or not.
|
||||
*
|
||||
* @param block a function to process with given Closeable resources.
|
||||
* @return the result of block function invoked on this resource.
|
||||
*/
|
||||
inline fun <T : Closeable?> Array<T>.use(block: () -> Unit) {
|
||||
var blockException: Throwable? = null
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Throwable) {
|
||||
blockException = e
|
||||
throw e
|
||||
} finally {
|
||||
when (blockException) {
|
||||
null -> forEach { it?.close() }
|
||||
else -> forEach {
|
||||
try {
|
||||
it?.close()
|
||||
} catch (closeException: Throwable) {
|
||||
blockException.addSuppressed(closeException)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
44
app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
|
@ -0,0 +1,44 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object Hash {
|
||||
|
||||
private val chars = charArrayOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'a', 'b', 'c', 'd', 'e', 'f',
|
||||
)
|
||||
|
||||
private val MD5 get() = MessageDigest.getInstance("MD5")
|
||||
|
||||
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
fun sha256(bytes: ByteArray): String {
|
||||
return encodeHex(SHA256.digest(bytes))
|
||||
}
|
||||
|
||||
fun sha256(string: String): String {
|
||||
return sha256(string.toByteArray())
|
||||
}
|
||||
|
||||
fun md5(bytes: ByteArray): String {
|
||||
return encodeHex(MD5.digest(bytes))
|
||||
}
|
||||
|
||||
fun md5(string: String): String {
|
||||
return md5(string.toByteArray())
|
||||
}
|
||||
|
||||
private fun encodeHex(data: ByteArray): String {
|
||||
val l = data.size
|
||||
val out = CharArray(l shl 1)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < l) {
|
||||
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
|
||||
out[j++] = chars[15 and data[i].toInt()]
|
||||
i++
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.InternalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rx.Observable
|
||||
import rx.Subscriber
|
||||
import rx.Subscription
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/*
|
||||
* Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
|
||||
*/
|
||||
|
||||
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
|
||||
cont.unsubscribeOnCancellation(
|
||||
subscribe(
|
||||
object : Subscriber<T>() {
|
||||
override fun onStart() {
|
||||
request(1)
|
||||
}
|
||||
|
||||
override fun onNext(t: T) {
|
||||
cont.resume(t)
|
||||
}
|
||||
|
||||
override fun onCompleted() {
|
||||
if (cont.isActive) {
|
||||
cont.resumeWithException(
|
||||
IllegalStateException(
|
||||
"Should have invoked onNext",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
/*
|
||||
* Rx1 observable throws NoSuchElementException if cancellation happened before
|
||||
* element emission. To mitigate this we try to atomically resume continuation with exception:
|
||||
* if resume failed, then we know that continuation successfully cancelled itself
|
||||
*/
|
||||
val token = cont.tryResumeWithException(e)
|
||||
if (token != null) {
|
||||
cont.completeResume(token)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
|
||||
invokeOnCancellation { sub.unsubscribe() }
|
|
@ -0,0 +1,67 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import androidx.core.text.parseAsHtml
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] at its end.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.chop(count: Int, replacement: String = "…"): String {
|
||||
return if (length > count) {
|
||||
take(count - replacement.length) + replacement
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] near the center.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
|
||||
if (length <= count) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
|
||||
|
||||
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive natural comparator for strings.
|
||||
*/
|
||||
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return comparator.compare(this, other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the string as the number of bytes.
|
||||
*/
|
||||
fun String.byteSize(): Int {
|
||||
return toByteArray(StandardCharsets.UTF_8).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string containing the first [n] bytes from this string, or the entire string if this
|
||||
* string is shorter.
|
||||
*/
|
||||
fun String.takeBytes(n: Int): String {
|
||||
val bytes = toByteArray(StandardCharsets.UTF_8)
|
||||
return if (bytes.size <= n) {
|
||||
this
|
||||
} else {
|
||||
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-decode the string
|
||||
*/
|
||||
fun String.htmlDecode(): String {
|
||||
return this.parseAsHtml().toString()
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package eu.kanade.tachiyomi.util.preference
|
||||
|
||||
import android.widget.CompoundButton
|
||||
import eu.kanade.core.preference.PreferenceMutableState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import tachiyomi.core.preference.Preference
|
||||
|
||||
/**
|
||||
* Binds a checkbox or switch view with a boolean preference.
|
||||
*/
|
||||
fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
|
||||
isChecked = pref.get()
|
||||
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.plusAssign(item: T) {
|
||||
set(get() + item)
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.minusAssign(item: T) {
|
||||
set(get() - item)
|
||||
}
|
||||
|
||||
fun Preference<Boolean>.toggle(): Boolean {
|
||||
set(!get())
|
||||
return get()
|
||||
}
|
||||
|
||||
fun <T> Preference<T>.asState(presenterScope: CoroutineScope): PreferenceMutableState<T> {
|
||||
return PreferenceMutableState(this, presenterScope)
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package eu.kanade.tachiyomi.util.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import java.io.File
|
||||
|
||||
val Context.cacheImageDir: File
|
||||
get() = File(cacheDir, "shared_image")
|
||||
|
||||
/**
|
||||
* Returns the uri of a file
|
||||
*
|
||||
* @param context context of application
|
||||
*/
|
||||
fun File.getUriCompat(context: Context): Uri {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, context.packageName + ".provider", this)
|
||||
} else {
|
||||
this.toUri()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.util.lang.truncateCenter
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import ani.dantotsu.toast
|
||||
import com.hippo.unifile.UniFile
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Copies a string to clipboard
|
||||
*
|
||||
* @param label Label to show to the user describing the content
|
||||
* @param content the actual text to copy to the board
|
||||
*/
|
||||
fun Context.copyToClipboard(label: String, content: String) {
|
||||
if (content.isBlank()) return
|
||||
|
||||
try {
|
||||
val clipboard = getSystemService<ClipboardManager>()!!
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, content))
|
||||
|
||||
// Android 13 and higher shows a visual confirmation of copied contents
|
||||
// https://developer.android.com/about/versions/13/features/copy-paste
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
toast("Copied to clipboard: " + content.truncateCenter(50))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
toast("Failed to copy to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the give permission is granted.
|
||||
*
|
||||
* @param permission the permission to check.
|
||||
* @return true if it has permissions.
|
||||
*/
|
||||
fun Context.hasPermission(permission: String) = PermissionChecker.checkSelfPermission(this, permission) == PermissionChecker.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Returns the color for the given attribute.
|
||||
*
|
||||
* @param resource the attribute.
|
||||
* @param alphaFactor the alpha number [0,1].
|
||||
*/
|
||||
@ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int {
|
||||
val typedArray = obtainStyledAttributes(intArrayOf(resource))
|
||||
val color = typedArray.getColor(0, 0)
|
||||
typedArray.recycle()
|
||||
|
||||
if (alphaFactor < 1f) {
|
||||
val alpha = (color.alpha * alphaFactor).roundToInt()
|
||||
return Color.argb(alpha, color.red, color.green, color.blue)
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
@ColorInt fun Context.getThemeColor(attr: Int): Int {
|
||||
val tv = TypedValue()
|
||||
return if (this.theme.resolveAttribute(attr, tv, true)) {
|
||||
if (tv.resourceId != 0) {
|
||||
getColor(tv.resourceId)
|
||||
} else {
|
||||
tv.data
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
val Context.powerManager: PowerManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
/**
|
||||
* Convenience method to acquire a partial wake lock.
|
||||
*/
|
||||
fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock {
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock")
|
||||
wakeLock.acquire()
|
||||
return wakeLock
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given service class is running.
|
||||
*/
|
||||
fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
|
||||
val className = serviceClass.name
|
||||
val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
@Suppress("DEPRECATION")
|
||||
return manager.getRunningServices(Integer.MAX_VALUE)
|
||||
.any { className == it.service.className }
|
||||
}
|
||||
|
||||
fun Context.openInBrowser(url: String, forceDefaultBrowser: Boolean = false) {
|
||||
this.openInBrowser(url.toUri(), forceDefaultBrowser)
|
||||
}
|
||||
|
||||
fun Context.openInBrowser(uri: Uri, forceDefaultBrowser: Boolean = false) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
// Force default browser so that verified extensions don't re-open Tachiyomi
|
||||
if (forceDefaultBrowser) {
|
||||
defaultBrowserPackageName()?.let { setPackage(it) }
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
toast(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.defaultBrowserPackageName(): String? {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri())
|
||||
val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.resolveActivity(browserIntent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
}
|
||||
return resolveInfo
|
||||
?.activityInfo?.packageName
|
||||
?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers }
|
||||
}
|
||||
|
||||
fun Context.createFileInCacheDir(name: String): File {
|
||||
val file = File(externalCacheDir, name)
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
file.createNewFile()
|
||||
return file
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if [packageName] is installed.
|
||||
*/
|
||||
fun Context.isPackageInstalled(packageName: String): Boolean {
|
||||
return try {
|
||||
packageManager.getApplicationInfo(packageName, 0)
|
||||
true
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets document size of provided [Uri]
|
||||
*
|
||||
* @return document size of [uri] or null if size can't be obtained
|
||||
*/
|
||||
fun Context.getUriSize(uri: Uri): Long? {
|
||||
return UniFile.fromUri(this, uri).length().takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
|
||||
val Context.hasMiuiPackageInstaller get() = isPackageInstalled("com.miui.packageinstaller")
|
||||
|
||||
val Context.isShizukuInstalled get() = false
|
||||
|
||||
|
||||
|
||||
fun Context.getApplicationIcon(pkgName: String): Drawable? {
|
||||
return try {
|
||||
packageManager.getApplicationIcon(pkgName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
object DeviceUtil {
|
||||
|
||||
val isMiui by lazy {
|
||||
getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the MIUI major version code from a string like "V12.5.3.0.QFGMIXM".
|
||||
*
|
||||
* @return MIUI major version code (e.g., 13) or null if can't be parsed.
|
||||
*/
|
||||
val miuiMajorVersion by lazy {
|
||||
if (!isMiui) return@lazy null
|
||||
|
||||
Build.VERSION.INCREMENTAL
|
||||
.substringBefore('.')
|
||||
.trimStart('V')
|
||||
.toIntOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
fun isMiuiOptimizationDisabled(): Boolean {
|
||||
val sysProp = getSystemProperty("persist.sys.miui_optimization")
|
||||
if (sysProp == "0" || sysProp == "false") {
|
||||
return true
|
||||
}
|
||||
|
||||
return try {
|
||||
Class.forName("android.miui.AppOpsUtils")
|
||||
.getDeclaredMethod("isXOptMode")
|
||||
.invoke(null) as Boolean
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val isSamsung by lazy {
|
||||
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
|
||||
val oneUiVersion by lazy {
|
||||
try {
|
||||
val semPlatformIntField = Build.VERSION::class.java.getDeclaredField("SEM_PLATFORM_INT")
|
||||
val version = semPlatformIntField.getInt(null) - 90000
|
||||
if (version < 0) {
|
||||
1.0
|
||||
} else {
|
||||
((version / 10000).toString() + "." + version % 10000 / 100).toDouble()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val invalidDefaultBrowsers = listOf(
|
||||
"android",
|
||||
"com.huawei.android.internal.app",
|
||||
"com.zui.resolver",
|
||||
)
|
||||
|
||||
@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) {
|
||||
logcat(LogPriority.WARN, e) { "Unable to use SystemProperties.get()" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.IntentCompat
|
||||
import java.io.Serializable
|
||||
|
||||
fun Uri.toShareIntent(context: Context, type: String = "image/*", message: String? = null): Intent {
|
||||
val uri = this
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
when (uri.scheme) {
|
||||
"http", "https" -> {
|
||||
putExtra(Intent.EXTRA_TEXT, uri.toString())
|
||||
}
|
||||
"content" -> {
|
||||
message?.let { putExtra(Intent.EXTRA_TEXT, it) }
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
}
|
||||
}
|
||||
clipData = ClipData.newRawUri(null, uri)
|
||||
setType(type)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
||||
return Intent.createChooser(shareIntent, "Share").apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? {
|
||||
return IntentCompat.getParcelableExtra(this, name, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(name: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getSerializableExtra(name, T::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getSerializableExtra(name) as? T
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Utility class to change the application's language in runtime.
|
||||
*/
|
||||
object LocaleHelper {
|
||||
|
||||
val comparator = compareBy<String>(
|
||||
{ getDisplayName(it) },
|
||||
{ it == "all" },
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns display name of a string language code.
|
||||
*/
|
||||
fun getSourceDisplayName(lang: String?, context: Context): String {
|
||||
return when (lang) {
|
||||
LAST_USED_KEY -> "Last used"
|
||||
PINNED_KEY -> "Pinned"
|
||||
"other" -> "Other"
|
||||
"all" -> "Multi"
|
||||
else -> getDisplayName(lang)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns display name of a string language code.
|
||||
*
|
||||
* @param lang empty for system language
|
||||
*/
|
||||
fun getDisplayName(lang: String?): String {
|
||||
if (lang == null) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val locale = when (lang) {
|
||||
"" -> LocaleListCompat.getAdjustedDefault()[0]
|
||||
"zh-CN" -> Locale.forLanguageTag("zh-Hans")
|
||||
"zh-TW" -> Locale.forLanguageTag("zh-Hant")
|
||||
else -> Locale.forLanguageTag(lang)
|
||||
}
|
||||
return locale!!.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default languages enabled for the sources.
|
||||
*/
|
||||
fun getDefaultEnabledLanguages(): Set<String> {
|
||||
return setOf("all", "en", Locale.getDefault().language)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return English display string from string language code
|
||||
*/
|
||||
fun getSimpleLocaleDisplayName(): String {
|
||||
val sp = Locale.getDefault().language.split("_", "-")
|
||||
return Locale(sp[0]).getDisplayLanguage(LocaleListCompat.getDefault()[0]!!)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val PINNED_KEY = "pinned"
|
||||
internal const val LAST_USED_KEY = "last_used"
|
|
@ -0,0 +1,92 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationChannelGroupCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
val Context.notificationManager: NotificationManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
fun Context.notify(id: Int, channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null) {
|
||||
val notification = notificationBuilder(channelId, block).build()
|
||||
this.notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(notificationWithIdAndTags: List<NotificationWithIdAndTag>) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(notificationWithIdAndTags)
|
||||
}
|
||||
|
||||
fun Context.cancelNotification(id: Int) {
|
||||
NotificationManagerCompat.from(this).cancel(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a notification builder.
|
||||
*
|
||||
* @param id the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification to be displayed or updated.
|
||||
*/
|
||||
fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setColor(getColor(android.R.color.holo_blue_dark))
|
||||
if (block != null) {
|
||||
builder.block()
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel group.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel group to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannelGroup(
|
||||
channelId: String,
|
||||
block: (NotificationChannelGroupCompat.Builder.() -> Unit),
|
||||
): NotificationChannelGroupCompat {
|
||||
val builder = NotificationChannelGroupCompat.Builder(channelId)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param channelImportance the channel importance.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannel(
|
||||
channelId: String,
|
||||
channelImportance: Int,
|
||||
block: (NotificationChannelCompat.Builder.() -> Unit),
|
||||
): NotificationChannelCompat {
|
||||
val builder = NotificationChannelCompat.Builder(channelId, channelImportance)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/**
|
||||
* Display a toast in this context.
|
||||
*
|
||||
* @param resource the text resource.
|
||||
* @param duration the duration of the toast. Defaults to short.
|
||||
*/
|
||||
fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
|
||||
return toast(getString(resource), duration, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a toast in this context.
|
||||
*
|
||||
* @param text the text to display.
|
||||
* @param duration the duration of the toast. Defaults to short.
|
||||
*/
|
||||
fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
|
||||
return Toast.makeText(applicationContext, text.orEmpty(), duration).also {
|
||||
block(it)
|
||||
it.show()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.os.Build
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
@Suppress("OverridingDeprecatedMember")
|
||||
abstract class WebViewClientCompat : WebViewClient() {
|
||||
|
||||
open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun onReceivedErrorCompat(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
isMainFrame: Boolean,
|
||||
) {
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
final override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
return shouldOverrideUrlCompat(view, request.url.toString())
|
||||
}
|
||||
|
||||
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
return shouldOverrideUrlCompat(view, url)
|
||||
}
|
||||
|
||||
final override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): WebResourceResponse? {
|
||||
return shouldInterceptRequestCompat(view, request.url.toString())
|
||||
}
|
||||
|
||||
final override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
url: String,
|
||||
): WebResourceResponse? {
|
||||
return shouldInterceptRequestCompat(view, url)
|
||||
}
|
||||
|
||||
final override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
onReceivedErrorCompat(
|
||||
view,
|
||||
error.errorCode,
|
||||
error.description?.toString(),
|
||||
request.url.toString(),
|
||||
request.isForMainFrame,
|
||||
)
|
||||
}
|
||||
|
||||
final override fun onReceivedError(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
) {
|
||||
onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url)
|
||||
}
|
||||
|
||||
final override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceResponse,
|
||||
) {
|
||||
onReceivedErrorCompat(
|
||||
view,
|
||||
error.statusCode,
|
||||
error.reasonPhrase,
|
||||
request.url
|
||||
.toString(),
|
||||
request.isForMainFrame,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
object WebViewUtil {
|
||||
const val SPOOF_PACKAGE_NAME = "org.chromium.chrome"
|
||||
|
||||
const val MINIMUM_WEBVIEW_VERSION = 108
|
||||
|
||||
fun supportsWebView(context: Context): Boolean {
|
||||
try {
|
||||
// May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView
|
||||
// is not installed
|
||||
CookieManager.getInstance()
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
return false
|
||||
}
|
||||
|
||||
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW)
|
||||
}
|
||||
}
|
||||
|
||||
fun WebView.isOutdated(): Boolean {
|
||||
return getWebViewMajorVersion() < WebViewUtil.MINIMUM_WEBVIEW_VERSION
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun WebView.setDefaultSettings() {
|
||||
with(settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
|
||||
// Allow zooming
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun WebView.getWebViewMajorVersion(): Int {
|
||||
val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString())
|
||||
return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) {
|
||||
uaRegexMatch.groupValues[1].toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://stackoverflow.com/a/29218966
|
||||
private fun WebView.getDefaultUserAgentString(): String {
|
||||
val originalUA: String = settings.userAgentString
|
||||
|
||||
// Next call to getUserAgentString() will get us the default
|
||||
settings.userAgentString = null
|
||||
val defaultUserAgentString = settings.userAgentString
|
||||
|
||||
// Revert to original UA string
|
||||
settings.userAgentString = originalUA
|
||||
|
||||
return defaultUserAgentString
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue