Kissmanga loading through Cloudflare. A lot of refactoring was needed

This commit is contained in:
len 2016-05-10 15:09:44 +02:00
parent 8da11dbdb9
commit 6e8a41f898
42 changed files with 753 additions and 524 deletions

View File

@ -118,7 +118,6 @@ dependencies {
// Network client
compile "com.squareup.okhttp3:okhttp:$OKHTTP_VERSION"
compile "com.squareup.okhttp3:okhttp-urlconnection:$OKHTTP_VERSION"
// REST
compile "com.squareup.retrofit2:retrofit:$RETROFIT_VERSION"
@ -131,6 +130,9 @@ dependencies {
// JSON
compile 'com.google.code.gson:gson:2.6.2'
// JavaScript engine
compile 'com.squareup.duktape:duktape-android:0.9.5'
// Disk cache
compile 'com.jakewharton:disklrucache:2.0.2'
@ -154,6 +156,7 @@ dependencies {
// Image library
compile 'com.github.bumptech.glide:glide:3.7.0'
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
// Logging
compile 'com.jakewharton.timber:timber:4.1.2'

View File

@ -101,7 +101,7 @@
<meta-data
android:name="eu.kanade.tachiyomi.data.cache.CoverGlideModule"
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
android:value="GlideModule" />
</application>

View File

@ -1,11 +1,6 @@
package eu.kanade.tachiyomi.data.cache
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.LazyHeaders
import com.bumptech.glide.request.animation.GlideAnimation
import com.bumptech.glide.request.target.SimpleTarget
import eu.kanade.tachiyomi.util.DiskUtils
import java.io.File
import java.io.IOException
@ -27,80 +22,19 @@ class CoverCache(private val context: Context) {
*/
private val cacheDir: File = File(context.externalCacheDir, "cover_disk_cache")
/**
* Download the cover with Glide and save the file.
* @param thumbnailUrl url of thumbnail.
* @param headers headers included in Glide request.
* @param onReady function to call when the image is ready
*/
fun save(thumbnailUrl: String?, headers: LazyHeaders?, onReady: ((File) -> Unit)? = null) {
// Check if url is empty.
if (thumbnailUrl.isNullOrEmpty())
return
// Download the cover with Glide and save the file.
val url = GlideUrl(thumbnailUrl, headers)
Glide.with(context)
.load(url)
.downloadOnly(object : SimpleTarget<File>() {
override fun onResourceReady(resource: File, anim: GlideAnimation<in File>) {
try {
// Copy the cover from Glide's cache to local cache.
copyToCache(thumbnailUrl!!, resource)
onReady?.invoke(resource)
} catch (e: IOException) {
// Do nothing.
}
}
})
}
/**
* Save or load the image from cache
* @param thumbnailUrl the thumbnail url.
* @param headers headers included in Glide request.
* @param onReady function to call when the image is ready
*/
fun saveOrLoadFromCache(thumbnailUrl: String?, headers: LazyHeaders?, onReady: ((File) -> Unit)?) {
// Check if url is empty.
if (thumbnailUrl.isNullOrEmpty())
return
// If file exist load it otherwise save it.
val localCover = getCoverFromCache(thumbnailUrl!!)
if (localCover.exists()) {
onReady?.invoke(localCover)
} else {
save(thumbnailUrl, headers, onReady)
}
}
/**
* Returns the cover from cache.
*
* @param thumbnailUrl the thumbnail url.
* @return cover image.
*/
private fun getCoverFromCache(thumbnailUrl: String): File {
fun getCoverFile(thumbnailUrl: String): File {
return File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl))
}
/**
* Copy the given file to this cache.
* @param thumbnailUrl url of thumbnail.
* @param sourceFile the source file of the cover image.
* @throws IOException if there's any error.
*/
@Throws(IOException::class)
fun copyToCache(thumbnailUrl: String, sourceFile: File) {
// Get destination file.
val destFile = getCoverFromCache(thumbnailUrl)
sourceFile.copyTo(destFile, overwrite = true)
}
/**
* Copy the given stream to this cache.
*
* @param thumbnailUrl url of the thumbnail.
* @param inputStream the stream to copy.
* @throws IOException if there's any error.
@ -108,13 +42,14 @@ class CoverCache(private val context: Context) {
@Throws(IOException::class)
fun copyToCache(thumbnailUrl: String, inputStream: InputStream) {
// Get destination file.
val destFile = getCoverFromCache(thumbnailUrl)
val destFile = getCoverFile(thumbnailUrl)
destFile.outputStream().use { inputStream.copyTo(it) }
}
/**
* Delete the cover file from the cache.
*
* @param thumbnailUrl the thumbnail url.
* @return status of deletion.
*/
@ -124,7 +59,7 @@ class CoverCache(private val context: Context) {
return false
// Remove file.
val file = File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl))
val file = getCoverFile(thumbnailUrl!!)
return file.exists() && file.delete()
}

View File

@ -1,22 +0,0 @@
package eu.kanade.tachiyomi.data.cache
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.module.GlideModule
/**
* Class used to update Glide module settings
*/
class CoverGlideModule : GlideModule {
override fun applyOptions(context: Context, builder: GlideBuilder) {
// Set the cache size of Glide to 15 MiB
builder.setDiskCache(InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024))
}
override fun registerComponents(context: Context, glide: Glide) {
// Nothing to see here!
}
}

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.data.glide
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.GlideModule
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.NetworkHelper
import java.io.InputStream
import javax.inject.Inject
/**
* Class used to update Glide module settings
*/
class AppGlideModule : GlideModule {
@Inject lateinit var networkHelper: NetworkHelper
override fun applyOptions(context: Context, builder: GlideBuilder) {
// Set the cache size of Glide to 15 MiB
builder.setDiskCache(InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024))
}
override fun registerComponents(context: Context, glide: Glide) {
App.get(context).component.inject(this)
glide.register(GlideUrl::class.java, InputStream::class.java,
OkHttpUrlLoader.Factory(networkHelper.defaultClient))
glide.register(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
}
}

View File

@ -0,0 +1,67 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.data.DataFetcher
import eu.kanade.tachiyomi.data.database.models.Manga
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
/**
* A [DataFetcher] for loading a cover of a manga depending on its favorite status.
* If the manga is favorite, it tries to load the cover from our cache, and if it's not found, it
* fallbacks to network and copies it to the cache.
* If the manga is not favorite, it tries to delete the cover from our cache and always fallback
* to network for fetching.
*
* @param networkFetcher the network fetcher for this cover.
* @param file the file where this cover should be. It may exists or not.
* @param manga the manga of the cover to load.
*/
class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
private val file: File,
private val manga: Manga)
: DataFetcher<InputStream> {
@Throws(Exception::class)
override fun loadData(priority: Priority): InputStream? {
if (manga.favorite) {
if (!file.exists()) {
file.parentFile.mkdirs()
networkFetcher.loadData(priority)?.let {
it.use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
}
}
return FileInputStream(file)
} else {
if (file.exists()) {
file.delete()
}
return networkFetcher.loadData(priority)
}
}
/**
* Returns the id for this manga's cover.
*
* Appending the file's modified date to the url, we can force Glide to skip its memory and disk
* lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when
* the file has changed. If the file doesn't exist it will append a 0.
*/
override fun getId(): String {
return manga.thumbnail_url + file.lastModified()
}
override fun cancel() {
networkFetcher.cancel()
}
override fun cleanup() {
networkFetcher.cleanup()
}
}

View File

@ -0,0 +1,118 @@
package eu.kanade.tachiyomi.data.glide
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.*
import com.bumptech.glide.load.model.stream.StreamModelLoader
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.SourceManager
import java.io.File
import java.io.InputStream
import javax.inject.Inject
/**
* A class for loading a cover associated with a [Manga] that can be present in our own cache.
* Coupled with [MangaDataFetcher], this class allows to implement the following flow:
*
* - Check in RAM LRU.
* - Check in disk LRU.
* - Check in this module.
* - Fetch from the network connection.
*
* @param context the application context.
*/
class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
/**
* Cover cache where persistent covers are stored.
*/
@Inject lateinit var coverCache: CoverCache
/**
* Source manager.
*/
@Inject lateinit var sourceManager: SourceManager
/**
* Base network loader.
*/
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
InputStream::class.java, context)
/**
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
* and the file where it should be stored in case the manga is a favorite.
*/
private val modelCache = ModelCache<String, Pair<GlideUrl, File>>(100)
/**
* Map where request headers are stored for a source.
*/
private val cachedHeaders = hashMapOf<Int, LazyHeaders>()
init {
App.get(context).component.inject(this)
}
/**
* Factory class for creating [MangaModelLoader] instances.
*/
class Factory : ModelLoaderFactory<Manga, InputStream> {
override fun build(context: Context, factories: GenericLoaderFactory)
= MangaModelLoader(context)
override fun teardown() {}
}
/**
* Returns a [MangaDataFetcher] for the given manga or null if the url is empty.
*
* @param manga the model.
* @param width the width of the view where the resource will be loaded.
* @param height the height of the view where the resource will be loaded.
*/
override fun getResourceFetcher(manga: Manga,
width: Int,
height: Int): DataFetcher<InputStream>? {
// Check thumbnail is not null or empty
val url = manga.thumbnail_url
if (url.isNullOrEmpty()) {
return null
}
// Obtain the request url and the file for this url from the LRU cache, or calculate it
// and add them to the cache.
val (glideUrl, file) = modelCache.get(url, width, height) ?:
Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url)).apply {
modelCache.put(url, width, height, this)
}
// Get the network fetcher for this request url.
val networkFetcher = baseLoader.getResourceFetcher(glideUrl, width, height)
// Return an instance of our fetcher providing the needed elements.
return MangaDataFetcher(networkFetcher, file, manga)
}
/**
* Returns the request headers for a source copying its OkHttp headers and caching them.
*
* @param manga the model.
*/
fun getHeaders(manga: Manga): LazyHeaders {
return cachedHeaders.getOrPut(manga.source) {
val source = sourceManager.get(manga.source)!!
LazyHeaders.Builder().apply {
for ((key, value) in source.requestHeaders.toMultimap()) {
addHeader(key, value[0])
}
}.build()
}
}
}

View File

@ -102,7 +102,7 @@ class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(cont
// MAL doesn't support score with decimals
fun getList(): Observable<List<MangaSync>> {
return networkService.requestBody(get(getListUrl(username), headers), true)
return networkService.requestBody(get(getListUrl(username), headers), networkService.forceCacheClient)
.map { Jsoup.parse(it) }
.flatMap { Observable.from(it.select("manga")) }
.map {

View File

@ -0,0 +1,81 @@
package eu.kanade.tachiyomi.data.network
import android.net.Uri
import com.squareup.duktape.Duktape
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
object CloudflareScraper {
//language=RegExp
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var t,r,a,f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
//language=RegExp
private val passPattern = Regex("""name="pass" value="(.+?)"""")
//language=RegExp
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
fun request(chain: Interceptor.Chain, cookies: PersistentCookieStore): Response {
val response = chain.proceed(chain.request())
// Check if we already solved a challenge
if (response.code() != 502 &&
cookies.get(response.request().url()).find { it.name() == "cf_clearance" } != null) {
return response
}
// Check if Cloudflare anti-bot is on
if ("URL=/cdn-cgi/" in response.header("Refresh", "")
&& response.header("Server", "") == "cloudflare-nginx") {
return chain.proceed(resolveChallenge(response))
}
return response
}
private fun resolveChallenge(response: Response): Request {
val duktape = Duktape.create()
try {
val originalRequest = response.request()
val domain = originalRequest.url().host()
val content = response.body().string()
// CloudFlare requires waiting 5 seconds before resolving the challenge
Thread.sleep(5000)
val operation = operationPattern.find(content)?.groups?.get(1)?.value
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
val pass = passPattern.find(content)?.groups?.get(1)?.value
if (operation == null || challenge == null || pass == null) {
throw RuntimeException("Failed resolving Cloudflare challenge")
}
val js = operation
//language=RegExp
.replace(Regex("""a\.value =(.+?) \+ .+?;"""), "$1")
//language=RegExp
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("\n", "")
// Duktape can only return strings, so the result has to be converted to string first
val result = duktape.evaluate("$js.toString()").toInt()
val answer = "${result + domain.length}"
val url = Uri.parse("http://$domain/cdn-cgi/l/chk_jschl").buildUpon()
.appendQueryParameter("jschl_vc", challenge)
.appendQueryParameter("pass", pass)
.appendQueryParameter("jschl_answer", answer)
.toString()
val referer = originalRequest.url().toString()
return get(url, originalRequest.headers().newBuilder().add("Referer", referer).build())
} finally {
duktape.close()
}
}
}

View File

@ -1,12 +1,12 @@
package eu.kanade.tachiyomi.data.network
import android.content.Context
import okhttp3.*
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import java.io.File
import java.net.CookieManager
import java.net.CookiePolicy
import java.net.CookieStore
class NetworkHelper(context: Context) {
@ -14,43 +14,41 @@ class NetworkHelper(context: Context) {
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val cookieManager = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
private val cookieManager = PersistentCookieJar(context)
private val forceCacheInterceptor = { chain: Interceptor.Chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.removeHeader("Pragma")
.header("Cache-Control", "max-age=" + 600)
.build()
}
private val client = OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookieManager))
val defaultClient = OkHttpClient.Builder()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize))
.build()
private val forceCacheClient = client.newBuilder()
.addNetworkInterceptor(forceCacheInterceptor)
val forceCacheClient = defaultClient.newBuilder()
.addNetworkInterceptor({ chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.removeHeader("Pragma")
.header("Cache-Control", "max-age=" + 600)
.build()
})
.build()
val cookies: CookieStore
get() = cookieManager.cookieStore
val cloudflareClient = defaultClient.newBuilder()
.addInterceptor { CloudflareScraper.request(it, cookies) }
.build()
val cookies: PersistentCookieStore
get() = cookieManager.store
@JvmOverloads
fun request(request: Request, forceCache: Boolean = false): Observable<Response> {
fun request(request: Request, client: OkHttpClient = defaultClient): Observable<Response> {
return Observable.fromCallable {
val c = if (forceCache) forceCacheClient else client
c.newCall(request).execute().apply { body().close() }
client.newCall(request).execute().apply { body().close() }
}
}
@JvmOverloads
fun requestBody(request: Request, forceCache: Boolean = false): Observable<String> {
fun requestBody(request: Request, client: OkHttpClient = defaultClient): Observable<String> {
return Observable.fromCallable {
val c = if (forceCache) forceCacheClient else client
c.newCall(request).execute().body().string()
client.newCall(request).execute().body().string()
}
}
@ -59,7 +57,7 @@ class NetworkHelper(context: Context) {
}
fun requestBodyProgressBlocking(request: Request, listener: ProgressListener): Response {
val progressClient = client.newBuilder()
val progressClient = defaultClient.newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
@ -72,5 +70,4 @@ class NetworkHelper(context: Context) {
return progressClient.newCall(request).execute()
}
}

View File

@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.data.network
import android.content.Context
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
class PersistentCookieJar(context: Context) : CookieJar {
val store = PersistentCookieStore(context)
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
store.addAll(url, cookies)
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return store.get(url)
}
}

View File

@ -0,0 +1,75 @@
package eu.kanade.tachiyomi.data.network
import android.content.Context
import okhttp3.Cookie
import okhttp3.HttpUrl
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
class PersistentCookieStore(context: Context) {
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
init {
for ((key, value) in prefs.all) {
@Suppress("UNCHECKED_CAST")
val cookies = value as? Set<String>
if (cookies != null) {
try {
val url = HttpUrl.parse("http://$key")
val nonExpiredCookies = cookies.map { Cookie.parse(url, it) }
.filter { !it.hasExpired() }
cookieMap.put(key, nonExpiredCookies)
} catch (e: Exception) {
// Ignore
}
}
}
}
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
synchronized(this) {
val key = url.uri().host
// Append or replace the cookies for this domain.
val cookiesForDomain = cookieMap[key].orEmpty().toMutableList()
for (cookie in cookies) {
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() }
if (pos == -1) {
cookiesForDomain.add(cookie)
} else {
cookiesForDomain[pos] = cookie
}
}
cookieMap.put(key, cookiesForDomain)
// Get cookies to be stored in disk
val newValues = cookiesForDomain.asSequence()
.filter { it.persistent() && !it.hasExpired() }
.map { it.toString() }
.toSet()
prefs.edit().putStringSet(key, newValues).apply()
}
}
fun removeAll() {
synchronized(this) {
prefs.edit().clear().apply()
cookieMap.clear()
}
}
fun get(url: HttpUrl) = get(url.uri().host)
fun get(uri: URI) = get(uri.host)
private fun get(url: String): List<Cookie> {
return cookieMap[url].orEmpty().filter { !it.hasExpired() }
}
fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt()
}

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.source.base
import android.content.Context
import com.bumptech.glide.load.model.LazyHeaders
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.models.Chapter
@ -11,6 +10,7 @@ import eu.kanade.tachiyomi.data.network.get
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
@ -27,12 +27,13 @@ abstract class Source(context: Context) : BaseSource() {
val requestHeaders by lazy { headersBuilder().build() }
val glideHeaders by lazy { glideHeadersBuilder().build() }
init {
App.get(context).component.inject(this)
}
open val networkClient: OkHttpClient
get() = networkService.defaultClient
override fun isLoginRequired(): Boolean {
return false
}
@ -75,7 +76,7 @@ abstract class Source(context: Context) : BaseSource() {
// Get the most popular mangas from the source
open fun pullPopularMangasFromNetwork(page: MangasPage): Observable<MangasPage> {
return networkService.requestBody(popularMangaRequest(page), true)
return networkService.requestBody(popularMangaRequest(page), networkClient)
.map { Jsoup.parse(it) }
.doOnNext { doc -> page.mangas = parsePopularMangasFromHtml(doc) }
.doOnNext { doc -> page.nextPageUrl = parseNextPopularMangasUrl(doc, page) }
@ -84,7 +85,7 @@ abstract class Source(context: Context) : BaseSource() {
// Get mangas from the source with a query
open fun searchMangasFromNetwork(page: MangasPage, query: String): Observable<MangasPage> {
return networkService.requestBody(searchMangaRequest(page, query), true)
return networkService.requestBody(searchMangaRequest(page, query), networkClient)
.map { Jsoup.parse(it) }
.doOnNext { doc -> page.mangas = parseSearchFromHtml(doc) }
.doOnNext { doc -> page.nextPageUrl = parseNextSearchUrl(doc, page, query) }
@ -93,13 +94,13 @@ abstract class Source(context: Context) : BaseSource() {
// Get manga details from the source
open fun pullMangaFromNetwork(mangaUrl: String): Observable<Manga> {
return networkService.requestBody(mangaDetailsRequest(mangaUrl))
return networkService.requestBody(mangaDetailsRequest(mangaUrl), networkClient)
.flatMap { Observable.just(parseHtmlToManga(mangaUrl, it)) }
}
// Get chapter list of a manga from the source
open fun pullChaptersFromNetwork(mangaUrl: String): Observable<List<Chapter>> {
return networkService.requestBody(chapterListRequest(mangaUrl))
return networkService.requestBody(chapterListRequest(mangaUrl), networkClient)
.flatMap { unparsedHtml ->
val chapters = parseHtmlToChapters(unparsedHtml)
if (!chapters.isEmpty())
@ -116,7 +117,7 @@ abstract class Source(context: Context) : BaseSource() {
}
open fun pullPageListFromNetwork(chapterUrl: String): Observable<List<Page>> {
return networkService.requestBody(pageListRequest(chapterUrl))
return networkService.requestBody(pageListRequest(chapterUrl), networkClient)
.flatMap { unparsedHtml ->
val pages = convertToPages(parseHtmlToPageUrls(unparsedHtml))
if (!pages.isEmpty())
@ -141,7 +142,7 @@ abstract class Source(context: Context) : BaseSource() {
open fun getImageUrlFromPage(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
return networkService.requestBody(imageUrlRequest(page))
return networkService.requestBody(imageUrlRequest(page), networkClient)
.flatMap { unparsedHtml -> Observable.just(parseHtmlToImageUrl(unparsedHtml)) }
.onErrorResumeNext { e ->
page.status = Page.ERROR
@ -224,13 +225,4 @@ abstract class Source(context: Context) : BaseSource() {
}
protected open fun glideHeadersBuilder(): LazyHeaders.Builder {
val builder = LazyHeaders.Builder()
for ((key, value) in requestHeaders.toMultimap()) {
builder.addHeader(key, value[0])
}
return builder
}
}

View File

@ -10,7 +10,6 @@ import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.net.HttpCookie;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.ParseException;
@ -34,6 +33,7 @@ import eu.kanade.tachiyomi.data.source.base.LoginSource;
import eu.kanade.tachiyomi.data.source.model.MangasPage;
import eu.kanade.tachiyomi.data.source.model.Page;
import eu.kanade.tachiyomi.util.Parser;
import okhttp3.Cookie;
import okhttp3.FormBody;
import okhttp3.Headers;
import okhttp3.Request;
@ -358,8 +358,8 @@ public class Batoto extends LoginSource {
@Override
public boolean isLogged() {
try {
for ( HttpCookie cookie : getNetworkService().getCookies().get(new URI(BASE_URL)) ) {
if (cookie.getName().equals("pass_hash"))
for (Cookie cookie : getNetworkService().getCookies().get(new URI(BASE_URL))) {
if (cookie.name().equals("pass_hash"))
return true;
}

View File

@ -1,234 +0,0 @@
package eu.kanade.tachiyomi.data.source.online.english;
import android.content.Context;
import android.net.Uri;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import eu.kanade.tachiyomi.data.database.models.Chapter;
import eu.kanade.tachiyomi.data.database.models.Manga;
import eu.kanade.tachiyomi.data.network.ReqKt;
import eu.kanade.tachiyomi.data.source.Language;
import eu.kanade.tachiyomi.data.source.LanguageKt;
import eu.kanade.tachiyomi.data.source.base.Source;
import eu.kanade.tachiyomi.data.source.model.MangasPage;
import eu.kanade.tachiyomi.data.source.model.Page;
import eu.kanade.tachiyomi.util.Parser;
import okhttp3.FormBody;
import okhttp3.Headers;
import okhttp3.Request;
public class Kissmanga extends Source {
public static final String NAME = "Kissmanga";
public static final String HOST = "kissmanga.com";
public static final String IP = "93.174.95.110";
public static final String BASE_URL = "http://" + IP;
public static final String POPULAR_MANGAS_URL = BASE_URL + "/MangaList/MostPopular?page=%s";
public static final String SEARCH_URL = BASE_URL + "/AdvanceSearch";
public Kissmanga(Context context) {
super(context);
}
@Override
protected Headers.Builder headersBuilder() {
Headers.Builder builder = super.headersBuilder();
builder.add("Host", HOST);
return builder;
}
@Override
public String getName() {
return NAME;
}
@Override
public String getBaseUrl() {
return BASE_URL;
}
public Language getLang() {
return LanguageKt.getEN();
}
@Override
protected String getInitialPopularMangasUrl() {
return String.format(POPULAR_MANGAS_URL, 1);
}
@Override
protected String getInitialSearchUrl(String query) {
return SEARCH_URL;
}
@Override
protected Request searchMangaRequest(MangasPage page, String query) {
if (page.page == 1) {
page.url = getInitialSearchUrl(query);
}
FormBody.Builder form = new FormBody.Builder();
form.add("authorArtist", "");
form.add("mangaName", query);
form.add("status", "");
form.add("genres", "");
return ReqKt.post(page.url, getRequestHeaders(), form.build());
}
@Override
protected Request pageListRequest(String chapterUrl) {
return ReqKt.post(getBaseUrl() + chapterUrl, getRequestHeaders());
}
@Override
protected Request imageRequest(Page page) {
return ReqKt.get(page.getImageUrl());
}
@Override
protected List<Manga> parsePopularMangasFromHtml(Document parsedHtml) {
List<Manga> mangaList = new ArrayList<>();
for (Element currentHtmlBlock : parsedHtml.select("table.listing tr:gt(1)")) {
Manga manga = constructPopularMangaFromHtml(currentHtmlBlock);
mangaList.add(manga);
}
return mangaList;
}
private Manga constructPopularMangaFromHtml(Element htmlBlock) {
Manga manga = new Manga();
manga.source = getId();
Element urlElement = Parser.element(htmlBlock, "td a:eq(0)");
if (urlElement != null) {
manga.setUrl(urlElement.attr("href"));
manga.title = urlElement.text();
}
return manga;
}
@Override
protected String parseNextPopularMangasUrl(Document parsedHtml, MangasPage page) {
String path = Parser.href(parsedHtml, "li > a:contains( Next)");
return path != null ? BASE_URL + path : null;
}
@Override
protected List<Manga> parseSearchFromHtml(Document parsedHtml) {
return parsePopularMangasFromHtml(parsedHtml);
}
@Override
protected String parseNextSearchUrl(Document parsedHtml, MangasPage page, String query) {
return null;
}
@Override
protected Manga parseHtmlToManga(String mangaUrl, String unparsedHtml) {
Document parsedDocument = Jsoup.parse(unparsedHtml);
Element infoElement = parsedDocument.select("div.barContent").first();
Manga manga = Manga.create(mangaUrl);
manga.title = Parser.text(infoElement, "a.bigChar");
manga.author = Parser.text(infoElement, "p:has(span:contains(Author:)) > a");
manga.genre = Parser.allText(infoElement, "p:has(span:contains(Genres:)) > *:gt(0)");
manga.description = Parser.allText(infoElement, "p:has(span:contains(Summary:)) ~ p");
manga.status = parseStatus(Parser.text(infoElement, "p:has(span:contains(Status:))"));
String thumbnail = Parser.src(parsedDocument, ".rightBox:eq(0) img");
if (thumbnail != null) {
manga.thumbnail_url = Uri.parse(thumbnail).buildUpon().authority(IP).toString();
}
manga.initialized = true;
return manga;
}
private int parseStatus(String status) {
if (status.contains("Ongoing")) {
return Manga.ONGOING;
}
if (status.contains("Completed")) {
return Manga.COMPLETED;
}
return Manga.UNKNOWN;
}
@Override
protected List<Chapter> parseHtmlToChapters(String unparsedHtml) {
Document parsedDocument = Jsoup.parse(unparsedHtml);
List<Chapter> chapterList = new ArrayList<>();
for (Element chapterElement : parsedDocument.select("table.listing tr:gt(1)")) {
Chapter chapter = constructChapterFromHtmlBlock(chapterElement);
chapterList.add(chapter);
}
return chapterList;
}
private Chapter constructChapterFromHtmlBlock(Element chapterElement) {
Chapter chapter = Chapter.create();
Element urlElement = Parser.element(chapterElement, "a");
String date = Parser.text(chapterElement, "td:eq(1)");
if (urlElement != null) {
chapter.setUrl(urlElement.attr("href"));
chapter.name = urlElement.text();
}
if (date != null) {
try {
chapter.date_upload = new SimpleDateFormat("MM/dd/yyyy", Locale.ENGLISH).parse(date).getTime();
} catch (ParseException e) { /* Ignore */ }
}
return chapter;
}
@Override
protected List<String> parseHtmlToPageUrls(String unparsedHtml) {
Document parsedDocument = Jsoup.parse(unparsedHtml);
List<String> pageUrlList = new ArrayList<>();
int numImages = parsedDocument.select("#divImage img").size();
for (int i = 0; i < numImages; i++) {
pageUrlList.add("");
}
return pageUrlList;
}
@Override
protected List<Page> parseFirstPage(List<? extends Page> pages, String unparsedHtml) {
Pattern p = Pattern.compile("lstImages.push\\(\"(.+?)\"");
Matcher m = p.matcher(unparsedHtml);
int i = 0;
while (m.find()) {
pages.get(i++).setImageUrl(m.group(1));
}
return (List<Page>) pages;
}
@Override
protected String parseHtmlToImageUrl(String unparsedHtml) {
return null;
}
}

View File

@ -0,0 +1,200 @@
package eu.kanade.tachiyomi.data.source.online.english
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.get
import eu.kanade.tachiyomi.data.network.post
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.base.Source
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.Parser
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
class Kissmanga(context: Context) : Source(context) {
override fun getName() = NAME
override fun getBaseUrl() = BASE_URL
override fun getLang() = EN
override val networkClient: OkHttpClient
get() = networkService.cloudflareClient
override fun getInitialPopularMangasUrl(): String {
return String.format(POPULAR_MANGAS_URL, 1)
}
override fun getInitialSearchUrl(query: String): String {
return SEARCH_URL
}
override fun searchMangaRequest(page: MangasPage, query: String): Request {
if (page.page == 1) {
page.url = getInitialSearchUrl(query)
}
val form = FormBody.Builder()
form.add("authorArtist", "")
form.add("mangaName", query)
form.add("status", "")
form.add("genres", "")
return post(page.url, requestHeaders, form.build())
}
override fun pageListRequest(chapterUrl: String): Request {
return post(baseUrl + chapterUrl, requestHeaders)
}
override fun imageRequest(page: Page): Request {
return get(page.imageUrl)
}
override fun parsePopularMangasFromHtml(parsedHtml: Document): List<Manga> {
val mangaList = ArrayList<Manga>()
for (currentHtmlBlock in parsedHtml.select("table.listing tr:gt(1)")) {
val manga = constructPopularMangaFromHtml(currentHtmlBlock)
mangaList.add(manga)
}
return mangaList
}
private fun constructPopularMangaFromHtml(htmlBlock: Element): Manga {
val manga = Manga()
manga.source = id
val urlElement = Parser.element(htmlBlock, "td a:eq(0)")
if (urlElement != null) {
manga.setUrl(urlElement.attr("href"))
manga.title = urlElement.text()
}
return manga
}
override fun parseNextPopularMangasUrl(parsedHtml: Document, page: MangasPage): String? {
val path = Parser.href(parsedHtml, "li > a:contains( Next)")
return if (path != null) BASE_URL + path else null
}
override fun parseSearchFromHtml(parsedHtml: Document): List<Manga> {
return parsePopularMangasFromHtml(parsedHtml)
}
override fun parseNextSearchUrl(parsedHtml: Document, page: MangasPage, query: String): String? {
return null
}
override fun parseHtmlToManga(mangaUrl: String, unparsedHtml: String): Manga {
val parsedDocument = Jsoup.parse(unparsedHtml)
val infoElement = parsedDocument.select("div.barContent").first()
val manga = Manga.create(mangaUrl)
manga.title = Parser.text(infoElement, "a.bigChar")
manga.author = Parser.text(infoElement, "p:has(span:contains(Author:)) > a")
manga.genre = Parser.allText(infoElement, "p:has(span:contains(Genres:)) > *:gt(0)")
manga.description = Parser.allText(infoElement, "p:has(span:contains(Summary:)) ~ p")
manga.status = parseStatus(Parser.text(infoElement, "p:has(span:contains(Status:))")!!)
val thumbnail = Parser.src(parsedDocument, ".rightBox:eq(0) img")
if (thumbnail != null) {
manga.thumbnail_url = thumbnail
}
manga.initialized = true
return manga
}
private fun parseStatus(status: String): Int {
if (status.contains("Ongoing")) {
return Manga.ONGOING
}
if (status.contains("Completed")) {
return Manga.COMPLETED
}
return Manga.UNKNOWN
}
override fun parseHtmlToChapters(unparsedHtml: String): List<Chapter> {
val parsedDocument = Jsoup.parse(unparsedHtml)
val chapterList = ArrayList<Chapter>()
for (chapterElement in parsedDocument.select("table.listing tr:gt(1)")) {
val chapter = constructChapterFromHtmlBlock(chapterElement)
chapterList.add(chapter)
}
return chapterList
}
private fun constructChapterFromHtmlBlock(chapterElement: Element): Chapter {
val chapter = Chapter.create()
val urlElement = Parser.element(chapterElement, "a")
val date = Parser.text(chapterElement, "td:eq(1)")
if (urlElement != null) {
chapter.setUrl(urlElement.attr("href"))
chapter.name = urlElement.text()
}
if (date != null) {
try {
chapter.date_upload = SimpleDateFormat("MM/dd/yyyy", Locale.ENGLISH).parse(date).time
} catch (e: ParseException) { /* Ignore */
}
}
return chapter
}
override fun parseHtmlToPageUrls(unparsedHtml: String): List<String> {
val parsedDocument = Jsoup.parse(unparsedHtml)
val pageUrlList = ArrayList<String>()
val numImages = parsedDocument.select("#divImage img").size
for (i in 0..numImages - 1) {
pageUrlList.add("")
}
return pageUrlList
}
override fun parseFirstPage(pages: List<Page>, unparsedHtml: String): List<Page> {
val p = Pattern.compile("lstImages.push\\(\"(.+?)\"")
val m = p.matcher(unparsedHtml)
var i = 0
while (m.find()) {
pages[i++].imageUrl = m.group(1)
}
return pages
}
override fun parseHtmlToImageUrl(unparsedHtml: String): String? {
return null
}
companion object {
val NAME = "Kissmanga"
val BASE_URL = "http://kissmanga.com"
val POPULAR_MANGAS_URL = BASE_URL + "/MangaList/MostPopular?page=%s"
val SEARCH_URL = BASE_URL + "/AdvanceSearch"
}
}

View File

@ -100,7 +100,7 @@ public class ReadMangaToday extends Source {
@Override
public Observable<MangasPage> searchMangasFromNetwork(final MangasPage page, String query) {
return networkService
.requestBody(searchMangaRequest(page, query), true)
.requestBody(searchMangaRequest(page, query), networkService.getDefaultClient())
.doOnNext(new Action1<String>() {
@Override
public void call(String doc) {

View File

@ -2,7 +2,9 @@ package eu.kanade.tachiyomi.injection.component
import android.app.Application
import dagger.Component
import eu.kanade.tachiyomi.data.glide.AppGlideModule
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.glide.MangaModelLoader
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.mangasync.UpdateMangaSyncService
import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService
@ -51,6 +53,9 @@ interface AppComponent {
fun inject(downloadService: DownloadService)
fun inject(updateMangaSyncService: UpdateMangaSyncService)
fun inject(mangaModelLoader: MangaModelLoader)
fun inject(appGlideModule: AppGlideModule)
fun inject(updateDownloader: UpdateDownloader)
fun application(): Application

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.catalogue
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.model.GlideUrl
import eu.kanade.tachiyomi.data.database.models.Manga
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
@ -42,20 +41,16 @@ class CatalogueGridHolder(private val view: View, private val adapter: Catalogue
* @param manga the manga to bind.
*/
fun setImage(manga: Manga) {
Glide.clear(view.thumbnail)
if (!manga.thumbnail_url.isNullOrEmpty()) {
val url = manga.thumbnail_url!!
val headers = adapter.fragment.presenter.source.glideHeaders
Glide.with(view.context)
.load(if (headers != null) GlideUrl(url, headers) else url)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
.centerCrop()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent)
.into(view.thumbnail)
} else {
Glide.clear(view.thumbnail)
}
}
}

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.catalogue
import android.os.Bundle
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -38,6 +39,11 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
*/
@Inject lateinit var prefs: PreferencesHelper
/**
* Cover cache.
*/
@Inject lateinit var coverCache: CoverCache
/**
* Enabled sources.
*/
@ -335,6 +341,9 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
*/
fun changeMangaFavorite(manga: Manga) {
manga.favorite = !manga.favorite
if (!manga.favorite) {
coverCache.deleteFromCache(manga.thumbnail_url)
}
db.insertManga(manga).executeAsBlocking()
}

View File

@ -98,10 +98,9 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryFragment) :
* @param position the position to bind.
*/
override fun onBindViewHolder(holder: LibraryHolder, position: Int) {
val presenter = (fragment.parentFragment as LibraryFragment).presenter
val manga = getItem(position)
holder.onSetValues(manga, presenter)
holder.onSetValues(manga)
//When user scrolls this bind the correct selection status
holder.itemView.isActivated = isSelected(position)
}

View File

@ -12,7 +12,7 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.event.LibraryMangaEvent
import eu.kanade.tachiyomi.ui.library.LibraryMangaEvent
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
import eu.kanade.tachiyomi.ui.base.fragment.BaseFragment
import eu.kanade.tachiyomi.ui.manga.MangaActivity

View File

@ -15,7 +15,6 @@ import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.event.LibraryMangaEvent
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.category.CategoryActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
@ -388,7 +387,10 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
* @param mangas the manga list to move.
*/
private fun moveMangasToCategories(mangas: List<Manga>) {
val categories = presenter.categories
// Hide the default category because it has a different behavior than the ones from db.
val categories = presenter.categories.filter { it.id != 0 }
// Get indexes of the common categories to preselect.
val commonCategoriesIndexes = presenter.getCommonCategories(mangas)
.map { categories.indexOf(it) }
.toTypedArray()
@ -397,7 +399,8 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
.title(R.string.action_move_category)
.items(categories.map { it.name })
.itemsCallbackMultiChoice(commonCategoriesIndexes) { dialog, positions, text ->
presenter.moveMangasToCategories(positions, mangas)
val selectedCategories = positions.map { categories[it] }
presenter.moveMangasToCategories(selectedCategories, mangas)
destroyActionModeIfNeeded()
true
}

View File

@ -3,10 +3,7 @@ package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.signature.StringSignature
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.base.Source
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
@ -19,8 +16,10 @@ import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new library holder.
*/
class LibraryHolder(private val view: View, private val adapter: LibraryCategoryAdapter, listener: FlexibleViewHolder.OnListItemClickListener) :
FlexibleViewHolder(view, adapter, listener) {
class LibraryHolder(private val view: View,
private val adapter: LibraryCategoryAdapter,
listener: FlexibleViewHolder.OnListItemClickListener)
: FlexibleViewHolder(view, adapter, listener) {
private var manga: Manga? = null
@ -29,9 +28,8 @@ class LibraryHolder(private val view: View, private val adapter: LibraryCategory
* holder with the given manga.
*
* @param manga the manga to bind.
* @param presenter the library presenter.
*/
fun onSetValues(manga: Manga, presenter: LibraryPresenter) {
fun onSetValues(manga: Manga) {
this.manga = manga
// Update the title of the manga.
@ -44,31 +42,13 @@ class LibraryHolder(private val view: View, private val adapter: LibraryCategory
}
// Update the cover.
loadCover(manga, presenter.sourceManager.get(manga.source)!!, presenter.coverCache)
}
/**
* Load the cover of a manga in a image view.
*
* @param manga the manga to bind.
* @param source the source of the manga.
* @param coverCache the cache that stores the cover in the filesystem.
*/
private fun loadCover(manga: Manga, source: Source, coverCache: CoverCache) {
Glide.clear(view.thumbnail)
if (!manga.thumbnail_url.isNullOrEmpty()) {
coverCache.saveOrLoadFromCache(manga.thumbnail_url, source.glideHeaders) {
if (adapter.fragment.isResumed && this.manga == manga) {
Glide.with(view.context)
.load(it)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.signature(StringSignature(it.lastModified().toString()))
.placeholder(android.R.color.transparent)
.into(itemView.thumbnail)
}
}
}
Glide.with(view.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.placeholder(android.R.color.transparent)
.into(view.thumbnail)
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.event
package eu.kanade.tachiyomi.ui.library
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga

View File

@ -11,10 +11,10 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.event.LibraryMangaEvent
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.BehaviorSubject
import java.io.IOException
import java.io.InputStream
@ -236,26 +236,18 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
* Remove the selected manga from the library.
*/
fun deleteMangas() {
for (manga in selectedMangas) {
manga.favorite = false
}
// Create a set of the list
val mangaToDelete = selectedMangas.toSet()
db.insertMangas(selectedMangas).executeAsBlocking()
}
/**
* Move the given list of manga to categories.
*
* @param positions the indexes of the selected categories.
* @param mangas the list of manga to move.
*/
fun moveMangasToCategories(positions: Array<Int>, mangas: List<Manga>) {
val categoriesToAdd = ArrayList<Category>()
for (index in positions) {
categoriesToAdd.add(categories[index])
}
moveMangasToCategories(categoriesToAdd, mangas)
Observable.from(mangaToDelete)
.subscribeOn(Schedulers.io())
.doOnNext {
it.favorite = false
coverCache.deleteFromCache(it.thumbnail_url)
}
.toList()
.flatMap { db.insertMangas(it).asRxObservable() }
.subscribe()
}
/**

View File

@ -8,7 +8,7 @@ import android.support.v4.app.FragmentManager
import android.support.v4.app.FragmentPagerAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.event.MangaEvent
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersFragment
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoFragment

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.event
package eu.kanade.tachiyomi.ui.manga
import eu.kanade.tachiyomi.data.database.models.Manga

View File

@ -4,8 +4,8 @@ import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.event.ChapterCountEvent
import eu.kanade.tachiyomi.event.MangaEvent
import eu.kanade.tachiyomi.ui.manga.info.ChapterCountEvent
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.SharedData
import rx.Observable

View File

@ -11,8 +11,8 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.base.Source
import eu.kanade.tachiyomi.event.ChapterCountEvent
import eu.kanade.tachiyomi.event.MangaEvent
import eu.kanade.tachiyomi.ui.manga.info.ChapterCountEvent
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.SharedData
import rx.Observable

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.event
package eu.kanade.tachiyomi.ui.manga.info
import rx.Observable
import rx.subjects.BehaviorSubject

View File

@ -6,8 +6,6 @@ import android.os.Bundle
import android.view.*
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.signature.StringSignature
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.base.Source
@ -112,45 +110,19 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
// Set the favorite drawable to the correct one.
setFavoriteDrawable(manga.favorite)
// Initialize CoverCache and Glide headers to retrieve cover information.
val coverCache = presenter.coverCache
val headers = presenter.source.glideHeaders
// Set cover if it wasn't already.
if (manga_cover.drawable == null) {
manga.thumbnail_url?.let { url ->
if (manga.favorite) {
coverCache.saveOrLoadFromCache(url, headers) {
if (isResumed) {
Glide.with(context)
.load(it)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.signature(StringSignature(it.lastModified().toString()))
.into(manga_cover)
if (manga_cover.drawable == null && !manga.thumbnail_url.isNullOrEmpty()) {
Glide.with(context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(manga_cover)
Glide.with(context)
.load(it)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.signature(StringSignature(it.lastModified().toString()))
.into(backdrop)
}
}
} else {
Glide.with(context)
.load(if (headers != null) GlideUrl(url, headers) else url)
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
.centerCrop()
.into(manga_cover)
Glide.with(context)
.load(if (headers != null) GlideUrl(url, headers) else url)
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
.centerCrop()
.into(backdrop)
}
}
Glide.with(context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(backdrop)
}
}

View File

@ -6,9 +6,8 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.base.Source
import eu.kanade.tachiyomi.event.ChapterCountEvent
import eu.kanade.tachiyomi.event.MangaEvent
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.util.SharedData
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
@ -116,22 +115,11 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
*/
fun toggleFavorite() {
manga.favorite = !manga.favorite
onMangaFavoriteChange(manga.favorite)
db.insertManga(manga).executeAsBlocking()
refreshManga()
}
/**
* (Removes / Saves) cover depending on favorite status.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun onMangaFavoriteChange(isFavorite: Boolean) {
if (isFavorite) {
coverCache.save(manga.thumbnail_url, source.glideHeaders)
} else {
if (!manga.favorite) {
coverCache.deleteFromCache(manga.thumbnail_url)
}
db.insertManga(manga).executeAsBlocking()
refreshManga()
}
/**

View File

@ -7,7 +7,7 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.event.MangaEvent
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.toast

View File

@ -21,7 +21,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.event.ReaderEvent
import eu.kanade.tachiyomi.ui.reader.ReaderEvent
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.base.listener.SimpleAnimationListener
import eu.kanade.tachiyomi.ui.base.listener.SimpleSeekBarListener

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.event
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga

View File

@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.base.Source
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.event.ReaderEvent
import eu.kanade.tachiyomi.ui.reader.ReaderEvent
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.SharedData
import rx.Observable

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
@ -20,6 +21,7 @@ class SettingsActivity : BaseActivity() {
@Inject lateinit var db: DatabaseHelper
@Inject lateinit var sourceManager: SourceManager
@Inject lateinit var syncManager: MangaSyncManager
@Inject lateinit var networkHelper: NetworkHelper
override fun onCreate(savedState: Bundle?) {
setAppTheme()

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.setting
import android.os.Bundle
import android.support.v7.preference.Preference
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
@ -16,8 +15,6 @@ import java.util.concurrent.atomic.AtomicInteger
class SettingsAdvancedFragment : SettingsNestedFragment() {
private var clearCacheSubscription: Subscription? = null
companion object {
fun newInstance(resourcePreference: Int, resourceTitle: Int): SettingsNestedFragment {
@ -27,17 +24,28 @@ class SettingsAdvancedFragment : SettingsNestedFragment() {
}
}
override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
val clearCache = findPreference(getString(R.string.pref_clear_chapter_cache_key))
val clearDatabase = findPreference(getString(R.string.pref_clear_database_key))
private val clearCache by lazy { findPreference(getString(R.string.pref_clear_chapter_cache_key)) }
clearCache.setOnPreferenceClickListener { preference ->
clearChapterCache(preference)
private val clearDatabase by lazy { findPreference(getString(R.string.pref_clear_database_key)) }
private val clearCookies by lazy { findPreference(getString(R.string.pref_clear_cookies_key)) }
private var clearCacheSubscription: Subscription? = null
override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
clearCache.setOnPreferenceClickListener {
clearChapterCache()
true
}
clearCache.summary = getString(R.string.used_cache, chapterCache.readableSize)
clearDatabase.setOnPreferenceClickListener { preference ->
clearCookies.setOnPreferenceClickListener {
settingsActivity.networkHelper.cookies.removeAll()
activity.toast(R.string.cookies_cleared)
true
}
clearDatabase.setOnPreferenceClickListener {
clearDatabase()
true
}
@ -48,7 +56,7 @@ class SettingsAdvancedFragment : SettingsNestedFragment() {
super.onDestroyView()
}
private fun clearChapterCache(preference: Preference) {
private fun clearChapterCache() {
val deletedFiles = AtomicInteger()
val files = chapterCache.cacheDir.listFiles()
@ -78,7 +86,7 @@ class SettingsAdvancedFragment : SettingsNestedFragment() {
}, {
dialog.dismiss()
activity.toast(getString(R.string.cache_deleted, deletedFiles.get()))
preference.summary = getString(R.string.used_cache, chapterCache.readableSize)
clearCache.summary = getString(R.string.used_cache, chapterCache.readableSize)
})
}
@ -87,7 +95,10 @@ class SettingsAdvancedFragment : SettingsNestedFragment() {
.content(R.string.clear_database_confirmation)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { dialog, which -> db.deleteMangasNotInLibrary().executeAsBlocking() }
.onPositive { dialog, which ->
db.deleteMangasNotInLibrary().executeAsBlocking()
activity.toast(R.string.clear_database_completed)
}
.show()
}

View File

@ -52,6 +52,7 @@
<string name="pref_clear_chapter_cache_key">pref_clear_chapter_cache_key</string>
<string name="pref_clear_database_key">pref_clear_database_key</string>
<string name="pref_clear_cookies_key">pref_clear_cookies_key</string>
<string name="pref_version">pref_version</string>
<string name="pref_build_time">pref_build_time</string>

View File

@ -158,9 +158,12 @@
<string name="used_cache">Used: %1$s</string>
<string name="cache_deleted">Cache cleared. %1$d files have been deleted</string>
<string name="cache_delete_error">An error occurred while clearing cache</string>
<string name="pref_clear_cookies">Clear cookies</string>
<string name="cookies_cleared">Cookies cleared</string>
<string name="pref_clear_database">Clear database</string>
<string name="pref_clear_database_summary">Delete manga and chapters that are not in your library</string>
<string name="clear_database_confirmation">Are you sure? Read chapters and progress of non-library manga will be lost</string>
<string name="clear_database_completed">Entries deleted</string>
<string name="pref_show_warning_message">Show warnings</string>
<string name="pref_show_warning_message_summary">Show warning messages during library sync </string>
<string name="pref_reencode">Reencode images</string>

View File

@ -6,6 +6,10 @@
android:key="@string/pref_clear_chapter_cache_key"
android:title="@string/pref_clear_chapter_cache"/>
<Preference
android:key="@string/pref_clear_cookies_key"
android:title="@string/pref_clear_cookies"/>
<Preference
android:key="@string/pref_clear_database_key"
android:summary="@string/pref_clear_database_summary"