diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eff4c70578..e13d89ba7c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -75,6 +75,20 @@ android:scheme="tachiyomi" /> + + + + + + + + + + { + 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) + } + + 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) + } + + override fun bind(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + api.findLibManga(track).flatMap { remoteTrack -> + if (remoteTrack != null && it != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + track.status = remoteTrack.status + track.last_chapter_read = remoteTrack.last_chapter_read + 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) + update(track) + } + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + track.copyPersonalFrom(it!!) + api.findLibManga(track) + .map { remoteTrack -> + if (remoteTrack != null) { + track.total_chapters = remoteTrack.total_chapters + track.status = remoteTrack.status + } + track + } + } + } + + companion object { + const val READING = 3 + const val COMPLETED = 2 + const val ON_HOLD = 4 + const val DROPPED = 5 + const val PLANNING = 1 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + } + + override val name = "Bangumi" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { BangumiInterceptor(this, gson) } + + private val api by lazy { BangumiApi(client, interceptor) } + + override fun getLogo() = R.drawable.bangumi + + override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) + } + + 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) + 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) { + saveCredentials(oauth.user_id.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/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt new file mode 100644 index 0000000000..661c265236 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -0,0 +1,208 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +import android.net.Uri +import com.github.salomonbrys.kotson.array +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.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import okhttp3.CacheControl +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.net.URLEncoder + +class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) { + + private val gson: Gson by injectLazy() + private val parser = JsonParser() + private val authClient = client.newBuilder().addInterceptor(interceptor).build() + + fun addLibManga(track: Track): Observable { + val body = FormBody.Builder() + .add("rating", track.score.toInt().toString()) + .add("status", track.toBangumiStatus()) + .build() + val request = Request.Builder() + .url("$apiUrl/collection/${track.media_id}/update") + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { + track + } + } + + fun updateLibManga(track: Track): Observable { + // chapter update + val body = FormBody.Builder() + .add("watched_eps", track.last_chapter_read.toString()) + .build() + val request = Request.Builder() + .url("$apiUrl/subject/${track.media_id}/update/watched_eps") + .post(body) + .build() + + // read status update + val sbody = FormBody.Builder() + .add("status", track.toBangumiStatus()) + .build() + val srequest = Request.Builder() + .url("$apiUrl/collection/${track.media_id}/update") + .post(sbody) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { + track + }.flatMap { + authClient.newCall(srequest) + .asObservableSuccess() + .map { + track + } + } + } + + fun search(search: String): Observable> { + val url = Uri.parse( + "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() + .appendQueryParameter("max_results", "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).obj["list"]?.array + response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } + } + + } + + private fun jsonToSearch(obj: JsonObject): TrackSearch { + return TrackSearch.create(TrackManager.BANGUMI).apply { + media_id = obj["id"].asInt + title = obj["name_cn"].asString + cover_url = obj["images"].obj["common"].asString + summary = obj["name"].asString + tracking_url = obj["url"].asString + } + } + + private fun jsonToTrack(mangas: JsonObject): Track { + return Track.create(TrackManager.BANGUMI).apply { + title = mangas["name"].asString + media_id = mangas["id"].asInt + score = if (mangas["rating"] != null) + (if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f) + else 0f + status = Bangumi.DEFAULT_STATUS + tracking_url = mangas["url"].asString + } + } + + fun findLibManga(track: Track): Observable { + val urlMangas = "$apiUrl/subject/${track.media_id}" + val requestMangas = Request.Builder() + .url(urlMangas) + .get() + .build() + + return authClient.newCall(requestMangas) + .asObservableSuccess() + .map { netResponse -> + // get comic info + val responseBody = netResponse.body()?.string().orEmpty() + jsonToTrack(parser.parse(responseBody).obj) + } + } + + fun statusLibManga(track: Track): Observable { + val urlUserRead = "$apiUrl/collection/${track.media_id}" + val requestUserRead = Request.Builder() + .url(urlUserRead) + .cacheControl(CacheControl.FORCE_NETWORK) + .get() + .build() + + // todo get user readed chapter here + return authClient.newCall(requestUserRead) + .asObservableSuccess() + .map { netResponse -> + val resp = netResponse.body()?.string() + val coll = gson.fromJson(resp, Collection::class.java) + track.status = coll.status?.id!! + track.last_chapter_read = coll.ep_status!! + track + } + } + + 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 = "bgm10555cda0762e80ca" + private const val clientSecret = "8fff394a8627b4c388cbf349ec865775" + + private const val baseUrl = "https://bangumi.org" + private const val apiUrl = "https://api.bgm.tv" + private const val oauthUrl = "https://bgm.tv/oauth/access_token" + private const val loginUrl = "https://bgm.tv/oauth/authorize" + + private const val redirectUrl = "tachiyomi://bangumi-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("response_type", "code") + .appendQueryParameter("redirect_uri", redirectUrl) + .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) + .add("redirect_uri", redirectUrl) + .build()) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt new file mode 100644 index 0000000000..69565f447b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +import com.google.gson.Gson +import okhttp3.FormBody +import okhttp3.Interceptor +import okhttp3.Response + +class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor { + + /** + * OAuth object used for authenticated requests. + */ + private var oauth: OAuth? = bangumi.restoreToken() + + fun addTocken(tocken: String, oidFormBody: FormBody): FormBody { + val newFormBody = FormBody.Builder() + for (i in 0 until oidFormBody.size()) { + newFormBody.add(oidFormBody.name(i), oidFormBody.value(i)) + } + newFormBody.add("access_token", tocken) + return newFormBody.build() + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") + + if (currAuth.isExpired()) { + val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) + if (response.isSuccessful) { + newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java)) + } else { + response.close() + } + } + + var authRequest = if (originalRequest.method() == "GET") originalRequest.newBuilder() + .header("User-Agent", "Tachiyomi") + .url(originalRequest.url().newBuilder() + .addQueryParameter("access_token", currAuth.access_token).build()) + .build() else originalRequest.newBuilder() + .post(addTocken(currAuth.access_token, originalRequest.body() as FormBody)) + .header("User-Agent", "Tachiyomi") + .build() + + return chain.proceed(authRequest) + } + + fun newAuth(oauth: OAuth?) { + this.oauth = if (oauth == null) null else OAuth( + oauth.access_token, + oauth.token_type, + System.currentTimeMillis() / 1000, + oauth.expires_in, + oauth.refresh_token, + this.oauth?.user_id) + + bangumi.saveToken(oauth) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt new file mode 100644 index 0000000000..83b9ce3054 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +import eu.kanade.tachiyomi.data.database.models.Track + +fun Track.toBangumiStatus() = when (status) { + Bangumi.READING -> "do" + Bangumi.COMPLETED -> "collect" + Bangumi.ON_HOLD -> "on_hold" + Bangumi.DROPPED -> "dropped" + Bangumi.PLANNING -> "wish" + else -> throw NotImplementedError("Unknown status") +} + +fun toTrackStatus(status: String) = when (status) { + "do" -> Bangumi.READING + "collect" -> Bangumi.COMPLETED + "on_hold" -> Bangumi.ON_HOLD + "dropped" -> Bangumi.DROPPED + "wish" -> Bangumi.PLANNING + + else -> throw Exception("Unknown status") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt new file mode 100644 index 0000000000..732676bf31 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class Collection( + val `private`: Int? = 0, + val comment: String? = "", + val ep_status: Int? = 0, + val lasttouch: Int? = 0, + val rating: Int? = 0, + val status: Status? = Status(), + val tag: List? = listOf(), + val user: User? = User(), + val vol_status: Int? = 0 +) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt new file mode 100644 index 0000000000..68dc7e5c4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String?, + val user_id: Long? +) { + + // Access token refersh before expired + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) + +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt new file mode 100644 index 0000000000..78e22e882c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class Status( + val id: Int? = 0, + val name: String? = "", + val type: String? = "" +) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt new file mode 100644 index 0000000000..808e4860a6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class User( + val avatar: Avatar? = Avatar(), + val id: Int? = 0, + val nickname: String? = "", + val sign: String? = "", + val url: String? = "", + val usergroup: Int? = 0, + val username: String? = "" +) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt new file mode 100644 index 0000000000..5654b4efa4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.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 BangumiLoginActivity : 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.bangumi.login(code) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ + returnToSettings() + }, { + returnToSettings() + }) + } else { + trackManager.bangumi.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/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 2a8f1f73ac..55bc47059e 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 @@ -9,6 +9,7 @@ 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.shikimori.ShikimoriApi +import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.util.getResourceColor import eu.kanade.tachiyomi.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog @@ -63,6 +64,15 @@ class SettingsTrackingController : SettingsController(), tabsIntent.launchUrl(activity, ShikimoriApi.authUrl()) } } + trackPreference(trackManager.bangumi) { + onClick { + val tabsIntent = CustomTabsIntent.Builder() + .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) + .build() + tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + tabsIntent.launchUrl(activity, BangumiApi.authUrl()) + } + } } } diff --git a/app/src/main/res/drawable-xxxhdpi/bangumi.png b/app/src/main/res/drawable-xxxhdpi/bangumi.png new file mode 100644 index 0000000000..412a28ba1c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/bangumi.png differ