Merge remote-tracking branch 'upstream/master' into Automatic_Reader_Background

This commit is contained in:
Jay 2019-04-13 09:45:17 -07:00
commit a7cb651df2
33 changed files with 1079 additions and 342 deletions

View File

@ -64,7 +64,7 @@ Catalogue requests should be created at https://github.com/inorichi/tachiyomi-ex
## FAQ ## FAQ
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ) [See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ)
You can also reach out to us on [Discord](https://discord.gg/WrBkRk4). You can also reach out to us on [Discord](https://discord.gg/tachiyomi).
## License ## License

View File

@ -14,6 +14,7 @@
<application <application
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@ -27,7 +28,12 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
<action android:name="com.google.android.gms.actions.SEARCH_ACTION"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable"/>
<!--suppress AndroidDomInspection --> <!--suppress AndroidDomInspection -->
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/> <meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
</activity> </activity>
@ -51,6 +57,21 @@
android:scheme="tachiyomi" /> android:scheme="tachiyomi" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.setting.ShikomoriLoginActivity"
android:label="Shikomori">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="shikimori-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity <activity
android:name=".extension.util.ExtensionInstallActivity" android:name=".extension.util.ExtensionInstallActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/> android:theme="@android:style/Theme.Translucent.NoTitleBar"/>

View File

@ -15,6 +15,8 @@ object PreferenceKeys {
const val showPageNumber = "pref_show_page_number_key" const val showPageNumber = "pref_show_page_number_key"
const val trueColor = "pref_true_color_key"
const val fullscreen = "fullscreen" const val fullscreen = "fullscreen"
const val keepScreenOn = "pref_keep_screen_on_key" const val keepScreenOn = "pref_keep_screen_on_key"
@ -105,6 +107,8 @@ object PreferenceKeys {
const val defaultCategory = "default_category" const val defaultCategory = "default_category"
const val skipRead = "skip_read"
const val downloadBadge = "display_download_badge" const val downloadBadge = "display_download_badge"
@Deprecated("Use the preferences of the source") @Deprecated("Use the preferences of the source")

View File

@ -43,6 +43,8 @@ class PreferencesHelper(val context: Context) {
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true) fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false)
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true) fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true) fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
@ -165,6 +167,8 @@ class PreferencesHelper(val context: Context) {
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1) fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
fun skipRead() = prefs.getBoolean(Keys.skipRead, false)
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE) fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet()) fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())

View File

@ -4,6 +4,7 @@ import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
import eu.kanade.tachiyomi.data.track.shikomori.Shikomori
class TrackManager(private val context: Context) { class TrackManager(private val context: Context) {
@ -11,6 +12,7 @@ class TrackManager(private val context: Context) {
const val MYANIMELIST = 1 const val MYANIMELIST = 1
const val ANILIST = 2 const val ANILIST = 2
const val KITSU = 3 const val KITSU = 3
const val SHIKOMORI = 4
} }
val myAnimeList = Myanimelist(context, MYANIMELIST) val myAnimeList = Myanimelist(context, MYANIMELIST)
@ -19,7 +21,9 @@ class TrackManager(private val context: Context) {
val kitsu = Kitsu(context, KITSU) val kitsu = Kitsu(context, KITSU)
val services = listOf(myAnimeList, aniList, kitsu) val shikomori = Shikomori(context, SHIKOMORI)
val services = listOf(myAnimeList, aniList, kitsu, shikomori)
fun getService(id: Int) = services.find { it.id == id } fun getService(id: Int) = services.find { it.id == id }

View File

@ -7,9 +7,9 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import java.net.URI
class Myanimelist(private val context: Context, id: Int) : TrackService(id) { class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
@ -114,23 +114,23 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).delete() preferences.trackToken(this).delete()
networkService.cookies.remove(URI(BASE_URL)) networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
} }
override val isLogged: Boolean override val isLogged: Boolean
get() = !getUsername().isEmpty() && get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() && !getPassword().isEmpty() &&
checkCookies(URI(BASE_URL)) && checkCookies() &&
!getCSRF().isEmpty() !getCSRF().isEmpty()
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault() private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(uri: URI): Boolean { private fun checkCookies(): Boolean {
var ckCount = 0 var ckCount = 0
val url = HttpUrl.parse(BASE_URL)!!
for (ck in networkService.cookies.get(uri)) { for (ck in networkService.cookieManager.get(url)) {
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE) if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
ckCount++ ckCount++
} }

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.shikomori
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
// Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -0,0 +1,138 @@
package eu.kanade.tachiyomi.data.track.shikomori
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Shikomori(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUsername())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track, getUsername())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.map { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
track
}
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Shikomori"
private val gson: Gson by injectLazy()
private val interceptor by lazy { ShikomoriInterceptor(this, gson) }
private val api by lazy { ShikomoriApi(client, interceptor) }
override fun getLogo() = R.drawable.shikomori
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLANNING -> getString(R.string.plan_to_read)
REPEATING -> getString(R.string.repeating)
else -> ""
}
}
override fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth)
if (oauth != null) {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
}
}.doOnError {
logout()
}.toCompletable()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
}

View File

@ -0,0 +1,189 @@
package eu.kanade.tachiyomi.data.track.shikomori
import android.net.Uri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.*
import rx.Observable
import uy.kohesive.injekt.injectLazy
class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInterceptor) {
private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val jsonime = MediaType.parse("application/json; charset=utf-8")
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track, user_id: String): Observable<Track> {
val payload = jsonObject(
"user_rate" to jsonObject(
"user_id" to user_id,
"target_id" to track.media_id,
"target_type" to "Manga",
"chapters" to track.last_chapter_read,
"score" to track.score.toInt(),
"status" to track.toShikomoriStatus()
)
)
val body = RequestBody.create(jsonime, payload.toString())
val request = Request.Builder()
.url("$apiUrl/v2/user_rates")
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id)
fun search(search: String): Observable<List<TrackSearch>> {
val url = Uri.parse("$apiUrl/mangas").buildUpon()
.appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search)
.appendQueryParameter("limit", "20")
.build()
val request = Request.Builder()
.url(url.toString())
.get()
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).array
response.map { jsonToSearch(it.obj) }
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.SHIKOMORI).apply {
media_id = obj["id"].asInt
title = obj["name"].asString
total_chapters = obj["chapters"].asInt
cover_url = baseUrl + obj["image"].obj["preview"].asString
summary = ""
tracking_url = baseUrl + obj["url"].asString
publishing_status = obj["status"].asString
publishing_type = obj["kind"].asString
start_date = obj.get("aired_on").nullString.orEmpty()
}
}
private fun jsonToTrack(obj: JsonObject): Track {
return Track.create(TrackManager.SHIKOMORI).apply {
media_id = obj["id"].asInt
title = ""
last_chapter_read = obj["chapters"].asInt
total_chapters = obj["chapters"].asInt
score = (obj["score"].asInt).toFloat()
status = toTrackStatus(obj["status"].asString)
}
}
fun findLibManga(track: Track, user_id: String): Observable<Track?> {
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
.appendQueryParameter("user_id", user_id)
.appendQueryParameter("target_id", track.media_id.toString())
.appendQueryParameter("target_type", "Manga")
.build()
val request = Request.Builder()
.url(url.toString())
.get()
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).array
if (response.size() > 1) {
throw Exception("Too much mangas in response")
}
val entry = response.map {
jsonToTrack(it.obj)
}
entry.firstOrNull()
}
}
fun getCurrentUser(): Int {
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body()?.string()
return parser.parse(user).obj["id"].asInt
}
fun accessToken(code: String): Observable<OAuth> {
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
gson.fromJson(responseBody, OAuth::class.java)
}
}
private fun accessTokenRequest(code: String) = POST(oauthUrl,
body = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("code", code)
.add("redirect_uri", redirectUrl)
.build()
)
companion object {
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.org"
private const val apiUrl = "https://shikimori.org/api"
private const val oauthUrl = "https://shikimori.org/oauth/token"
private const val loginUrl = "https://shikimori.org/oauth/authorize"
private const val redirectUrl = "tachiyomi://shikimori-auth"
private const val baseMangaUrl = "$apiUrl/mangas"
fun mangaUrl(remoteId: Int): String {
return "$baseMangaUrl/$remoteId"
}
fun authUrl() =
Uri.parse(loginUrl).buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUrl)
.appendQueryParameter("response_type", "code")
.build()
fun refreshTokenRequest(token: String) = POST(oauthUrl,
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
}
}

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.data.track.shikomori
import com.google.gson.Gson
import okhttp3.Interceptor
import okhttp3.Response
class ShikomoriInterceptor(val shikomori: Shikomori, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = shikomori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Shikomori")
val refreshToken = currAuth.refresh_token!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(ShikomoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
} else {
response.close()
}
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.header("User-Agent", "Tachiyomi")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = oauth
shikomori.saveToken(oauth)
}
}

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.data.track.shikomori
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toShikomoriStatus() = when (status) {
Shikomori.READING -> "watching"
Shikomori.COMPLETED -> "completed"
Shikomori.ON_HOLD -> "on_hold"
Shikomori.DROPPED -> "dropped"
Shikomori.PLANNING -> "planned"
Shikomori.REPEATING -> "rewatching"
else -> throw NotImplementedError("Unknown status")
}
fun toTrackStatus(status: String) = when (status) {
"watching" -> Shikomori.READING
"completed" -> Shikomori.COMPLETED
"on_hold" -> Shikomori.ON_HOLD
"dropped" -> Shikomori.DROPPED
"planned" -> Shikomori.PLANNING
"rewatching" -> Shikomori.REPEATING
else -> throw Exception("Unknown status")
}

View File

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import android.os.Build
import android.webkit.CookieManager
import android.webkit.CookieSyncManager
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
class AndroidCookieJar(context: Context) : CookieJar {
private val manager = CookieManager.getInstance()
private val syncManager by lazy { CookieSyncManager.createInstance(context) }
init {
// Init sync manager when using anything below L
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
syncManager
}
}
override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) {
val urlString = url.toString()
for (cookie in cookies) {
manager.setCookie(urlString, cookie.toString())
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
syncManager.sync()
}
}
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.isEmpty()) {
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
} else {
emptyList()
}
}
fun remove(url: HttpUrl) {
val cookies = manager.getCookie(url.toString()) ?: return
val domain = ".${url.host()}"
cookies.split(";")
.map { it.substringBefore("=") }
.onEach { manager.setCookie(domain, "$it=;Max-Age=-1") }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
syncManager.sync()
}
}
fun removeAll() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
manager.removeAllCookies {}
} else {
manager.removeAllCookie()
syncManager.sync()
}
}
}

View File

@ -1,41 +1,52 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import com.squareup.duktape.Duktape import android.annotation.SuppressLint
import okhttp3.* import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import eu.kanade.tachiyomi.util.WebViewClientCompat
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class CloudflareInterceptor : Interceptor { class CloudflareInterceptor(private val context: Context) : Interceptor {
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
private val passPattern = Regex("""name="pass" value="(.+?)"""")
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
private val sPattern = Regex("""name="s" value="([^"]+)""")
private val kPattern = Regex("""k\s+=\s+'([^']+)';""")
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
private interface IBase64 { private val handler = Handler(Looper.getMainLooper())
fun decode(input: String): String
}
private val b64: IBase64 = object : IBase64 { /**
override fun decode(input: String): String { * When this is called, it initializes the WebView if it wasn't already. We use this to avoid
return okio.ByteString.decodeBase64(input)!!.utf8() * blocking the main thread too much. If used too often we could consider moving it to the
* Application class.
*/
private val initWebView by lazy {
if (Build.VERSION.SDK_INT >= 17) {
WebSettings.getDefaultUserAgent(context)
} else {
null
} }
} }
@Synchronized @Synchronized
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
initWebView
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on // Check if Cloudflare anti-bot is on
if (response.code() == 503 && response.header("Server") in serverCheck) { if (response.code() == 503 && response.header("Server") in serverCheck) {
return try { try {
chain.proceed(resolveChallenge(response)) response.close()
val solutionRequest = resolveWithWebView(chain.request())
return chain.proceed(solutionRequest)
} catch (e: Exception) { } catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app // we don't crash the entire app
@ -46,65 +57,98 @@ class CloudflareInterceptor : Interceptor {
return response return response
} }
private fun resolveChallenge(response: Response): Request { private fun isChallengeSolutionUrl(url: String): Boolean {
Duktape.create().use { duktape -> return "chk_jschl" in url
val originalRequest = response.request() }
val url = originalRequest.url()
val domain = url.host()
val content = response.body()!!.string()
// CloudFlare requires waiting 4 seconds before resolving the challenge @SuppressLint("SetJavaScriptEnabled")
Thread.sleep(4000) private fun resolveWithWebView(request: Request): Request {
// 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)
val operation = operationPattern.find(content)?.groups?.get(1)?.value var webView: WebView? = null
val challenge = challengePattern.find(content)?.groups?.get(1)?.value var solutionUrl: String? = null
val pass = passPattern.find(content)?.groups?.get(1)?.value var challengeFound = false
val s = sPattern.find(content)?.groups?.get(1)?.value
// If `k` is null, it uses old methods. val origRequestUrl = request.url().toString()
val k = kPattern.find(content)?.groups?.get(1)?.value ?: "" val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
val innerHTMLValue = Regex("""<div(.*)id="$k"(.*)>(.*)</div>""")
.find(content)?.groups?.get(3)?.value ?: ""
if (operation == null || challenge == null || pass == null || s == null) { handler.post {
throw Exception("Failed resolving Cloudflare challenge") val view = WebView(context)
webView = view
view.settings.javaScriptEnabled = true
view.settings.userAgentString = request.header("User-Agent")
view.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
if (isChallengeSolutionUrl(url)) {
solutionUrl = url
latch.countDown()
}
return solutionUrl != null
}
override fun shouldInterceptRequestCompat(
view: WebView,
url: String
): WebResourceResponse? {
if (solutionUrl != null) {
// Intercept any request when we have the solution.
return WebResourceResponse("text/plain", "UTF-8", null)
}
return null
}
override fun onPageFinished(view: WebView, url: String) {
// Http error codes are only received since M
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
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 == 503) {
// Found the cloudflare challenge page.
challengeFound = true
} else {
// Unlock thread, the challenge wasn't found.
latch.countDown()
}
}
}
} }
webView?.loadUrl(origRequestUrl, headers)
// Export native Base64 decode function to js object.
duktape.set("b64", IBase64::class.java, b64)
// Return simulated innerHTML when call DOM.
val simulatedDocumentJS = """var document = { getElementById: function (x) { return { innerHTML: "$innerHTMLValue" }; } }"""
val js = operation
.replace(Regex("""a\.value = (.+\.toFixed\(10\);).+"""), "$1")
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("t.length", "${domain.length}")
.replace("\n", "")
val result = duktape.evaluate("""$simulatedDocumentJS;$ATOB_JS;var t="$domain";$js""") as String
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
.newBuilder()
.addQueryParameter("jschl_vc", challenge)
.addQueryParameter("pass", pass)
.addQueryParameter("s", s)
.addQueryParameter("jschl_answer", result)
.toString()
val cloudflareHeaders = originalRequest.headers()
.newBuilder()
.add("Referer", url.toString())
.add("Accept", "text/html,application/xhtml+xml,application/xml")
.add("Accept-Language", "en")
.build()
return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build())
} }
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
// around 4 seconds but it can take more due to slow networks or server issues.
latch.await(12, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
}
val solution = solutionUrl ?: throw Exception("Challenge not found")
return Request.Builder().get()
.url(solution)
.headers(request.headers())
.addHeader("Referer", origRequestUrl)
.addHeader("Accept", "text/html,application/xhtml+xml,application/xml")
.addHeader("Accept-Language", "en")
.build()
} }
companion object { }
// atob() is browser API, Using Android's own function. (java.util.Base64 can't be used because of min API level)
private const val ATOB_JS = """var atob = function (input) { return b64.decode(input) }"""
}
}

View File

@ -2,11 +2,7 @@ package eu.kanade.tachiyomi.network
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import okhttp3.Cache import okhttp3.*
import okhttp3.CipherSuite
import okhttp3.ConnectionSpec
import okhttp3.OkHttpClient
import okhttp3.TlsVersion
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress import java.net.InetAddress
@ -15,11 +11,7 @@ import java.net.UnknownHostException
import java.security.KeyManagementException import java.security.KeyManagementException
import java.security.KeyStore import java.security.KeyStore
import java.security.NoSuchAlgorithmException import java.security.NoSuchAlgorithmException
import javax.net.ssl.SSLContext import javax.net.ssl.*
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
class NetworkHelper(context: Context) { class NetworkHelper(context: Context) {
@ -27,7 +19,7 @@ class NetworkHelper(context: Context) {
private val cacheSize = 5L * 1024 * 1024 // 5 MiB private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val cookieManager = PersistentCookieJar(context) val cookieManager = AndroidCookieJar(context)
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.cookieJar(cookieManager) .cookieJar(cookieManager)
@ -36,12 +28,9 @@ class NetworkHelper(context: Context) {
.build() .build()
val cloudflareClient = client.newBuilder() val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor()) .addInterceptor(CloudflareInterceptor(context))
.build() .build()
val cookies: PersistentCookieStore
get() = cookieManager.store
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
return this return this

View File

@ -1,19 +0,0 @@
package eu.kanade.tachiyomi.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

@ -1,78 +0,0 @@
package eu.kanade.tachiyomi.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") ?: continue
val nonExpiredCookies = cookies.mapNotNull { Cookie.parse(url, it) }
.filter { !it.hasExpired() }
cookieMap.put(key, nonExpiredCookies)
} catch (e: Exception) {
// Ignore
}
}
}
}
@Synchronized
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
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(Cookie::toString)
.toSet()
prefs.edit().putStringSet(key, newValues).apply()
}
@Synchronized
fun removeAll() {
prefs.edit().clear().apply()
cookieMap.clear()
}
fun remove(uri: URI) {
prefs.edit().remove(uri.host).apply()
cookieMap.remove(uri.host)
}
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() }
}
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt()
}

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.main package eu.kanade.tachiyomi.ui.main
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.app.SearchManager
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
@ -15,6 +16,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.base.controller.* import eu.kanade.tachiyomi.ui.base.controller.*
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.download.DownloadController import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.extension.ExtensionController import eu.kanade.tachiyomi.ui.extension.ExtensionController
import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.library.LibraryController
@ -158,6 +160,16 @@ class MainActivity : BaseActivity() {
setSelectedDrawerItem(R.id.nav_drawer_downloads) setSelectedDrawerItem(R.id.nav_drawer_downloads)
} }
} }
Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> {
//If the intent match the "standard" Android search intent
// or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant)
setSelectedDrawerItem(R.id.nav_drawer_catalogues)
//Get the search query provided in extras, and if not null, perform a global search with it.
intent.getStringExtra(SearchManager.QUERY)?.also { query ->
router.pushController(CatalogueSearchController(query).withFadeTransaction())
}
}
else -> return false else -> return false
} }
return true return true

View File

@ -15,12 +15,7 @@ import android.support.customtabs.CustomTabsIntent
import android.support.v4.content.pm.ShortcutInfoCompat import android.support.v4.content.pm.ShortcutInfoCompat
import android.support.v4.content.pm.ShortcutManagerCompat import android.support.v4.content.pm.ShortcutManagerCompat
import android.support.v4.graphics.drawable.IconCompat import android.support.v4.graphics.drawable.IconCompat
import android.view.LayoutInflater import android.view.*
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -138,6 +133,7 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_open_in_browser -> openInBrowser() R.id.action_open_in_browser -> openInBrowser()
R.id.action_open_in_web_view -> openInWebView()
R.id.action_share -> shareManga() R.id.action_share -> shareManga()
R.id.action_add_to_home_screen -> addToHomeScreen() R.id.action_add_to_home_screen -> addToHomeScreen()
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
@ -302,6 +298,19 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
} }
} }
private fun openInWebView() {
val source = presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(presenter.manga).url().toString()
} catch (e: Exception) {
return
}
parentController?.router?.pushController(MangaWebViewController(source.id, url)
.withFadeTransaction())
}
/** /**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog. * Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/ */

View File

@ -0,0 +1,51 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import uy.kohesive.injekt.injectLazy
class MangaWebViewController(bundle: Bundle? = null) : BaseController(bundle) {
private val sourceManager by injectLazy<SourceManager>()
constructor(sourceId: Long, url: String) : this(Bundle().apply {
putLong(SOURCE_KEY, sourceId)
putString(URL_KEY, url)
})
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.manga_info_web_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
val source = sourceManager.get(args.getLong(SOURCE_KEY)) as? HttpSource ?: return
val url = args.getString(URL_KEY) ?: return
val headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
val web = view as WebView
web.settings.javaScriptEnabled = true
web.settings.userAgentString = source.headers["User-Agent"]
web.loadUrl(url, headers)
}
override fun onDestroyView(view: View) {
val web = view as WebView
web.stopLoading()
web.destroy()
super.onDestroyView(view)
}
private companion object {
const val SOURCE_KEY = "source_key"
const val URL_KEY = "url_key"
}
}

View File

@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -13,6 +14,7 @@ import android.view.*
import android.view.animation.Animation import android.view.animation.Animation
import android.view.animation.AnimationUtils import android.view.animation.AnimationUtils
import android.widget.SeekBar import android.widget.SeekBar
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
@ -558,6 +560,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
subscriptions += preferences.showPageNumber().asObservable() subscriptions += preferences.showPageNumber().asObservable()
.subscribe { setPageNumberVisibility(it) } .subscribe { setPageNumberVisibility(it) }
subscriptions += preferences.trueColor().asObservable()
.subscribe { setTrueColor(it) }
subscriptions += preferences.fullscreen().asObservable() subscriptions += preferences.fullscreen().asObservable()
.subscribe { setFullscreen(it) } .subscribe { setFullscreen(it) }
@ -614,6 +619,16 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
page_number.visibility = if (visible) View.VISIBLE else View.INVISIBLE page_number.visibility = if (visible) View.VISIBLE else View.INVISIBLE
} }
/**
* Sets the 32-bit color mode according to [enabled].
*/
private fun setTrueColor(enabled: Boolean) {
if (enabled)
SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888)
else
SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.RGB_565)
}
/** /**
* Sets the fullscreen reading mode (immersive) according to [enabled]. * Sets the fullscreen reading mode (immersive) according to [enabled].
*/ */

View File

@ -32,7 +32,7 @@ import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import java.util.Date import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@ -84,12 +84,25 @@ class ReaderPresenter(
private val chapterList by lazy { private val chapterList by lazy {
val manga = manga!! val manga = manga!!
val dbChapters = db.getChapters(manga).executeAsBlocking() val dbChapters = db.getChapters(manga).executeAsBlocking()
val selectedChapter = dbChapters.find { it.id == chapterId } val selectedChapter = dbChapters.find { it.id == chapterId }
?: error("Requested chapter of id $chapterId not found in chapter list") ?: error("Requested chapter of id $chapterId not found in chapter list")
val chaptersForReader =
if (preferences.skipRead()) {
var list = dbChapters.filter { it -> !it.read }.toMutableList()
val find = list.find { it.id == chapterId }
if (find == null) {
list.add(selectedChapter)
}
list
} else {
dbChapters
}
when (manga.sorting) { when (manga.sorting) {
Manga.SORTING_SOURCE -> ChapterLoadBySource().get(dbChapters) Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader)
Manga.SORTING_NUMBER -> ChapterLoadByNumber().get(dbChapters, selectedChapter) Manga.SORTING_NUMBER -> ChapterLoadByNumber().get(chaptersForReader, selectedChapter)
else -> error("Unknown sorting method") else -> error("Unknown sorting method")
}.map(::ReaderChapter) }.map(::ReaderChapter)
} }
@ -165,12 +178,12 @@ class ReaderPresenter(
if (!needsInit()) return if (!needsInit()) return
db.getManga(mangaId).asRxObservable() db.getManga(mangaId).asRxObservable()
.first() .first()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialChapterId) } .doOnNext { init(it, initialChapterId) }
.subscribeFirst({ _, _ -> .subscribeFirst({ _, _ ->
// Ignore onNext event // Ignore onNext event
}, ReaderActivity::setInitialChapterError) }, ReaderActivity::setInitialChapterError)
} }
/** /**
@ -193,13 +206,13 @@ class ReaderPresenter(
// Read chapterList from an io thread because it's retrieved lazily and would block main. // Read chapterList from an io thread because it's retrieved lazily and would block main.
activeChapterSubscription?.unsubscribe() activeChapterSubscription?.unsubscribe()
activeChapterSubscription = Observable activeChapterSubscription = Observable
.fromCallable { chapterList.first { chapterId == it.chapter.id } } .fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) } .flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ _, _ -> .subscribeFirst({ _, _ ->
// Ignore onNext event // Ignore onNext event
}, ReaderActivity::setInitialChapterError) }, ReaderActivity::setInitialChapterError)
} }
/** /**
@ -214,23 +227,23 @@ class ReaderPresenter(
chapter: ReaderChapter chapter: ReaderChapter
): Observable<ViewerChapters> { ): Observable<ViewerChapters> {
return loader.loadChapter(chapter) return loader.loadChapter(chapter)
.andThen(Observable.fromCallable { .andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter) val chapterPos = chapterList.indexOf(chapter)
ViewerChapters(chapter, ViewerChapters(chapter,
chapterList.getOrNull(chapterPos - 1), chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1)) chapterList.getOrNull(chapterPos + 1))
}) })
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters -> .doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value val oldChapters = viewerChaptersRelay.value
// Add new references first to avoid unnecessary recycling // Add new references first to avoid unnecessary recycling
newChapters.ref() newChapters.ref()
oldChapters?.unref() oldChapters?.unref()
viewerChaptersRelay.call(newChapters) viewerChaptersRelay.call(newChapters)
} }
} }
/** /**
@ -244,10 +257,10 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe() activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter) activeChapterSubscription = getLoadObservable(loader, chapter)
.toCompletable() .toCompletable()
.onErrorComplete() .onErrorComplete()
.subscribe() .subscribe()
.also(::add) .also(::add)
} }
/** /**
@ -262,13 +275,13 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe() activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter) activeChapterSubscription = getLoadObservable(loader, chapter)
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst({ view, _ -> .subscribeFirst({ view, _ ->
view.moveToPageIndex(0) view.moveToPageIndex(0)
}, { _, _ -> }, { _, _ ->
// Ignore onError event, viewers handle that state // Ignore onError event, viewers handle that state
}) })
} }
/** /**
@ -285,12 +298,12 @@ class ReaderPresenter(
val loader = loader ?: return val loader = loader ?: return
loader.loadChapter(chapter) loader.loadChapter(chapter)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
// Update current chapters whenever a chapter is preloaded // Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) } .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete() .onErrorComplete()
.subscribe() .subscribe()
.also(::add) .also(::add)
} }
/** /**
@ -331,9 +344,9 @@ class ReaderPresenter(
*/ */
private fun saveChapterProgress(chapter: ReaderChapter) { private fun saveChapterProgress(chapter: ReaderChapter) {
db.updateChapterProgress(chapter.chapter).asRxCompletable() db.updateChapterProgress(chapter.chapter).asRxCompletable()
.onErrorComplete() .onErrorComplete()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
/** /**
@ -342,9 +355,9 @@ class ReaderPresenter(
private fun saveChapterHistory(chapter: ReaderChapter) { private fun saveChapterHistory(chapter: ReaderChapter) {
val history = History.create(chapter.chapter).apply { last_read = Date().time } val history = History.create(chapter.chapter).apply { last_read = Date().time }
db.updateHistoryLastRead(history).asRxCompletable() db.updateHistoryLastRead(history).asRxCompletable()
.onErrorComplete() .onErrorComplete()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
/** /**
@ -394,18 +407,18 @@ class ReaderPresenter(
db.updateMangaViewer(manga).executeAsBlocking() db.updateMangaViewer(manga).executeAsBlocking()
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ -> .subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value val currChapters = viewerChaptersRelay.value
if (currChapters != null) { if (currChapters != null) {
// Save current page // Save current page
val currChapter = currChapters.currChapter val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read currChapter.requestedPage = currChapter.chapter.last_page_read
// Emit manga and chapters to the new viewer // Emit manga and chapters to the new viewer
view.setManga(manga) view.setManga(manga)
view.setChapters(currChapters) view.setChapters(currChapters)
} }
}) })
} }
/** /**
@ -446,22 +459,22 @@ class ReaderPresenter(
// Pictures directory. // Pictures directory.
val destDir = File(Environment.getExternalStorageDirectory().absolutePath + val destDir = File(Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES + File.separator + Environment.DIRECTORY_PICTURES +
File.separator + "Tachiyomi") File.separator + "Tachiyomi")
// Copy file in background. // Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) } Observable.fromCallable { saveImage(page, destDir, manga) }
.doOnNext { file -> .doOnNext { file ->
DiskUtil.scanMedia(context, file) DiskUtil.scanMedia(context, file)
notifier.onComplete(file) notifier.onComplete(file)
} }
.doOnError { notifier.onError(it.message) } .doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst( .subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) } { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
) )
} }
/** /**
@ -478,14 +491,14 @@ class ReaderPresenter(
val destDir = File(context.cacheDir, "shared_image") val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.delete() } // Keep only the last shared file Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(page, destDir, manga) } .map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst( .subscribeFirst(
{ view, file -> view.onShareImageResult(file) }, { view, file -> view.onShareImageResult(file) },
{ view, error -> /* Empty */ } { view, error -> /* Empty */ }
) )
} }
/** /**
@ -497,28 +510,28 @@ class ReaderPresenter(
val stream = page.stream ?: return val stream = page.stream ?: return
Observable Observable
.fromCallable { .fromCallable {
if (manga.source == LocalSource.ID) { if (manga.source == LocalSource.ID) {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream()) LocalSource.updateCover(context, manga, stream())
R.string.cover_updated R.string.cover_updated
SetAsCoverResult.Success
} else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } else {
SetAsCoverResult.AddToLibraryFirst val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
SetAsCoverResult.Success
} else {
SetAsCoverResult.AddToLibraryFirst
}
} }
} }
} .subscribeOn(Schedulers.io())
.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread()) .subscribeFirst(
.subscribeFirst( { view, result -> view.onSetAsCoverResult(result) },
{ view, result -> view.onSetAsCoverResult(result) }, { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } )
)
} }
/** /**
@ -559,26 +572,26 @@ class ReaderPresenter(
val trackManager = Injekt.get<TrackManager>() val trackManager = Injekt.get<TrackManager>()
db.getTracks(manga).asRxSingle() db.getTracks(manga).asRxSingle()
.flatMapCompletable { trackList -> .flatMapCompletable { trackList ->
Completable.concat(trackList.map { track -> Completable.concat(trackList.map { track ->
val service = trackManager.getService(track.sync_id) val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) { if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) {
track.last_chapter_read = lastChapterRead track.last_chapter_read = lastChapterRead
// We wan't these to execute even if the presenter is destroyed and leaks // We wan't these to execute even if the presenter is destroyed and leaks
// for a while. The view can still be garbage collected. // for a while. The view can still be garbage collected.
Observable.defer { service.update(track) } Observable.defer { service.update(track) }
.map { db.insertTrack(track).executeAsBlocking() } .map { db.insertTrack(track).executeAsBlocking() }
.toCompletable() .toCompletable()
.onErrorComplete() .onErrorComplete()
} else { } else {
Completable.complete() Completable.complete()
} }
}) })
} }
.onErrorComplete() .onErrorComplete()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
/** /**
@ -594,19 +607,19 @@ class ReaderPresenter(
if (removeAfterReadSlots == -1) return if (removeAfterReadSlots == -1) return
Completable Completable
.fromCallable { .fromCallable {
// Position of the read chapter // Position of the read chapter
val position = chapterList.indexOf(chapter) val position = chapterList.indexOf(chapter)
// Retrieve chapter to delete according to preference // Retrieve chapter to delete according to preference
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots) val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
if (chapterToDelete != null) { if (chapterToDelete != null) {
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga) downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
}
} }
} .onErrorComplete()
.onErrorComplete() .subscribeOn(Schedulers.io())
.subscribeOn(Schedulers.io()) .subscribe()
.subscribe()
} }
/** /**
@ -615,9 +628,9 @@ class ReaderPresenter(
*/ */
private fun deletePendingChapters() { private fun deletePendingChapters() {
Completable.fromCallable { downloadManager.deletePendingChapters() } Completable.fromCallable { downloadManager.deletePendingChapters() }
.onErrorComplete() .onErrorComplete()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
} }

View File

@ -43,7 +43,7 @@ class SettingsAdvancedController : SettingsController() {
titleRes = R.string.pref_clear_cookies titleRes = R.string.pref_clear_cookies
onClick { onClick {
network.cookies.removeAll() network.cookieManager.removeAll()
activity?.toast(R.string.cookies_cleared) activity?.toast(R.string.cookies_cleared)
} }
} }

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.setting package eu.kanade.tachiyomi.ui.setting
import android.os.Build
import android.support.v7.preference.PreferenceScreen import android.support.v7.preference.PreferenceScreen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
@ -62,6 +63,11 @@ class SettingsReaderController : SettingsController() {
defaultValue = "500" defaultValue = "500"
summary = "%s" summary = "%s"
} }
switchPreference {
key = Keys.skipRead
titleRes = R.string.pref_skip_read_chapters
defaultValue = false
}
switchPreference { switchPreference {
key = Keys.fullscreen key = Keys.fullscreen
titleRes = R.string.pref_fullscreen titleRes = R.string.pref_fullscreen
@ -77,6 +83,13 @@ class SettingsReaderController : SettingsController() {
titleRes = R.string.pref_show_page_number titleRes = R.string.pref_show_page_number
defaultValue = true defaultValue = true
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
switchPreference {
key = Keys.trueColor
titleRes = R.string.pref_true_color
defaultValue = false
}
}
preferenceCategory { preferenceCategory {
titleRes = R.string.pager_viewer titleRes = R.string.pager_viewer

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.shikomori.ShikomoriApi
import eu.kanade.tachiyomi.util.getResourceColor import eu.kanade.tachiyomi.util.getResourceColor
import eu.kanade.tachiyomi.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.LoginPreference
import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog
@ -53,6 +54,15 @@ class SettingsTrackingController : SettingsController(),
dialog.showDialog(router) dialog.showDialog(router)
} }
} }
trackPreference(trackManager.shikomori) {
onClick {
val tabsIntent = CustomTabsIntent.Builder()
.setToolbarColor(context.getResourceColor(R.attr.colorPrimary))
.build()
tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
tabsIntent.launchUrl(activity, ShikomoriApi.authUrl())
}
}
} }
} }
@ -70,6 +80,7 @@ class SettingsTrackingController : SettingsController(),
super.onActivityResumed(activity) super.onActivityResumed(activity)
// Manually refresh anilist holder // Manually refresh anilist holder
updatePreference(trackManager.aniList.id) updatePreference(trackManager.aniList.id)
updatePreference(trackManager.shikomori.id)
} }
private fun updatePreference(id: Int) { private fun updatePreference(id: Int) {

View File

@ -0,0 +1,50 @@
package eu.kanade.tachiyomi.ui.setting
import android.content.Intent
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.ProgressBar
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
class ShikomoriLoginActivity : AppCompatActivity() {
private val trackManager: TrackManager by injectLazy()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
val view = ProgressBar(this)
setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER))
val code = intent.data?.getQueryParameter("code")
if (code != null) {
trackManager.shikomori.login(code)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings()
}, {
returnToSettings()
})
} else {
trackManager.shikomori.logout()
returnToSettings()
}
}
private fun returnToSettings() {
finish()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
}
}

View File

@ -0,0 +1,83 @@
package eu.kanade.tachiyomi.util
import android.annotation.TargetApi
import android.os.Build
import android.webkit.*
@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)
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
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)
}
@TargetApi(Build.VERSION_CODES.M)
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)
}
@TargetApi(Build.VERSION_CODES.M)
final override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
error: WebResourceResponse
) {
onReceivedErrorCompat(view, error.statusCode, error.reasonPhrase, request.url
.toString(), request.isForMainFrame)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<WebView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
</WebView>

View File

@ -105,6 +105,17 @@
android:textColor="?android:attr/textColorSecondary" android:textColor="?android:attr/textColorSecondary"
app:layout_constraintTop_toBottomOf="@id/background_color" /> app:layout_constraintTop_toBottomOf="@id/background_color" />
<android.support.v7.widget.SwitchCompat
android:id="@+id/true_color"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/pref_true_color"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone"
tools:visibility="visible"
app:layout_constraintTop_toBottomOf="@id/show_page_number" />
<android.support.v7.widget.SwitchCompat <android.support.v7.widget.SwitchCompat
android:id="@+id/fullscreen" android:id="@+id/fullscreen"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -112,7 +123,7 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/pref_fullscreen" android:text="@string/pref_fullscreen"
android:textColor="?android:attr/textColorSecondary" android:textColor="?android:attr/textColorSecondary"
app:layout_constraintTop_toBottomOf="@id/show_page_number" /> app:layout_constraintTop_toBottomOf="@id/true_color" />
<android.support.v7.widget.SwitchCompat <android.support.v7.widget.SwitchCompat
android:id="@+id/keepscreen" android:id="@+id/keepscreen"

View File

@ -12,8 +12,12 @@
android:title="@string/action_open_in_browser" android:title="@string/action_open_in_browser"
app:showAsAction="never"/> app:showAsAction="never"/>
<item android:id="@+id/action_open_in_web_view"
android:title="@string/action_open_in_web_view"
app:showAsAction="never"/>
<item android:id="@+id/action_add_to_home_screen" <item android:id="@+id/action_add_to_home_screen"
android:title="@string/action_add_to_home_screen" android:title="@string/action_add_to_home_screen"
app:showAsAction="never"/> app:showAsAction="never"/>
</menu> </menu>

View File

@ -73,6 +73,7 @@
<string name="action_resume">Resume</string> <string name="action_resume">Resume</string>
<string name="action_move">Move</string> <string name="action_move">Move</string>
<string name="action_open_in_browser">Open in browser</string> <string name="action_open_in_browser">Open in browser</string>
<string name="action_open_in_web_view">Open in web view</string>
<string name="action_add_to_home_screen">Add to home screen</string> <string name="action_add_to_home_screen">Add to home screen</string>
<string name="action_display_mode">Change display mode</string> <string name="action_display_mode">Change display mode</string>
<string name="action_display">Display</string> <string name="action_display">Display</string>
@ -173,10 +174,12 @@
<string name="pref_page_transitions">Page transitions</string> <string name="pref_page_transitions">Page transitions</string>
<string name="pref_double_tap_anim_speed">Double tap animation speed</string> <string name="pref_double_tap_anim_speed">Double tap animation speed</string>
<string name="pref_show_page_number">Show page number</string> <string name="pref_show_page_number">Show page number</string>
<string name="pref_true_color">32-bit color</string>
<string name="pref_crop_borders">Crop borders</string> <string name="pref_crop_borders">Crop borders</string>
<string name="pref_custom_brightness">Use custom brightness</string> <string name="pref_custom_brightness">Use custom brightness</string>
<string name="pref_custom_color_filter">Use custom color filter</string> <string name="pref_custom_color_filter">Use custom color filter</string>
<string name="pref_keep_screen_on">Keep screen on</string> <string name="pref_keep_screen_on">Keep screen on</string>
<string name="pref_skip_read_chapters">Skip chapters marked read</string>
<string name="pref_reader_navigation">Navigation</string> <string name="pref_reader_navigation">Navigation</string>
<string name="pref_read_with_volume_keys">Volume keys</string> <string name="pref_read_with_volume_keys">Volume keys</string>
<string name="pref_read_with_volume_keys_inverted">Invert volume keys</string> <string name="pref_read_with_volume_keys_inverted">Invert volume keys</string>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="database" path="tachiyomi.db"/>
</full-backup-content>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:label="@string/app_name"
android:hint="@string/action_global_search_hint" >
</searchable>