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 000000000..9859d16e6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/shikomori.png differ