Commit 4b460ec9 authored by Christophe Henry's avatar Christophe Henry

Replace Fuel by Retrofit

parent 16649ec0
Pipeline #3683 passed with stage
in 0 seconds
......@@ -31,6 +31,7 @@ android {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = ["-Xallow-result-return-type"]
}
testOptions {
......@@ -102,9 +103,9 @@ spotless {
dependencies {
def appcompat_version = "1.1.0"
def activity_version = "1.1.0"
def fragment_version = "1.2.1"
def fragment_version = '1.2.2'
def lifecycle_version = "2.2.0"
def room_version = "2.2.3"
def room_version = '2.2.4'
def roomigrant_version = "0.1.7"
def fuel_version = "2.0.1"
def jackson_version = '2.10.2'
......@@ -115,6 +116,7 @@ dependencies {
def acraVersion = '5.5.0'
def autoservice_version = "1.0-rc6"
def android_test = "1.2.0"
def retrofit_version = "2.7.2"
// Linter
ktlint "com.github.shyiko:ktlint:0.31.0"
......@@ -136,10 +138,10 @@ dependencies {
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation "androidx.preference:preference-ktx:1.1.0"
implementation "com.google.android.material:material:1.2.0-alpha04"
implementation 'com.google.android.material:material:1.2.0-alpha05'
// AndroidX testing
implementation "androidx.fragment:fragment-testing:$fragment_version"
debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
......@@ -163,13 +165,13 @@ dependencies {
kapt "com.github.MatrixDev.Roomigrant:RoomigrantCompiler:$roomigrant_version"
// HTTP and promises
implementation "com.github.kittinunf.fuel:fuel:$fuel_version"
implementation "com.github.kittinunf.fuel:fuel-android:$fuel_version"
implementation("com.github.kittinunf.fuel:fuel-jackson:$fuel_version") { transitive = false }
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jackson_version"
implementation "com.fasterxml.jackson.datatype:jackson-datatype-joda:$jackson_version"
implementation "nl.komponents.kovenant:kovenant:$promise_version"
implementation "nl.komponents.kovenant:kovenant-android:$promise_version"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-jackson:$retrofit_version"
implementation "com.squareup.retrofit2:converter-scalars:$retrofit_version"
// Utils
implementation "org.apache.commons:commons-text:1.8"
......@@ -188,12 +190,33 @@ dependencies {
// Tests
testImplementation "junit:junit:4.13"
testImplementation "org.hamcrest:hamcrest-library:2.2"
testImplementation "com.squareup.okhttp3:mockwebserver:4.4.0"
testImplementation "com.github.javafaker:javafaker:1.0.2"
/*
* org.json:json is used with explicit permission of its copyright holders :
*
* Copyright (c) 2002 JSON.org
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* The Software shall be used for Good, not Evil.
*/
testImplementation "org.json:json:20190722"
androidTestImplementation "com.github.javafaker:javafaker:1.0.2"
androidTestImplementation "androidx.test:rules:$android_test"
androidTestImplementation "androidx.test:runner:$android_test"
androidTestImplementation "androidx.test.ext:junit:1.1.1"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
androidTestImplementation "com.github.javafaker:javafaker:1.0.2"
// Debug
debugImplementation "com.facebook.stetho:stetho:1.5.1"
......
......@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
import fr.chenry.android.freshrss.R.drawable
import fr.chenry.android.freshrss.R.string
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.database.models.VoidAccount
import fr.chenry.android.freshrss.utils.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
......@@ -49,9 +48,6 @@ class RefresherService: Service() {
fun refresh(manual: Boolean = true): Promise<Unit, Exception> {
if(Store.refreshingPromise.value != null) return Store.refreshingPromise.value!!
// This case should not happen. Blocking only for testing
if((Store.account.value ?: VoidAccount) == VoidAccount) return Promise.ofSuccess(Unit)
if(!F.context.isConnectedToNetwork()) {
if(manual) {
Toast.makeText(
......@@ -66,8 +62,6 @@ class RefresherService: Service() {
F.notificationManager.notify(NotificationHelper.ONGOING_REFRESH_NOTIFICATION, refreshNotification)
Store.ensureToken()
val promise = Store.getSubscriptions()
.bind {Store.getUnreadCount()}
.bind {
......@@ -82,6 +76,7 @@ class RefresherService: Service() {
Store.refreshingPromise.postValue(promise)
promise.always {Store.refreshingPromise.postValue(null)}
.failUi {
it.printStackTrace()
F.notificationManager.notify(NotificationHelper.FAIL_REFRESH_NOTIFICATION, failNotification)
}.alwaysUi {
cancelRefreshNotification()
......
......@@ -14,10 +14,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.api.utils.ServerException
import fr.chenry.android.freshrss.utils.*
import kotlinx.android.synthetic.main.activity_login.*
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import java.util.Properties
class LoginActivity: AppCompatActivity() {
......@@ -106,14 +105,10 @@ class LoginActivity: AppCompatActivity() {
focusView?.requestFocus()
} else {
showProgress(true)
Store.login(instanceUrl.toString(), loginStr, passwordStr) successUi {
startActivity(Intent(this, MainActivity::class.java))
Store.login(instanceUrl.toString(), loginStr, passwordStr).onSuccess {
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
} failUi {
this.e(it)
instance.error = getString(R.string.error_instance)
showProgress(false)
}
}.onFailure (::handleError)
}
}
......@@ -127,6 +122,19 @@ class LoginActivity: AppCompatActivity() {
reified_instance_url.text = instanceUrl.toSpanString()
}
private fun handleError(err: Throwable) {
showProgress(false)
this.e(err)
instance.error = getString(R.string.error_instance)
if(err is ServerException) {
when(err.response.code()) {
403 -> {}
404 -> {}
else -> {}
}
}
}
private fun resetErrors() {
listOf(login, password, instance).forEach {
it.error = null
......
......@@ -27,7 +27,7 @@ import nl.komponents.kovenant.ui.failUi
class SubscriptionArticlesDetailFragment : Fragment() {
private lateinit var articleId: String
private val model: SubscriptionArticleVM by lazy {
ViewModelProvider(activity!!.viewModelStore, SubscriptionArticleVMF(articleId))
ViewModelProvider(requireActivity().viewModelStore, SubscriptionArticleVMF(articleId))
.get("articleId=$articleId", SubscriptionArticleVM::class.java)
}
private var isFetching = MutableLiveData<Boolean>().apply { value = false }
......
......@@ -29,7 +29,7 @@ class SubscriptionArticlesFragment: Fragment() {
private val readStatus: ReadStatus by lazy {args.readStatus}
private val model: SubscriptionArticlesVM by lazy {
ViewModelProvider(activity!!.viewModelStore, SubscriptionArticlesVMF(subscription, readStatus))
ViewModelProvider(requireActivity().viewModelStore, SubscriptionArticlesVMF(subscription, readStatus))
.get("stream=${subscription.id}:readStatus=$readStatus", SubscriptionArticlesVM::class.java)
}
......
......@@ -32,7 +32,7 @@ class SubscriptionsFragment: Fragment(), Observer<SubscriptionViewItems> {
val model by lazy {
val key = "section=${section.name}${if(category != VoidCategory) ":category=${category.id}" else ""}"
ViewModelProvider(activity!!.viewModelStore, SubscriptionsFragmentCategoryVMF(category, section))
ViewModelProvider(requireActivity().viewModelStore, SubscriptionsFragmentCategoryVMF(category, section))
.get(key, SubscriptionsVM::class.java)
}
......
package fr.chenry.android.freshrss.store.api
import fr.chenry.android.freshrss.store.api.models.*
import fr.chenry.android.freshrss.store.api.utils.*
import fr.chenry.android.freshrss.store.database.models.Account
import fr.chenry.android.freshrss.store.database.models.ReadStatus
import fr.chenry.android.freshrss.utils.addTrailingSlash
import okhttp3.OkHttpClient
import retrofit2.*
import retrofit2.converter.jackson.JacksonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.http.*
const val LOGIN_ENPOINT = "accounts/ClientLogin"
interface API {
@AuthorisationRequired
@POST("reader/api/0/token")
fun getWriteToken(): Call<String>
@JsonOutput
@AuthorisationRequired
@UseDeserializer(SubscriptionApiItemsConverter::class)
@GET("reader/api/0/subscription/list")
fun getSubscriptions(): Call<List<SubscriptionApiItem>>
@JsonOutput
@AuthorisationRequired
@GET("reader/api/0/unread-count")
fun getUnreadCount(): Call<UnreadCountsHandler>
@JsonOutput
@AuthorisationRequired
@GET("reader/api/0/stream/contents/{id}")
fun getStreamContents(@Path("id") id: String): Call<ContentItemsHandler>
@JsonOutput
@AuthorisationRequired
@UseDeserializer(TagsConverter::class)
@GET("reader/api/0/tag/list")
fun getTags(): Call<List<String>>
@JsonOutput
@AuthorisationRequired
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list?n=1000000&xt=user/-/state/com.google/read")
fun getUnreadItems(@Query("ot") @QueryTransformer(OTTransformer::class) ot: Long): Call<ContentItemsHandler>
@TokenRequired
@AuthorisationRequired
@POST("reader/api/0/edit-tag")
fun postReadStatus(
@Query("i") itemId: String,
@Query("r") @QueryTransformer(ReadStatusTransformer::class) readStatus: ReadStatus
): Call<Unit>
@TokenRequired
@AuthorisationRequired
@POST("reader/api/0/subscription/quickadd")
fun postAddSubscription(@Query("quickadd") url: String): Call<String>
companion object {
const val TOKEN_RENEWAL_FAILED_MESSAGE = "Token renewal failed"
const val VOID_ACCOUNT_FAIL_MESSAGE = "No account attached"
fun get(account: Account): API {
val interceptor = APIInterceptor(account)
val okHttpClient = OkHttpClient.Builder().addInterceptor(interceptor).build()
val service: API = Retrofit.Builder()
.client(okHttpClient)
.baseUrl(account.serverInstance.addTrailingSlash())
.addConverterFactory(APIConverter())
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(JacksonConverterFactory.create(JACKSON_OBJECT_MAPPER))
.build()
.create()
interceptor.service = service
return service
}
}
}
package fr.chenry.android.freshrss.store.api
import com.github.kittinunf.fuel.Fuel
import fr.chenry.android.freshrss.store.api.models.*
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.utils.*
import nl.komponents.kovenant.*
class Api(val account: Account) {
private val endpoints = Endpoints(account.serverInstance)
fun getWriteToken() = Fuel
.post(endpoints.tokenEndpoint)
.header("Authorization" to "GoogleLogin auth=${account.SID}")
.promiseString()
fun getSubscriptions(): Promise<List<SubscriptionApiItem>, Exception> =
Fuel
.getJson(endpoints.subscriptionEndpoint)
.authentify(this.account)
.promise<SubscriptionsHandler>()
.then { it.subscriptions }
fun getUnreadCount(): Promise<UnreadCountsHandler, Exception> =
Fuel
.getJson(endpoints.unreadCountEndpoint)
.authentify(this.account)
.promise()
fun getStreamContents(id: String): Promise<ContentItemsHandler, Exception> =
Fuel
.getJson(endpoints.streamContentsEndpoint(id), listOf("n" to 1_000_000))
.authentify(this.account)
.promise()
fun getTags(): Promise<List<String>, Exception> =
Fuel
.getJson(endpoints.tagEndpoint)
.authentify(this.account)
.promiseWithSerializer(TagsDeserializer)
fun getUnreadItems(olderTimestamp: Long): Promise<ContentItemsHandler, Exception> {
val params = mutableListOf(
"xt" to "user/-/state/com.google/read",
"n" to 1_000_000
)
if (olderTimestamp > 0) params.add("ot" to "$olderTimestamp")
return Fuel
.getJson(endpoints.unreadItemsEndpoint, params)
.authentify(account)
.promise()
}
fun postReadStatus(itemId: String, readStatus: ReadStatus): Promise<Unit, Exception> {
val parameters = listOf(
"i" to itemId,
(if (readStatus == ReadStatus.READ) "a" else "r") to "user/-/state/com.google/read"
)
return Fuel.post(endpoints.editTagEnpoint, parameters)
.authentify(account)
.authorize(account)
.promiseString()
.toSuccessVoid()
}
fun postAddSubscription(url: String): Promise<String, Exception> =
Fuel
.post(endpoints.subscriptionAdd(url))
.authentify(account)
.authorize(account)
.promiseWithSerializer(SubscritpionAddResponseDeserializer)
companion object {
fun login(instance: String, login: String, password: String): Promise<Account, Exception> {
val params = listOf(
"service" to "reader",
"Email" to login,
"Passwd" to password,
"source" to "FreshRSS",
"accountType" to "GOOGLE"
)
val temporaryEndpoints = Endpoints(instance)
return Fuel
.post(temporaryEndpoints.loginEndpoint, params)
.promiseWithSerializer<Account>()
.then { it.copy(serverInstance = instance, login = login) }
}
}
}
package fr.chenry.android.freshrss.store.api
import java.net.URLEncoder
class Endpoints(private val base: String) {
val loginEndpoint = "${this.base}/accounts/ClientLogin"
val tokenEndpoint = "${this.base}/reader/api/0/token"
val subscriptionEndpoint = "${this.base}/reader/api/0/subscription/list"
fun subscriptionAdd(url: String) =
"${this.base}/reader/api/0/subscription/quickadd?quickadd=${URLEncoder.encode(url, "utf-8")}"
val unreadCountEndpoint = "${this.base}/reader/api/0/unread-count"
val unreadItemsEndpoint = "${this.base}/reader/api/0/stream/contents/user/-/state/com.google/reading-list"
fun streamContentsEndpoint(id: String) = "${this.base}/reader/api/0/stream/contents/$id"
val tagEndpoint = "${this.base}/reader/api/0/tag/list"
val editTagEnpoint = "${this.base}/reader/api/0/edit-tag"
}
......@@ -2,6 +2,7 @@ package fr.chenry.android.freshrss.store.api.models
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import fr.chenry.android.freshrss.store.api.utils.*
import org.joda.time.LocalDateTime
data class ContentItemsHandler(
......
package fr.chenry.android.freshrss.store.api.models
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.datatype.joda.deser.LocalDateTimeDeserializer
import com.github.kittinunf.fuel.core.ResponseDeserializable
import fr.chenry.android.freshrss.utils.*
import org.joda.time.DateTimeZone
import org.joda.time.LocalDateTime
import org.json.JSONObject
class ContentItemsDeserializer: JsonDeserializer<ContentItems>() {
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): ContentItems =
p?.let {_ ->
Try {
JACKSON_OBJECT_MAPPER.readTree<ArrayNode>(p).mapNotNull {
Try {JACKSON_OBJECT_MAPPER.convertValue(it, ContentItem::class.java)}.getOrNull()
}
}.getOrDefault(listOf())
} ?: listOf()
}
class MicroSecTimestampDeserializer: LocalDateTimeDeserializer() {
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): LocalDateTime {
val tz = if(_format.isTimezoneExplicit) _format.timeZone else DateTimeZone.forTimeZone(ctxt?.timeZone)
return LocalDateTime(p?.valueAsString?.toLongOrNull()?.div(1000) ?: 0, tz)
}
}
class MilliSecTimestampDeserializer: LocalDateTimeDeserializer() {
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): LocalDateTime {
val tz = if(_format.isTimezoneExplicit) _format.timeZone else DateTimeZone.forTimeZone(ctxt?.timeZone)
return LocalDateTime(p?.valueAsString?.toLongOrNull() ?: 0, tz)
}
}
class PublishedDateDeserializer: LocalDateTimeDeserializer() {
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): LocalDateTime {
val tz = if(_format.isTimezoneExplicit) _format.timeZone else DateTimeZone.forTimeZone(ctxt?.timeZone)
return LocalDateTime((p?.valueAsLong ?: 0L) * 1000L, tz)
}
}
object TagsDeserializer: ResponseDeserializable<List<String>> {
override fun deserialize(content: String): List<String> {
return try {
val jsonArray = JSONObject(content).optJSONArray("tags")
val initialCapacity = jsonArray.length()
val tags = ArrayList<String>(initialCapacity)
for(i in 0 until initialCapacity) tags.add(jsonArray.optJSONObject(i).optString("id"))
tags
} catch(e: Exception) {
this.e(e)
listOf()
}
}
}
object SubscritpionAddResponseDeserializer: ResponseDeserializable<String> {
override fun deserialize(content: String): String? {
return JSONObject(content).getString("streamId")
}
}
package fr.chenry.android.freshrss.store.api.utils
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type
import kotlin.reflect.full.primaryConstructor
internal class APIConverter: Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
annotations.forEach {
if(it is UseDeserializer) {
return it.deserializer.objectInstance
?: it.deserializer.primaryConstructor?.call()
?: it.deserializer.constructors.find {c -> c.parameters.isEmpty()}?.call()
?: throw NoSuchMethodException(
"Unable to find a parameterless constructor for class ${it.deserializer.simpleName}"
)
}
}
return null
}
}
package fr.chenry.android.freshrss.store.api.utils
import fr.chenry.android.freshrss.store.api.API
import fr.chenry.android.freshrss.store.api.API.Companion.TOKEN_RENEWAL_FAILED_MESSAGE
import fr.chenry.android.freshrss.store.api.API.Companion.VOID_ACCOUNT_FAIL_MESSAGE
import fr.chenry.android.freshrss.store.database.models.Account
import fr.chenry.android.freshrss.store.database.models.VoidAccount
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Protocol
import retrofit2.*
import kotlin.reflect.full.primaryConstructor
import okhttp3.Response as OkHttp3Response
internal class APIInterceptor(private val account: Account): Interceptor {
lateinit var service: API
override fun intercept(chain: Interceptor.Chain): OkHttp3Response {
if(account == VoidAccount)
return OkHttp3Response.Builder()
.code(460)
.message(VOID_ACCOUNT_FAIL_MESSAGE)
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.build()
val request = chain.request()
val invocation = request.tag(Invocation::class.java)!!
val method = invocation.method()
val builder = request.newBuilder()
var reqUrl = request.url()
if(method.isAnnotationPresent(TokenRequired::class.java)) {
if(account.isWriteTokenExpired) {
val response = runBlocking {service.getWriteToken().awaitResponse()}
if(!response.isSuccessful) return tokenRenewalFailResponse(response)
account.writeToken = response.body()!!.trim()
}
builder.header("T", account.writeToken)
}
if(method.isAnnotationPresent(JsonOutput::class.java))
reqUrl = reqUrl.newBuilder().setQueryParameter("output", "json").build()
if(method.isAnnotationPresent(AuthorisationRequired::class.java)) builder.apply {
header("Authorization", "GoogleLogin auth=${account.SID}")
}
method.parameterAnnotations.forEachIndexed {index, annotations ->
val annotation = annotations.find {it is QueryTransformer}
if(annotation != null && annotation is QueryTransformer) {
val parameter = invocation.arguments()[index]!!
val parameterType = method.parameterTypes[index]!!
val transformer = annotation.value.primaryConstructor!!.call()
@Suppress("UNCHECKED_CAST")
val queryParams = transformer.javaClass.getMethod("transform", parameterType)
.invoke(transformer, parameter) as List<Pair<String, String?>>
queryParams.forEach {
reqUrl =
if(it.second != null) reqUrl.newBuilder().setQueryParameter(it.first, it.second).build()
else reqUrl.newBuilder().removeAllQueryParameters(it.first).build()
}
}
}
return builder.url(reqUrl).build().let {chain.proceed(it)}
}
private fun <T> tokenRenewalFailResponse(response: Response<T>) =
response.raw().newBuilder().message(TOKEN_RENEWAL_FAILED_MESSAGE).build()
}
package fr.chenry.android.freshrss.store.api.utils
import fr.chenry.android.freshrss.store.database.models.ReadStatus
import okhttp3.ResponseBody
import retrofit2.Converter
import kotlin.reflect.KClass
/** This annotation will ensure that a valid token is available.
if not, it will perform a query to get a new one */
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
internal annotation class TokenRequired
/** This annotation always applies the correct `Authorization` header to the request */
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
internal annotation class AuthorisationRequired