From a62a7d533000dee0f83ca3ed2e2fa0826c8530e1 Mon Sep 17 00:00:00 2001 From: Pavka Date: Wed, 3 Apr 2019 11:14:37 +0300 Subject: [PATCH] Feature/shikomori track (#1905) * Add shikomori track * Fix char 'M' * Fix date in search --- app/src/main/AndroidManifest.xml | 15 ++ .../tachiyomi/data/track/TrackManager.kt | 6 +- .../tachiyomi/data/track/shikomori/OAuth.kt | 13 ++ .../data/track/shikomori/Shikomori.kt | 138 +++++++++++++ .../data/track/shikomori/ShikomoriApi.kt | 189 ++++++++++++++++++ .../track/shikomori/ShikomoriInterceptor.kt | 43 ++++ .../data/track/shikomori/ShikomoriModels.kt | 24 +++ .../ui/setting/SettingsTrackingController.kt | 11 + .../ui/setting/ShikomoriLoginActivity.kt | 50 +++++ .../main/res/drawable-xxxhdpi/shikomori.png | Bin 0 -> 8847 bytes 10 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/OAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/Shikomori.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriInterceptor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/ShikomoriModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/ShikomoriLoginActivity.kt create mode 100644 app/src/main/res/drawable-xxxhdpi/shikomori.png diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c8f6aea2..ac0eb94e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,6 +52,21 @@ android:scheme="tachiyomi" /> + + + + + + + + + + + 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 73fa15c55..14558d1f1 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/shikomori/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikomori/OAuth.kt new file mode 100644 index 000000000..ad6adc18a --- /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 000000000..83fee74cf --- /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 000000000..2df1eae63 --- /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 000000000..e46e7cfb4 --- /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 000000000..d66f20649 --- /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/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 699c253d2..250289cc1 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 000000000..6c3ba6f83 --- /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/res/drawable-xxxhdpi/shikomori.png b/app/src/main/res/drawable-xxxhdpi/shikomori.png new file mode 100644 index 0000000000000000000000000000000000000000..9859d16e6a7cb27d56117bf3b27bbd123c32c2ea GIT binary patch literal 8847 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