소스 검색

Feature/shikomori track (#1905)

* Add shikomori track

* Fix char 'M'

* Fix date in search
Pavka 6 년 전
부모
커밋
a62a7d5330

+ 15 - 0
app/src/main/AndroidManifest.xml

@@ -52,6 +52,21 @@
                     android:scheme="tachiyomi" />
             </intent-filter>
         </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
             android:name=".extension.util.ExtensionInstallActivity"
             android:theme="@android:style/Theme.Translucent.NoTitleBar"/>

+ 5 - 1
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 }
 

+ 13 - 0
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)
+}
+

+ 138 - 0
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<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)
+    }
+}

+ 189 - 0
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<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())
+
+    }
+
+}

+ 43 - 0
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)
+    }
+}

+ 24 - 0
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")
+}

+ 11 - 0
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) {

+ 50 - 0
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)
+    }
+
+}

BIN
app/src/main/res/drawable-xxxhdpi/shikomori.png