diff --git a/README.md b/README.md index 92b9e80b70..4b21282d6b 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Catalogue requests should be created at https://github.com/inorichi/tachiyomi-ex ## 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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6537290437..3e5f35a9b1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ - + + + + + + @@ -51,6 +57,21 @@ android:scheme="tachiyomi" /> + + + + + + + + + + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 259e1fb8c2..1b2e6bb322 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -15,6 +15,8 @@ object PreferenceKeys { const val showPageNumber = "pref_show_page_number_key" + const val trueColor = "pref_true_color_key" + const val fullscreen = "fullscreen" const val keepScreenOn = "pref_keep_screen_on_key" @@ -105,6 +107,8 @@ object PreferenceKeys { const val defaultCategory = "default_category" + const val skipRead = "skip_read" + const val downloadBadge = "display_download_badge" @Deprecated("Use the preferences of the source") diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index ee5b0b39e5..58ad2b0aff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -43,6 +43,8 @@ class PreferencesHelper(val context: Context) { fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true) + fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false) + fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, 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 skipRead() = prefs.getBoolean(Keys.skipRead, false) + fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE) fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index 73fa15c550..14558d1f1b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt @@ -4,6 +4,7 @@ import android.content.Context import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist +import eu.kanade.tachiyomi.data.track.shikomori.Shikomori class TrackManager(private val context: Context) { @@ -11,6 +12,7 @@ class TrackManager(private val context: Context) { const val MYANIMELIST = 1 const val ANILIST = 2 const val KITSU = 3 + const val SHIKOMORI = 4 } val myAnimeList = Myanimelist(context, MYANIMELIST) @@ -19,7 +21,9 @@ class TrackManager(private val context: Context) { 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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index f871db2b2e..a83e8b9ffb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -7,9 +7,9 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch +import okhttp3.HttpUrl import rx.Completable import rx.Observable -import java.net.URI 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() { super.logout() preferences.trackToken(this).delete() - networkService.cookies.remove(URI(BASE_URL)) + networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!) } override val isLogged: Boolean get() = !getUsername().isEmpty() && !getPassword().isEmpty() && - checkCookies(URI(BASE_URL)) && + checkCookies() && !getCSRF().isEmpty() private fun getCSRF(): String = preferences.trackToken(this).getOrDefault() private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) - private fun checkCookies(uri: URI): Boolean { + private fun checkCookies(): Boolean { var ckCount = 0 - - for (ck in networkService.cookies.get(uri)) { + val url = HttpUrl.parse(BASE_URL)!! + for (ck in networkService.cookieManager.get(url)) { if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE) ckCount++ } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/OAuth.kt new file mode 100644 index 0000000000..ad6adc18a4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/OAuth.kt @@ -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) +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/Shikomori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/Shikomori.kt new file mode 100644 index 0000000000..83fee74cf4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/Shikomori.kt @@ -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 { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + override fun add(track: Track): Observable { + return api.addLibManga(track, getUsername()) + } + + override fun update(track: Track): Observable { + 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 { + 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> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + 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 { + 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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriApi.kt new file mode 100644 index 0000000000..2df1eae635 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriApi.kt @@ -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 { + 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 = addLibManga(track, user_id) + + fun search(search: String): Observable> { + 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 { + 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 { + 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()) + + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriInterceptor.kt new file mode 100644 index 0000000000..e46e7cfb4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriModels.kt new file mode 100644 index 0000000000..d66f206495 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriModels.kt @@ -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") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt new file mode 100644 index 0000000000..0795b5e5d6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt @@ -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) { + 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 { + return get(url) + } + + fun get(url: HttpUrl): List { + 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() + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt index 641cdd9602..a3f4283a11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt @@ -1,41 +1,52 @@ package eu.kanade.tachiyomi.network -import com.squareup.duktape.Duktape -import okhttp3.* +import android.annotation.SuppressLint +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.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit -class CloudflareInterceptor : 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+'([^']+)';""") +class CloudflareInterceptor(private val context: Context) : Interceptor { private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") - private interface IBase64 { - fun decode(input: String): String - } + private val handler = Handler(Looper.getMainLooper()) - private val b64: IBase64 = object : IBase64 { - override fun decode(input: String): String { - return okio.ByteString.decodeBase64(input)!!.utf8() + /** + * When this is called, it initializes the WebView if it wasn't already. We use this to avoid + * blocking the main thread too much. If used too often we could consider moving it to the + * Application class. + */ + private val initWebView by lazy { + if (Build.VERSION.SDK_INT >= 17) { + WebSettings.getDefaultUserAgent(context) + } else { + null } } @Synchronized override fun intercept(chain: Interceptor.Chain): Response { + initWebView + val response = chain.proceed(chain.request()) // Check if Cloudflare anti-bot is on if (response.code() == 503 && response.header("Server") in serverCheck) { - return try { - chain.proceed(resolveChallenge(response)) + try { + response.close() + val solutionRequest = resolveWithWebView(chain.request()) + return chain.proceed(solutionRequest) } catch (e: Exception) { // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // we don't crash the entire app @@ -46,65 +57,98 @@ class CloudflareInterceptor : Interceptor { return response } - private fun resolveChallenge(response: Response): Request { - Duktape.create().use { duktape -> - val originalRequest = response.request() - val url = originalRequest.url() - val domain = url.host() - val content = response.body()!!.string() + private fun isChallengeSolutionUrl(url: String): Boolean { + return "chk_jschl" in url + } - // CloudFlare requires waiting 4 seconds before resolving the challenge - Thread.sleep(4000) + @SuppressLint("SetJavaScriptEnabled") + 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 - val challenge = challengePattern.find(content)?.groups?.get(1)?.value - val pass = passPattern.find(content)?.groups?.get(1)?.value - val s = sPattern.find(content)?.groups?.get(1)?.value + var webView: WebView? = null + var solutionUrl: String? = null + var challengeFound = false - // If `k` is null, it uses old methods. - val k = kPattern.find(content)?.groups?.get(1)?.value ?: "" - val innerHTMLValue = Regex("""(.*)""") - .find(content)?.groups?.get(3)?.value ?: "" + val origRequestUrl = request.url().toString() + val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" } - if (operation == null || challenge == null || pass == null || s == null) { - throw Exception("Failed resolving Cloudflare challenge") + handler.post { + 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() + } + } + } } - - // 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()) + webView?.loadUrl(origRequestUrl, headers) } + + // 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) }""" - } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index 5e93894830..275dca17dc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -2,11 +2,7 @@ package eu.kanade.tachiyomi.network import android.content.Context import android.os.Build -import okhttp3.Cache -import okhttp3.CipherSuite -import okhttp3.ConnectionSpec -import okhttp3.OkHttpClient -import okhttp3.TlsVersion +import okhttp3.* import java.io.File import java.io.IOException import java.net.InetAddress @@ -15,11 +11,7 @@ import java.net.UnknownHostException import java.security.KeyManagementException import java.security.KeyStore import java.security.NoSuchAlgorithmException -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocket -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager +import javax.net.ssl.* class NetworkHelper(context: Context) { @@ -27,7 +19,7 @@ class NetworkHelper(context: Context) { private val cacheSize = 5L * 1024 * 1024 // 5 MiB - private val cookieManager = PersistentCookieJar(context) + val cookieManager = AndroidCookieJar(context) val client = OkHttpClient.Builder() .cookieJar(cookieManager) @@ -36,12 +28,9 @@ class NetworkHelper(context: Context) { .build() val cloudflareClient = client.newBuilder() - .addInterceptor(CloudflareInterceptor()) + .addInterceptor(CloudflareInterceptor(context)) .build() - val cookies: PersistentCookieStore - get() = cookieManager.store - private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { return this diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieJar.kt b/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieJar.kt deleted file mode 100644 index fda9799789..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieJar.kt +++ /dev/null @@ -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) { - store.addAll(url, cookies) - } - - override fun loadForRequest(url: HttpUrl): List { - return store.get(url) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieStore.kt b/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieStore.kt deleted file mode 100644 index ca854bb72f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/network/PersistentCookieStore.kt +++ /dev/null @@ -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>() - 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 - 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) { - 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 { - return cookieMap[url].orEmpty().filter { !it.hasExpired() } - } - - private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt() - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index d96b222c29..63d07429b0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.main import android.animation.ObjectAnimator +import android.app.SearchManager import android.content.Intent import android.graphics.Color 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.controller.* 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.extension.ExtensionController import eu.kanade.tachiyomi.ui.library.LibraryController @@ -158,6 +160,16 @@ class MainActivity : BaseActivity() { 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 } return true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt index cb9b091faf..f2a0f412ef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt @@ -15,12 +15,7 @@ import android.support.customtabs.CustomTabsIntent import android.support.v4.content.pm.ShortcutInfoCompat import android.support.v4.content.pm.ShortcutManagerCompat import android.support.v4.graphics.drawable.IconCompat -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup +import android.view.* import android.widget.Toast import com.afollestad.materialdialogs.MaterialDialog import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -138,6 +133,7 @@ class MangaInfoController : NucleusController(), override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_open_in_browser -> openInBrowser() + R.id.action_open_in_web_view -> openInWebView() R.id.action_share -> shareManga() R.id.action_add_to_home_screen -> addToHomeScreen() else -> return super.onOptionsItemSelected(item) @@ -302,6 +298,19 @@ class MangaInfoController : NucleusController(), } } + 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. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaWebViewController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaWebViewController.kt new file mode 100644 index 0000000000..15a85a1080 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaWebViewController.kt @@ -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() + + 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" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index deae549a57..ffa78615ce 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.graphics.Bitmap import android.graphics.Color import android.os.Build import android.os.Bundle @@ -13,6 +14,7 @@ import android.view.* import android.view.animation.Animation import android.view.animation.AnimationUtils import android.widget.SeekBar +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga @@ -558,6 +560,9 @@ class ReaderActivity : BaseRxActivity() { subscriptions += preferences.showPageNumber().asObservable() .subscribe { setPageNumberVisibility(it) } + subscriptions += preferences.trueColor().asObservable() + .subscribe { setTrueColor(it) } + subscriptions += preferences.fullscreen().asObservable() .subscribe { setFullscreen(it) } @@ -614,6 +619,16 @@ class ReaderActivity : BaseRxActivity() { 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]. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index ab8b56ee83..fbb2f4bbfe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -32,7 +32,7 @@ import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File -import java.util.Date +import java.util.* import java.util.concurrent.TimeUnit /** @@ -84,12 +84,25 @@ class ReaderPresenter( private val chapterList by lazy { val manga = manga!! val dbChapters = db.getChapters(manga).executeAsBlocking() + 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) { - Manga.SORTING_SOURCE -> ChapterLoadBySource().get(dbChapters) - Manga.SORTING_NUMBER -> ChapterLoadByNumber().get(dbChapters, selectedChapter) + Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader) + Manga.SORTING_NUMBER -> ChapterLoadByNumber().get(chaptersForReader, selectedChapter) else -> error("Unknown sorting method") }.map(::ReaderChapter) } @@ -165,12 +178,12 @@ class ReaderPresenter( if (!needsInit()) return db.getManga(mangaId).asRxObservable() - .first() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { init(it, initialChapterId) } - .subscribeFirst({ _, _ -> - // Ignore onNext event - }, ReaderActivity::setInitialChapterError) + .first() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { init(it, initialChapterId) } + .subscribeFirst({ _, _ -> + // Ignore onNext event + }, ReaderActivity::setInitialChapterError) } /** @@ -193,13 +206,13 @@ class ReaderPresenter( // Read chapterList from an io thread because it's retrieved lazily and would block main. activeChapterSubscription?.unsubscribe() activeChapterSubscription = Observable - .fromCallable { chapterList.first { chapterId == it.chapter.id } } - .flatMap { getLoadObservable(loader!!, it) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ _, _ -> - // Ignore onNext event - }, ReaderActivity::setInitialChapterError) + .fromCallable { chapterList.first { chapterId == it.chapter.id } } + .flatMap { getLoadObservable(loader!!, it) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ _, _ -> + // Ignore onNext event + }, ReaderActivity::setInitialChapterError) } /** @@ -214,23 +227,23 @@ class ReaderPresenter( chapter: ReaderChapter ): Observable { return loader.loadChapter(chapter) - .andThen(Observable.fromCallable { - val chapterPos = chapterList.indexOf(chapter) + .andThen(Observable.fromCallable { + val chapterPos = chapterList.indexOf(chapter) - ViewerChapters(chapter, - chapterList.getOrNull(chapterPos - 1), - chapterList.getOrNull(chapterPos + 1)) - }) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { newChapters -> - val oldChapters = viewerChaptersRelay.value + ViewerChapters(chapter, + chapterList.getOrNull(chapterPos - 1), + chapterList.getOrNull(chapterPos + 1)) + }) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { newChapters -> + val oldChapters = viewerChaptersRelay.value - // Add new references first to avoid unnecessary recycling - newChapters.ref() - oldChapters?.unref() + // Add new references first to avoid unnecessary recycling + newChapters.ref() + oldChapters?.unref() - viewerChaptersRelay.call(newChapters) - } + viewerChaptersRelay.call(newChapters) + } } /** @@ -244,10 +257,10 @@ class ReaderPresenter( activeChapterSubscription?.unsubscribe() activeChapterSubscription = getLoadObservable(loader, chapter) - .toCompletable() - .onErrorComplete() - .subscribe() - .also(::add) + .toCompletable() + .onErrorComplete() + .subscribe() + .also(::add) } /** @@ -262,13 +275,13 @@ class ReaderPresenter( activeChapterSubscription?.unsubscribe() activeChapterSubscription = getLoadObservable(loader, chapter) - .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } - .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } - .subscribeFirst({ view, _ -> - view.moveToPageIndex(0) - }, { _, _ -> - // Ignore onError event, viewers handle that state - }) + .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } + .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } + .subscribeFirst({ view, _ -> + view.moveToPageIndex(0) + }, { _, _ -> + // Ignore onError event, viewers handle that state + }) } /** @@ -285,12 +298,12 @@ class ReaderPresenter( val loader = loader ?: return loader.loadChapter(chapter) - .observeOn(AndroidSchedulers.mainThread()) - // Update current chapters whenever a chapter is preloaded - .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) } - .onErrorComplete() - .subscribe() - .also(::add) + .observeOn(AndroidSchedulers.mainThread()) + // Update current chapters whenever a chapter is preloaded + .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) } + .onErrorComplete() + .subscribe() + .also(::add) } /** @@ -331,9 +344,9 @@ class ReaderPresenter( */ private fun saveChapterProgress(chapter: ReaderChapter) { db.updateChapterProgress(chapter.chapter).asRxCompletable() - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -342,9 +355,9 @@ class ReaderPresenter( private fun saveChapterHistory(chapter: ReaderChapter) { val history = History.create(chapter.chapter).apply { last_read = Date().time } db.updateHistoryLastRead(history).asRxCompletable() - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -394,18 +407,18 @@ class ReaderPresenter( db.updateMangaViewer(manga).executeAsBlocking() Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - val currChapters = viewerChaptersRelay.value - if (currChapters != null) { - // Save current page - val currChapter = currChapters.currChapter - currChapter.requestedPage = currChapter.chapter.last_page_read + .subscribeFirst({ view, _ -> + val currChapters = viewerChaptersRelay.value + if (currChapters != null) { + // Save current page + val currChapter = currChapters.currChapter + currChapter.requestedPage = currChapter.chapter.last_page_read - // Emit manga and chapters to the new viewer - view.setManga(manga) - view.setChapters(currChapters) - } - }) + // Emit manga and chapters to the new viewer + view.setManga(manga) + view.setChapters(currChapters) + } + }) } /** @@ -446,22 +459,22 @@ class ReaderPresenter( // Pictures directory. val destDir = File(Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + "Tachiyomi") + File.separator + Environment.DIRECTORY_PICTURES + + File.separator + "Tachiyomi") // Copy file in background. Observable.fromCallable { saveImage(page, destDir, manga) } - .doOnNext { file -> - DiskUtil.scanMedia(context, file) - notifier.onComplete(file) - } - .doOnError { notifier.onError(it.message) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, - { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) } - ) + .doOnNext { file -> + DiskUtil.scanMedia(context, file) + notifier.onComplete(file) + } + .doOnError { notifier.onError(it.message) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, + { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) } + ) } /** @@ -478,14 +491,14 @@ class ReaderPresenter( val destDir = File(context.cacheDir, "shared_image") - Observable.fromCallable { destDir.delete() } // Keep only the last shared file - .map { saveImage(page, destDir, manga) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, file -> view.onShareImageResult(file) }, - { view, error -> /* Empty */ } - ) + Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file + .map { saveImage(page, destDir, manga) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, file -> view.onShareImageResult(file) }, + { view, error -> /* Empty */ } + ) } /** @@ -497,28 +510,28 @@ class ReaderPresenter( val stream = page.stream ?: return Observable - .fromCallable { - if (manga.source == LocalSource.ID) { - val context = Injekt.get() - LocalSource.updateCover(context, manga, stream()) - 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()) + .fromCallable { + if (manga.source == LocalSource.ID) { + val context = Injekt.get() + LocalSource.updateCover(context, manga, stream()) + R.string.cover_updated SetAsCoverResult.Success } 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()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, result -> view.onSetAsCoverResult(result) }, - { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } - ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, result -> view.onSetAsCoverResult(result) }, + { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } + ) } /** @@ -559,26 +572,26 @@ class ReaderPresenter( val trackManager = Injekt.get() db.getTracks(manga).asRxSingle() - .flatMapCompletable { trackList -> - Completable.concat(trackList.map { track -> - val service = trackManager.getService(track.sync_id) - if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) { - track.last_chapter_read = lastChapterRead + .flatMapCompletable { trackList -> + Completable.concat(trackList.map { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) { + track.last_chapter_read = lastChapterRead - // We wan't these to execute even if the presenter is destroyed and leaks - // for a while. The view can still be garbage collected. - Observable.defer { service.update(track) } - .map { db.insertTrack(track).executeAsBlocking() } - .toCompletable() - .onErrorComplete() - } else { - Completable.complete() - } - }) - } - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + // We wan't these to execute even if the presenter is destroyed and leaks + // for a while. The view can still be garbage collected. + Observable.defer { service.update(track) } + .map { db.insertTrack(track).executeAsBlocking() } + .toCompletable() + .onErrorComplete() + } else { + Completable.complete() + } + }) + } + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -594,19 +607,19 @@ class ReaderPresenter( if (removeAfterReadSlots == -1) return Completable - .fromCallable { - // Position of the read chapter - val position = chapterList.indexOf(chapter) + .fromCallable { + // Position of the read chapter + val position = chapterList.indexOf(chapter) - // Retrieve chapter to delete according to preference - val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots) - if (chapterToDelete != null) { - downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga) + // Retrieve chapter to delete according to preference + val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots) + if (chapterToDelete != null) { + downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga) + } } - } - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -615,9 +628,9 @@ class ReaderPresenter( */ private fun deletePendingChapters() { Completable.fromCallable { downloadManager.deletePendingChapters() } - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 07f69eddee..3ad78247a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -43,7 +43,7 @@ class SettingsAdvancedController : SettingsController() { titleRes = R.string.pref_clear_cookies onClick { - network.cookies.removeAll() + network.cookieManager.removeAll() activity?.toast(R.string.cookies_cleared) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt index 79b08b243d..4d9425bf79 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.ui.setting +import android.os.Build import android.support.v7.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys @@ -62,6 +63,11 @@ class SettingsReaderController : SettingsController() { defaultValue = "500" summary = "%s" } + switchPreference { + key = Keys.skipRead + titleRes = R.string.pref_skip_read_chapters + defaultValue = false + } switchPreference { key = Keys.fullscreen titleRes = R.string.pref_fullscreen @@ -77,6 +83,13 @@ class SettingsReaderController : SettingsController() { titleRes = R.string.pref_show_page_number defaultValue = true } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + switchPreference { + key = Keys.trueColor + titleRes = R.string.pref_true_color + defaultValue = false + } + } preferenceCategory { titleRes = R.string.pager_viewer diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 699c253d23..250289cc1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService 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.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog @@ -53,6 +54,15 @@ class SettingsTrackingController : SettingsController(), 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) // Manually refresh anilist holder updatePreference(trackManager.aniList.id) + updatePreference(trackManager.shikomori.id) } private fun updatePreference(id: Int) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ShikomoriLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ShikomoriLoginActivity.kt new file mode 100644 index 0000000000..6c3ba6f839 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ShikomoriLoginActivity.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/WebViewClientCompat.kt b/app/src/main/java/eu/kanade/tachiyomi/util/WebViewClientCompat.kt new file mode 100644 index 0000000000..977dca5e6d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/WebViewClientCompat.kt @@ -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) + } + +} diff --git a/app/src/main/res/drawable-xxxhdpi/shikomori.png b/app/src/main/res/drawable-xxxhdpi/shikomori.png new file mode 100644 index 0000000000..9859d16e6a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/shikomori.png differ diff --git a/app/src/main/res/layout/manga_info_web_controller.xml b/app/src/main/res/layout/manga_info_web_controller.xml new file mode 100644 index 0000000000..6d52f5e22a --- /dev/null +++ b/app/src/main/res/layout/manga_info_web_controller.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/layout/reader_settings_sheet.xml b/app/src/main/res/layout/reader_settings_sheet.xml index f3b402baa3..d28155d704 100644 --- a/app/src/main/res/layout/reader_settings_sheet.xml +++ b/app/src/main/res/layout/reader_settings_sheet.xml @@ -105,6 +105,17 @@ android:textColor="?android:attr/textColorSecondary" app:layout_constraintTop_toBottomOf="@id/background_color" /> + + + app:layout_constraintTop_toBottomOf="@id/true_color" /> + + - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bd4fc5a0d..bd65d77d58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,7 @@ Resume Move Open in browser + Open in web view Add to home screen Change display mode Display @@ -173,10 +174,12 @@ Page transitions Double tap animation speed Show page number + 32-bit color Crop borders Use custom brightness Use custom color filter Keep screen on + Skip chapters marked read Navigation Volume keys Invert volume keys diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000..81c96476a7 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/searchable.xml b/app/src/main/res/xml/searchable.xml new file mode 100644 index 0000000000..f224a1c83c --- /dev/null +++ b/app/src/main/res/xml/searchable.xml @@ -0,0 +1,5 @@ + + + \ No newline at end of file