Commit 3bc4c92e authored by Christophe Henry's avatar Christophe Henry

Persistence of auth tokens

parent fbafca1b
......@@ -89,4 +89,7 @@ dependencies {
testImplementation "junit:junit:4.12"
androidTestImplementation "androidx.test:runner:$test_runnner_version"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
// Debug
implementation "com.facebook.stetho:stetho:1.5.0"
}
package fr.chenry.android.freshrss
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.facebook.stetho.Stetho
import fr.chenry.android.freshrss.store.Store
import nl.komponents.kovenant.android.startKovenant
import nl.komponents.kovenant.android.stopKovenant
import nl.komponents.kovenant.deferred
import java.util.*
class FreshRSSApplication: Application() {
......@@ -13,13 +15,19 @@ class FreshRSSApplication: Application() {
super.onCreate()
// Stupid hack because Android is still not able to provide the current application application globally
// even though it is effectively a singleton...
FreshRSSApplication.application = this
FreshRSSApplication.applicationPromise.resolve(this)
startKovenant()
Store.debugMode = try {
val properties = Properties()
properties.load(baseContext.assets.open("config.properties"))
properties.getProperty("debug", "false")!!.toBoolean()
} catch (_: Throwable) { false }
} catch(_: Throwable) {
false
}
if(Store.debugMode) {
Stetho.initializeWithDefaults(this)
}
}
override fun onTerminate() {
......@@ -28,8 +36,11 @@ class FreshRSSApplication: Application() {
}
companion object {
lateinit var application: FreshRSSApplication
private set
private val applicationPromise = deferred<FreshRSSApplication, Throwable>()
val application: FreshRSSApplication
get() = applicationPromise.promise.get()
val context: Context get() = application.applicationContext
val notificationManager: NotificationManagerCompat
get() = NotificationManagerCompat.from(FreshRSSApplication.application)
......
......@@ -13,10 +13,10 @@ import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.store.FreshRSSDabatabase
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.api.Endpoints
import fr.chenry.android.freshrss.utils.cleanUrlSlashes
import fr.chenry.android.freshrss.utils.e
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
......@@ -28,6 +28,13 @@ import java.util.*
class LoginActivity: AppCompatActivity(), LoaderCallbacks<Cursor> {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if(FreshRSSDabatabase.instance.authTokensExistInDB) {
Store.init(FreshRSSDabatabase.instance.account)
startNextActivity()
return
}
setContentView(R.layout.activity_login)
// Set up the login form.
password.setOnEditorActionListener(TextView.OnEditorActionListener {_, id, _ ->
......@@ -49,12 +56,17 @@ class LoginActivity: AppCompatActivity(), LoaderCallbacks<Cursor> {
} catch(_: Throwable) {
}
computeHint()
instance.addTextChangedListener {computeHint()}
computeInstanceURLHint()
instance.addTextChangedListener {computeInstanceURLHint()}
resetErrors()
instance.requestFocus()
}
private fun startNextActivity() {
startActivity(Intent(this, MainActivity::class.java))
//startActivity(Intent(this, TestActivity::class.java))
}
private fun attemptLogin() {
resetErrors()
......@@ -85,8 +97,7 @@ class LoginActivity: AppCompatActivity(), LoaderCallbacks<Cursor> {
} else {
showProgress(true)
Store.login(instanceStr, loginStr, passwordStr) successUi {
startActivity(Intent(this, MainActivity::class.java))
//startActivity(Intent(this, TestActivity::class.java))
startNextActivity()
} failUi {
this.e(it)
instance.error = getString(R.string.error_instance)
......@@ -95,7 +106,7 @@ class LoginActivity: AppCompatActivity(), LoaderCallbacks<Cursor> {
}
}
private fun computeHint() {
private fun computeInstanceURLHint() {
val instanceStr = instance.text.toString().let {
if(it.isBlank()) resources.getString(R.string.instance_exemple) else it
}
......
......@@ -8,13 +8,13 @@ import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.components.waiting.WaitingFragment
import fr.chenry.android.freshrss.store.*
import fr.chenry.android.freshrss.utils.e
import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.resolve
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
class MainActivity: AppCompatActivity() {
private val deferred = deferred<Unit, Throwable>()
private val deferred = deferred<Unit, Exception>()
init {
Store.refresh().successUi {deferred.resolve()}.failUi(deferred::reject)
......
package fr.chenry.android.freshrss.store
import androidx.room.*
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.store.dao.*
import fr.chenry.android.freshrss.utils.getOrDefault
import nl.komponents.kovenant.task
import kotlin.reflect.KProperty
@Database(version = 1, entities = [Account::class])
abstract class FreshRSSDabatabase: RoomDatabase() {
private val authTokensDelegate = AuthTokensDelegate()
var account: Account by authTokensDelegate
val authTokensExistInDB get() = authTokensDelegate.isInitialized
protected abstract fun getAuthTokensDAO(): AuthTokensDAO
companion object {
private val dbName by lazy {
"${FreshRSSApplication.context.getString(R.string.app_name).toLowerCase()}.db"
}
val instance by lazy {
val instance =
Room.databaseBuilder(FreshRSSApplication.context, FreshRSSDabatabase::class.java, dbName).build()
instance.authTokensDelegate.fetchAuthtokensFromDB()
instance
}
}
inner class AuthTokensDelegate {
private lateinit var cachedAccount: Account
val isInitialized
get() = ::cachedAccount.isInitialized
operator fun getValue(thisRef: Any?, property: KProperty<*>): Account = cachedAccount
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Account) {
cachedAccount = value
task { getAuthTokensDAO().insert(account) }.getOrDefault(Unit)
}
fun fetchAuthtokensFromDB() {
if(!isInitialized) {
val authTokensFromDB = task {getAuthTokensDAO().getAuthTokens()[0]}.getOrDefault(null)
if(authTokensFromDB != null) cachedAccount = authTokensFromDB
}
}
}
}
......@@ -5,6 +5,7 @@ import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.R.string
import fr.chenry.android.freshrss.components.subscriptions.SubscriptionSection
import fr.chenry.android.freshrss.store.api.Api
import fr.chenry.android.freshrss.store.dao.Account
import fr.chenry.android.freshrss.store.dao.common.StreamId
import fr.chenry.android.freshrss.store.dao.common.Subscription
import fr.chenry.android.freshrss.store.dao.store.Article
......@@ -15,11 +16,13 @@ import fr.chenry.android.freshrss.utils.NotificationHelper.NotificationChanels.R
import fr.chenry.android.freshrss.utils.e
import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.ui.*
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
object Store {
lateinit var api: Api
private set
var debugMode = false
val api = Api()
val subscriptions = GenericLiveData<StreamId, Subscription>()
val totalUnreadCount = MutableLiveData<Int>()
val contentItems = GenericLiveData<StreamId, GenericLiveData<ItemId, Article>>()
......@@ -29,8 +32,13 @@ object Store {
private val refreshingPromise = MutableLiveData<Promise<Unit, Exception>>()
private var lastFetchTimestamp = 0L
fun init(account: Account) {
FreshRSSDabatabase.instance.account = account
api = Api(account)
}
fun login(instance: String, user: String, password: String): Promise<Unit, Exception> =
api.login(instance, user, password) bind {getToken()}
Api.login(instance, user, password) then {init(it)}
fun refresh(): Promise<Unit, Exception> {
if(refreshingPromise.value != null) return refreshingPromise.value!!
......@@ -96,9 +104,6 @@ object Store {
val article = contentItems[streamId]?.get(articleId)
return if(article == null) Promise.ofFail(ArticleNotFoundException(articleId)) else Promise.ofSuccess(article)
}
private fun getToken(): Promise<Unit, Exception> =
api.getToken() then {this.e("::getToken: TODO"); Unit}
}
class ArticleNotFoundException(val id: String): Exception("Article with id $id cound not be found in store")
\ No newline at end of file
package fr.chenry.android.freshrss.store.api
import com.github.kittinunf.fuel.Fuel
import fr.chenry.android.freshrss.store.dao.AuthTokens
import fr.chenry.android.freshrss.store.dao.Account
import fr.chenry.android.freshrss.store.dao.api.*
import fr.chenry.android.freshrss.store.dao.common.Subscriptions
import fr.chenry.android.freshrss.store.dao.common.SubscriptionsHandler
......@@ -11,84 +11,42 @@ import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.then
import nl.komponents.kovenant.ui.successUi
class ApiNotInitializedException: Exception("User is not logged in yet")
class Api {
private lateinit var endpoints: Endpoints
private lateinit var authTokens: AuthTokens
fun login(instance: String, login: String, password: String): Promise<AuthTokens, 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<AuthTokens>()
.success {
endpoints = temporaryEndpoints
authTokens = it.copy(login = login)
}
}
fun getToken(): Promise<String, Exception> {
if(!isLogged()) return Promise.ofFail(ApiNotInitializedException())
return Fuel
.post(endpoints.tokenEndpoint)
.header("Authorization" to "GoogleLogin auth=${this.authTokens.SID}")
.promiseString()
}
class Api(private val account: Account) {
private val endpoints = Endpoints(account.serverInstance)
fun getSubscriptions(): Promise<Subscriptions, Exception> {
if(!isLogged()) return Promise.ofFail(ApiNotInitializedException())
return Fuel
.getJson(endpoints.subscriptionEndpoint)
.authorize(this.authTokens)
.authorize(this.account)
.promise(SubscriptionsHandler.serializer())
.then {it.subscriptions}
}
fun getUnreadCount(): Promise<UnreadCountsHandler, Exception> {
if(!isLogged()) return Promise.ofFail(ApiNotInitializedException())
return Fuel
.getJson(endpoints.unreadCountEndpoint)
.authorize(this.authTokens)
.authorize(this.account)
.promise(UnreadCountsHandler.serializer())
}
fun getStreamItems(id: String): Promise<String, Exception> {
if(!isLogged()) return Promise.ofFail(ApiNotInitializedException())
return Fuel
.getJson(endpoints.streamItemsEndpoint, listOf("s" to id))
.authorize(this.authTokens)
.authorize(this.account)
.promiseString() successUi {this.e("::getStreamItems: TODO")}
}
fun getStreamContents(id: String): Promise<ContentItemsHandler, Exception> {
if(!isLogged()) return Promise.ofFail(ApiNotInitializedException())
return Fuel
.getJson(endpoints.streamContentsEndpoint(id))
.authorize(this.authTokens)
.authorize(this.account)
.promise(ContentItemsHandler.serializer())
}
fun getTags(): Promise<List<String>, Exception> {
if(!isLogged()) return Promise.ofFail(ApiNotInitializedException())
return Fuel
.getJson(endpoints.tagEndpoint)
.authorize(this.authTokens)
.authorize(this.account)
.promiseWithSerializer(TagsDeserializer)
}
......@@ -96,15 +54,13 @@ class Api {
olderTimestamp: Long,
continuation: String? = null
): Promise<ContentItemsHandler, Exception> {
if(!isLogged()) return Promise.ofFail(ApiNotInitializedException())
val params = mutableListOf("xt" to "user/-/state/com.google/read")
if(!continuation.isNullOrBlank()) params.add("c" to continuation)
if(olderTimestamp > 0) params.add("ot" to "$olderTimestamp")
return Fuel
.getJson(endpoints.unreadItemsEndpoint, params)
.authorize(authTokens)
.authorize(account)
.promise<ContentItemsHandler>()
.bind {it1 ->
if(it1.continuation.isNotBlank() && it1.continuation != continuation) {
......@@ -114,5 +70,30 @@ class Api {
}
}
private fun isLogged() = ::endpoints.isInitialized && ::authTokens.isInitialized
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)}
.bind {account ->
Fuel
.post(temporaryEndpoints.tokenEndpoint)
.header("Authorization" to "GoogleLogin auth=${account.SID}")
.promiseString()
.then {account.copy().apply {this.copy().writeToken = it}}
.fail {}.then {account}
}
}
}
}
package fr.chenry.android.freshrss.store.dao
import androidx.room.*
import com.github.kittinunf.fuel.core.ResponseDeserializable
import java.util.*
data class AuthTokens(
@Entity(tableName = "accounts")
data class Account(
val SID: String,
val Auth: String,
val login: String = ""
val login: String = "default_user",
val serverInstance: String = ""
) {
lateinit var token: String
// Limit to only one user for now
@PrimaryKey
var id = 1
set(_) = Unit
var writeToken = ""
companion object : ResponseDeserializable<AuthTokens> {
override fun deserialize(content: String): fr.chenry.android.freshrss.store.dao.AuthTokens {
companion object: ResponseDeserializable<Account> {
override fun deserialize(content: String): fr.chenry.android.freshrss.store.dao.Account {
val properties = Properties()
properties.load(content.reader())
return AuthTokens(properties.getProperty("SID", ""), properties.getProperty("Auth", ""))
return Account(
properties.getProperty("SID", ""),
properties.getProperty("Auth", ""))
}
}
}
@Dao
interface AuthTokensDAO {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(account: Account)
@Query("SELECT * FROM accounts")
fun getAuthTokens(): List<Account>
}
\ No newline at end of file
......@@ -2,7 +2,7 @@ package fr.chenry.android.freshrss.utils
import android.util.Log
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.dao.AuthTokens
import fr.chenry.android.freshrss.store.dao.Account
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.*
import com.github.kittinunf.fuel.serialization.responseObject
......@@ -66,7 +66,7 @@ fun Fuel.Companion.postJson(path: String, parameters: List<Pair<String, Any?>>?
fun Fuel.Companion.getJson(path: String, parameters: List<Pair<String, Any?>>? = null) =
Fuel.get(path, parameters.orEmpty() + ("output" to "json"))
fun Request.authorize(authTokens: AuthTokens) = this.header("Authorization" to "GoogleLogin auth=${authTokens.SID}")
fun Request.authorize(account: Account) = this.header("Authorization" to "GoogleLogin auth=${account.SID}")
fun Any.v(message: String) = Log.v(this::class.qualifiedName, message)
fun Any.v(message: Throwable) = Log.v(this::class.qualifiedName, "VERBOSE", message)
......@@ -90,6 +90,7 @@ fun String?.nullIfBlank() = if(this.isNullOrBlank()) null else this
fun <V, E>Promise<V, E>.getOrDefault(default: V) = try {
this.get()
} catch (_: Exception) {
} catch (e: Exception) {
this.w(e)
default
}
......@@ -10,7 +10,7 @@
<string name="prompt_instance">Your FreshRSS instance</string>
<string name="error_instance">This url is not correct</string>
<string name="protocol_default">https://</string>
<string name="instance_exemple">your-instance.com/p</string>
<string name="instance_exemple">your-instance.com</string>
<string name="api_endpoint">/api/greader</string>
<string name="full_instance_exemple">@string/protocol_default</string>
<string name="login_progress_text">Login to instance %s</string>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment