From cb921436138acd639e5332cc57b03daeba8cfb9a Mon Sep 17 00:00:00 2001 From: len Date: Sun, 18 Sep 2016 11:50:52 +0200 Subject: [PATCH] Merge anilist backend --- .../data/mangasync/MangaSyncManager.kt | 5 + .../data/mangasync/anilist/Anilist.kt | 132 ++++++++++++++++++ .../data/mangasync/anilist/AnilistApi.kt | 89 ++++++++++++ .../mangasync/anilist/AnilistInterceptor.kt | 61 ++++++++ .../data/mangasync/anilist/model/ALManga.kt | 17 +++ .../mangasync/anilist/model/ALUserLists.kt | 6 + .../mangasync/anilist/model/ALUserManga.kt | 29 ++++ .../data/mangasync/anilist/model/OAuth.kt | 11 ++ .../ui/setting/AnilistLoginActivity.kt | 49 +++++++ .../ui/setting/SettingsSyncFragment.kt | 55 ++++++-- 10 files changed, 440 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/Anilist.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistInterceptor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserLists.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/OAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt index 220e751404..9a0093783f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt @@ -1,16 +1,21 @@ package eu.kanade.tachiyomi.data.mangasync import android.content.Context +import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList class MangaSyncManager(private val context: Context) { companion object { const val MYANIMELIST = 1 + const val ANILIST = 2 } val myAnimeList = MyAnimeList(context, MYANIMELIST) + val aniList = Anilist(context, ANILIST) + + // TODO enable anilist val services = listOf(myAnimeList) fun getService(id: Int) = services.find { it.id == id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/Anilist.kt new file mode 100644 index 0000000000..dff6f530f2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/Anilist.kt @@ -0,0 +1,132 @@ +package eu.kanade.tachiyomi.data.mangasync.anilist + +import android.content.Context +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.MangaSync +import eu.kanade.tachiyomi.data.mangasync.MangaSyncService +import rx.Completable +import rx.Observable +import timber.log.Timber + +class Anilist(private val context: Context, id: Int) : MangaSyncService(context, id) { + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLAN_TO_READ = 5 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + } + + override val name = "AniList" + + private val interceptor by lazy { AnilistInterceptor(getPassword()) } + + private val api by lazy { + AnilistApi.createService(networkService.client.newBuilder() + .addInterceptor(interceptor) + .build()) + } + + override fun login(username: String, password: String) = login(password) + + fun login(authCode: String): Completable { + // Create a new api with the default client to avoid request interceptions. + return AnilistApi.createService(client) + // Request the access token from the API with the authorization code. + .requestAccessToken(authCode) + // Save the token in the interceptor. + .doOnNext { interceptor.setAuth(it) } + // Obtain the authenticated user from the API. + .zipWith(api.getCurrentUser().map { it["id"].toString() }) + { oauth, user -> Pair(user, oauth.refresh_token!!) } + // Save service credentials (username and refresh token). + .doOnNext { saveCredentials(it.first, it.second) } + // Logout on any error. + .doOnError { logout() } + .toCompletable() + } + + override fun logout() { + super.logout() + interceptor.setAuth(null) + } + + fun search(query: String): Observable> { + return api.search(query, 1) + .flatMap { Observable.from(it) } + .filter { it.type != "Novel" } + .map { it.toMangaSync() } + .toList() + } + + fun getList(): Observable> { + return api.getList(getUsername()) + .flatMap { Observable.from(it.flatten()) } + .map { it.toMangaSync() } + .toList() + } + + override fun add(manga: MangaSync): Observable { + return api.addManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(), + manga.score.toInt()) + .doOnNext { it.body().close() } + .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") } + .doOnError { Timber.e(it, it.message) } + .map { manga } + } + + override fun update(manga: MangaSync): Observable { + if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) { + manga.status = COMPLETED + } + return api.updateManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(), + manga.score.toInt()) + .doOnNext { it.body().close() } + .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") } + .doOnError { Timber.e(it, it.message) } + .map { manga } + } + + override fun bind(manga: MangaSync): Observable { + return getList() + .flatMap { userlist -> + manga.sync_id = id + val mangaFromList = userlist.find { it.remote_id == manga.remote_id } + if (mangaFromList != null) { + manga.copyPersonalFrom(mangaFromList) + update(manga) + } else { + // Set default fields if it's not found in the list + manga.score = DEFAULT_SCORE.toFloat() + manga.status = DEFAULT_STATUS + add(manga) + } + } + } + + 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) + PLAN_TO_READ -> getString(R.string.plan_to_read) + else -> "" + } + } + + private fun MangaSync.getAnilistStatus() = when (status) { + READING -> "reading" + COMPLETED -> "completed" + ON_HOLD -> "on-hold" + DROPPED -> "dropped" + PLAN_TO_READ -> "plan to read" + else -> throw NotImplementedError("Unknown status") + } + +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistApi.kt new file mode 100644 index 0000000000..6280562be7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistApi.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.data.mangasync.anilist + +import android.net.Uri +import com.google.gson.JsonObject +import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALManga +import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALUserLists +import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth +import eu.kanade.tachiyomi.data.network.POST +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.* +import rx.Observable + +interface AnilistApi { + + companion object { + private const val clientId = "tachiyomi-hrtje" + private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C" + private const val clientUrl = "tachiyomi://anilist-auth" + private const val baseUrl = "https://anilist.co/api/" + + fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon() + .appendQueryParameter("grant_type", "authorization_code") + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("redirect_uri", clientUrl) + .appendQueryParameter("response_type", "code") + .build() + + fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token", + body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("refresh_token", token) + .build()) + + fun createService(client: OkHttpClient) = Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) + .build() + .create(AnilistApi::class.java) + + } + + @FormUrlEncoded + @POST("auth/access_token") + fun requestAccessToken( + @Field("code") code: String, + @Field("grant_type") grant_type: String = "authorization_code", + @Field("client_id") client_id: String = clientId, + @Field("client_secret") client_secret: String = clientSecret, + @Field("redirect_uri") redirect_uri: String = clientUrl) + : Observable + + @GET("user") + fun getCurrentUser(): Observable + + @GET("manga/search/{query}") + fun search(@Path("query") query: String, @Query("page") page: Int): Observable> + + @GET("user/{username}/mangalist") + fun getList(@Path("username") username: String): Observable + + @FormUrlEncoded + @PUT("mangalist") + fun addManga( + @Field("id") id: Int, + @Field("chapters_read") chapters_read: Int, + @Field("list_status") list_status: String, + @Field("score_raw") score_raw: Int) + : Observable> + + @FormUrlEncoded + @PUT("mangalist") + fun updateManga( + @Field("id") id: Int, + @Field("chapters_read") chapters_read: Int, + @Field("list_status") list_status: String, + @Field("score_raw") score_raw: Int) + : Observable> + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistInterceptor.kt new file mode 100644 index 0000000000..a7a1232f12 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistInterceptor.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.data.mangasync.anilist + +import com.google.gson.Gson +import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth +import okhttp3.Interceptor +import okhttp3.Response + +class AnilistInterceptor(private var refreshToken: String?) : Interceptor { + + /** + * OAuth object used for authenticated requests. + * + * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute + * before its original expiration date. + */ + private var oauth: OAuth? = null + set(value) { + field = value?.copy(expires = value.expires * 1000 - 60 * 1000) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (refreshToken.isNullOrEmpty()) { + throw Exception("Not authenticated with Anilist") + } + + // Refresh access token if null or expired. + if (oauth == null || oauth!!.isExpired()) { + val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!)) + oauth = if (response.isSuccessful) { + Gson().fromJson(response.body().string(), OAuth::class.java) + } else { + response.close() + null + } + } + + // Throw on null auth. + if (oauth == null) { + throw Exception("Access token wasn't refreshed") + } + + // Add the authorization header to the original request. + val authRequest = originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .build() + + return chain.proceed(authRequest) + } + + /** + * Called when the user authenticates with Anilist for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + refreshToken = oauth?.refresh_token + this.oauth = oauth + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALManga.kt new file mode 100644 index 0000000000..7b4a64ebb8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALManga.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.data.mangasync.anilist.model + +import eu.kanade.tachiyomi.data.database.models.MangaSync +import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager + +data class ALManga( + val id: Int, + val title_romaji: String, + val type: String, + val total_chapters: Int) { + + fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply { + remote_id = this@ALManga.id + title = title_romaji + total_chapters = this@ALManga.total_chapters + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserLists.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserLists.kt new file mode 100644 index 0000000000..7caf7def52 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserLists.kt @@ -0,0 +1,6 @@ +package eu.kanade.tachiyomi.data.mangasync.anilist.model + +data class ALUserLists(val lists: Map>) { + + fun flatten() = lists.values.flatten() +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserManga.kt new file mode 100644 index 0000000000..406ed8e01f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserManga.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.data.mangasync.anilist.model + +import eu.kanade.tachiyomi.data.database.models.MangaSync +import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager +import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist + +data class ALUserManga( + val id: Int, + val list_status: String, + val score_raw: Int, + val chapters_read: Int, + val manga: ALManga) { + + fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply { + remote_id = manga.id + status = getMangaSyncStatus() + score = score_raw.toFloat() + last_chapter_read = chapters_read + } + + fun getMangaSyncStatus() = when (list_status) { + "reading" -> Anilist.READING + "completed" -> Anilist.COMPLETED + "on-hold" -> Anilist.ON_HOLD + "dropped" -> Anilist.DROPPED + "plan to read" -> Anilist.PLAN_TO_READ + else -> throw NotImplementedError("Unknown status") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/OAuth.kt new file mode 100644 index 0000000000..05b21a83be --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/OAuth.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.data.mangasync.anilist.model + +data class OAuth( + val access_token: String, + val token_type: String, + val expires: Long, + val expires_in: Long, + val refresh_token: String?) { + + fun isExpired() = System.currentTimeMillis() > expires +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt new file mode 100644 index 0000000000..c7209e3703 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt @@ -0,0 +1,49 @@ +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.mangasync.MangaSyncManager +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import uy.kohesive.injekt.injectLazy + +class AnilistLoginActivity : AppCompatActivity() { + + private val syncManager: MangaSyncManager 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) { + syncManager.aniList.login(code) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ + returnToSettings() + }, { error -> + returnToSettings() + }) + } else { + syncManager.aniList.logout() + returnToSettings() + } + } + + private fun returnToSettings() { + finish() + + val intent = Intent(this, SettingsActivity::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/SettingsSyncFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSyncFragment.kt index 6e6c595b57..8cf1fcdd12 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSyncFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSyncFragment.kt @@ -6,6 +6,7 @@ import android.support.v7.preference.PreferenceCategory import android.support.v7.preference.XpPreferenceFragment import android.view.View import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager +import eu.kanade.tachiyomi.data.mangasync.MangaSyncService import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.MangaSyncLoginDialog @@ -32,30 +33,56 @@ class SettingsSyncFragment : SettingsFragment() { override fun onViewCreated(view: View, savedState: Bundle?) { super.onViewCreated(view, savedState) - val themedContext = preferenceManager.context + registerService(syncManager.myAnimeList) - for (sync in syncManager.services) { - val pref = LoginPreference(themedContext).apply { - key = preferences.keys.syncUsername(sync.id) - title = sync.name +// registerService(syncManager.aniList) { +// val intent = CustomTabsIntent.Builder() +// .setToolbarColor(activity.theme.getResourceColor(R.attr.colorPrimary)) +// .build() +// intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) +// intent.launchUrl(activity, AnilistApi.authUrl()) +// } + } - setOnPreferenceClickListener { - val fragment = MangaSyncLoginDialog.newInstance(sync) - fragment.setTargetFragment(this@SettingsSyncFragment, SYNC_CHANGE_REQUEST) - fragment.show(fragmentManager, null) - true - } + private fun registerService( + service: T, + onPreferenceClick: (T) -> Unit = defaultOnPreferenceClick) { + + LoginPreference(preferenceManager.context).apply { + key = preferences.keys.syncUsername(service.id) + title = service.name + + setOnPreferenceClickListener { + onPreferenceClick(service) + true } - syncCategory.addPreference(pref) + syncCategory.addPreference(this) } } + private val defaultOnPreferenceClick: (MangaSyncService) -> Unit + get() = { + val fragment = MangaSyncLoginDialog.newInstance(it) + fragment.setTargetFragment(this, SYNC_CHANGE_REQUEST) + fragment.show(fragmentManager, null) + } + + override fun onResume() { + super.onResume() + // Manually refresh anilist holder +// updatePreference(syncManager.aniList.id) + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == SYNC_CHANGE_REQUEST) { - val pref = findPreference(preferences.keys.syncUsername(resultCode)) as? LoginPreference - pref?.notifyChanged() + updatePreference(resultCode) } } + private fun updatePreference(id: Int) { + val pref = findPreference(preferences.keys.syncUsername(id)) as? LoginPreference + pref?.notifyChanged() + } + }