Commit e7f3fc10 authored by Christophe Henry's avatar Christophe Henry Committed by Christophe Henry

Solves #6: implement a mark read button

parent 718f3478
......@@ -2,9 +2,8 @@ package fr.chenry.android.freshrss.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.LoaderManager.LoaderCallbacks
import android.content.*
import android.database.Cursor
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
......@@ -16,7 +15,8 @@ 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.*
import fr.chenry.android.freshrss.utils.cleanUrlSlashes
import fr.chenry.android.freshrss.utils.e
import kotlinx.android.synthetic.main.activity_login.*
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
......@@ -25,7 +25,7 @@ import java.util.*
/**
* A login screen that offers login via email/password.
*/
class LoginActivity: AppCompatActivity(), LoaderCallbacks<Cursor> {
class LoginActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
......@@ -163,16 +163,4 @@ class LoginActivity: AppCompatActivity(), LoaderCallbacks<Cursor> {
login_progress.visibility = if(show) View.VISIBLE else View.GONE
}
override fun onLoaderReset(cursorLoader: Loader<Cursor>) {
}
override fun onLoadFinished(p0: Loader<Cursor>?, p1: Cursor?) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
override fun onCreateLoader(p0: Int, p1: Bundle?): Loader<Cursor> {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}
package fr.chenry.android.freshrss.components.subscriptionarticles
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.*
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import android.widget.Toast.LENGTH_SHORT
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.ShareActionProvider
import androidx.core.view.MenuItemCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
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.dao.store.Article
import fr.chenry.android.freshrss.store.dao.store.ReadStatus
import fr.chenry.android.freshrss.utils.capitalizeFull
import fr.chenry.android.freshrss.utils.e
import kotlinx.android.synthetic.main.fragment_subscription_article_detail.*
import nl.komponents.kovenant.ui.*
import java.util.regex.Pattern
import kotlin.text.RegexOption.IGNORE_CASE
......@@ -20,6 +28,7 @@ class SubscriptionArticlesDetailFragment: Fragment() {
private lateinit var subscriptionContentsId: String
private lateinit var subscriptionArticleId: String
private lateinit var article: Article
private var isFetching = MutableLiveData<Boolean>().apply {value = false}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
......@@ -38,27 +47,11 @@ class SubscriptionArticlesDetailFragment: Fragment() {
override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
inflater?.inflate(R.menu.article_actionbar, menu)
menu?.findItem(R.id.action_share)
.let {MenuItemCompat.getActionProvider(it) as ShareActionProvider}
.setShareIntent(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
val regex = "\\s*[^\\p{L}]*\\s*${Pattern.quote(article.author)}\\s*\\W*\\s*".toRegex(IGNORE_CASE)
val computedTitle = regex.replace(article.title, "")
val computedAuthor = if(article.author.isBlank()) "" else " — ${article.author}"
val comutedUrl = if(article.href.isBlank()) "" else "\n${article.href}"
putExtra(Intent.EXTRA_TEXT, "${computedTitle.capitalize()}${computedAuthor.capitalizeFull()}$comutedUrl")
})
val url = try { Uri.parse(article.href) } catch(_: Throwable) {null}
if(url != null) {
val actionOrigin = menu?.findItem(R.id.action_origin)
actionOrigin?.isVisible = true
actionOrigin?.setOnMenuItemClickListener {
val intent = Intent(Intent.ACTION_VIEW).apply {data = url}
startActivity(intent)
true
}
if(menu != null) {
setUpReadStatusButton(menu)
setupOpenInBrowser(menu)
setupShareAction(menu)
}
super.onCreateOptionsMenu(menu, inflater)
......@@ -66,13 +59,109 @@ class SubscriptionArticlesDetailFragment: Fragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
val content = "<h1>${article.title}</h1>${article.content}"
content_detail.settings.javaScriptEnabled = false
content_detail.loadData(content, "text/html", "UTF-8")
"<h1>${article.title}</h1>${article.content}".let {
content_detail.settings.javaScriptEnabled = false
content_detail.loadData(it, "text/html", "UTF-8")
}
}
override fun onResume() {
super.onResume()
setReadStatus(ReadStatus.READ)
}
override fun onDestroyView() {
activity.let {it as AppCompatActivity}.supportActionBar?.subtitle = null
super.onDestroyView()
}
private fun setupShareAction(menu: Menu) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
val regex = "\\s*[^\\p{L}]*\\s*${Pattern.quote(article.author)}\\s*\\W*\\s*".toRegex(IGNORE_CASE)
val computedTitle = regex.replace(article.title, "")
val computedAuthor = if(article.author.isBlank()) "" else " — ${article.author}"
val comutedUrl = if(article.href.isBlank()) "" else "\n${article.href}"
val intantValue = "${computedTitle.capitalize()}${computedAuthor.capitalizeFull()}$comutedUrl"
putExtra(Intent.EXTRA_TEXT, intantValue)
}
menu.findItem(R.id.action_share)
.let {MenuItemCompat.getActionProvider(it) as ShareActionProvider}
.setShareIntent(intent)
}
private fun setupOpenInBrowser(menu: Menu) {
if(article.url != null) {
val actionOrigin = menu.findItem(R.id.action_origin)
actionOrigin?.isVisible = true
actionOrigin?.setOnMenuItemClickListener {
val intent = Intent(Intent.ACTION_VIEW).apply {data = article.url}
startActivity(intent)
true
}
}
}
private fun setUpReadStatusButton(menu: Menu) {
menu.findItem(R.id.action_mark_read_status)?.let {
fun mutateUi(readStatus: ReadStatus) {
when(readStatus) {
ReadStatus.READ -> {
it.icon = context?.getDrawable(R.drawable.ic_is_read_24dp)
it.title = context?.getString(R.string.mark_unread)
}
ReadStatus.UNREAD -> {
it.icon = context?.getDrawable(R.drawable.ic_is_unread_24dp)
it.title = context?.getString(R.string.mark_read)
}
}
it.isVisible = true
}
mutateUi(article.readStatus.value!!)
article.readStatus.observe(this, Observer(::mutateUi))
it.setOnMenuItemClickListener {setReadStatus(article.readStatus.value!!.toggle())}
}
}
private fun setReadStatus(readStatus: ReadStatus): Boolean {
if(isFetching.value!!) {
Toast.makeText(context, R.string.request_already_ongoing, LENGTH_SHORT).show()
return false
}
val readText = when(readStatus) {
ReadStatus.READ -> this.getString(R.string.read)
ReadStatus.UNREAD -> this.getString(R.string.unread)
}.let {this.getString(R.string.mark_read_status_authorization, it)}
if(!FreshRSSDabatabase.instance.account.canPostRequests) {
val toastText = this.getString(R.string.cannot_make_post_requests, readText)
Toast.makeText(context, toastText, LENGTH_LONG).show()
return false
}
isFetching.value = true
Store.postReadStatus(article.streamId, article.id, readStatus)
.successUi {
Store.subscriptions[article.streamId]?.let {
it.unreadCount = when(readStatus) {
ReadStatus.READ -> Math.max(it.unreadCount - 1, 0)
ReadStatus.UNREAD -> it.unreadCount + 1
}
Store.subscriptions.emitUi()
}
}
.failUi {e ->
this.e(e)
val toastText = context?.getString(R.string.unable_to, readText)
Toast.makeText(context, toastText, LENGTH_SHORT).show()
} alwaysUi {isFetching.value = false}
return true
}
}
......@@ -3,12 +3,13 @@ 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.store.dao.Account
import fr.chenry.android.freshrss.store.dao.AuthTokensDAO
import fr.chenry.android.freshrss.utils.getOrDefault
import nl.komponents.kovenant.task
import kotlin.reflect.KProperty
@Database(version = 1, entities = [Account::class])
@Database(version = 1, entities = [Account::class], exportSchema = false)
abstract class FreshRSSDabatabase: RoomDatabase() {
private val authTokensDelegate = AuthTokensDelegate()
var account: Account by authTokensDelegate
......
......@@ -8,8 +8,7 @@ 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
import fr.chenry.android.freshrss.store.dao.store.ItemId
import fr.chenry.android.freshrss.store.dao.store.*
import fr.chenry.android.freshrss.store.databindingsupport.GenericLiveData
import fr.chenry.android.freshrss.utils.NotificationHelper
import fr.chenry.android.freshrss.utils.NotificationHelper.NotificationChanels.REFRESH
......@@ -45,6 +44,8 @@ object Store {
val notificationId = NotificationHelper.post(REFRESH, R.string.notification_refresh_title)
ensureToken()
val promise = getSubscriptions()
.bind {getUnreadCount()}
.bind {all(subscriptions.keys.map {getStreamContents(it)}, cancelOthersOnError = false)}
......@@ -74,18 +75,7 @@ object Store {
fun getStreamContents(id: String): Promise<Unit, Exception> =
api.getStreamContents(id) then {
contentItems[id] = GenericLiveData(
it.items.map {item ->
item.id to Article(
item.id,
item.title,
item.alternate.first().href,
item.categories,
item.author,
item.summary.content
)
}.toMap()
)
contentItems[id] = GenericLiveData(it.items.map {item -> item.id to Article.fromContentItem(item)}.toMap())
contentItems.emit()
}
......@@ -95,7 +85,7 @@ object Store {
api.getUnreadItems(lastFetchTimestamp) then {
val unreadItems = it.items.groupBy {c -> c.origin.streamId}
unreadItems.forEach {u ->
subscriptions[u.key]?.unreadList = u.value.sortedBy {i -> i.timestampUsec}.map {i -> i.id}
u.value.forEach {a -> contentItems[u.key]?.get(a.id)?.readStatus?.postValue(ReadStatus.UNREAD)}
}
lastFetchTimestamp = System.currentTimeMillis() % 1000
}
......@@ -104,6 +94,19 @@ object Store {
val article = contentItems[streamId]?.get(articleId)
return if(article == null) Promise.ofFail(ArticleNotFoundException(articleId)) else Promise.ofSuccess(article)
}
fun postReadStatus(streamId: StreamId, itemId: ItemId, readStatus: ReadStatus): Promise<Unit, Exception> =
ensureToken()
.bind {api.postReadStatus(itemId, readStatus)}
.success {contentItems[streamId]?.get(itemId)?.readStatus?.postValue(readStatus)}
private fun ensureToken(): Promise<Unit, Exception> {
if(!FreshRSSDabatabase.instance.account.isWriteTokenExpired) return Promise.ofSuccess(Unit)
return api.getWriteToken()
.success {FreshRSSDabatabase.instance.account.writeToken = it}
.toSuccessVoid()
}
}
class ArticleNotFoundException(val id: String): Exception("Article with id $id cound not be found in store")
\ No newline at end of file
......@@ -5,50 +5,51 @@ 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
import fr.chenry.android.freshrss.store.dao.store.ItemId
import fr.chenry.android.freshrss.store.dao.store.ReadStatus
import fr.chenry.android.freshrss.utils.*
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.then
import nl.komponents.kovenant.ui.successUi
class Api(private val account: Account) {
private val endpoints = Endpoints(account.serverInstance)
fun getSubscriptions(): Promise<Subscriptions, Exception> {
return Fuel
fun getWriteToken() = Fuel
.post(endpoints.tokenEndpoint)
.header("Authorization" to "GoogleLogin auth=${account.SID}")
.promiseString()
fun getSubscriptions(): Promise<Subscriptions, Exception> =
Fuel
.getJson(endpoints.subscriptionEndpoint)
.authorize(this.account)
.authentify(this.account)
.promise(SubscriptionsHandler.serializer())
.then {it.subscriptions}
}
fun getUnreadCount(): Promise<UnreadCountsHandler, Exception> {
return Fuel
fun getUnreadCount(): Promise<UnreadCountsHandler, Exception> =
Fuel
.getJson(endpoints.unreadCountEndpoint)
.authorize(this.account)
.authentify(this.account)
.promise(UnreadCountsHandler.serializer())
}
fun getStreamItems(id: String): Promise<String, Exception> {
return Fuel
fun getStreamItems(id: String): Promise<String, Exception> =
Fuel
.getJson(endpoints.streamItemsEndpoint, listOf("s" to id))
.authorize(this.account)
.authentify(this.account)
.promiseString() successUi {this.e("::getStreamItems: TODO")}
}
fun getStreamContents(id: String): Promise<ContentItemsHandler, Exception> {
return Fuel
fun getStreamContents(id: String): Promise<ContentItemsHandler, Exception> =
Fuel
.getJson(endpoints.streamContentsEndpoint(id))
.authorize(this.account)
.authentify(this.account)
.promise(ContentItemsHandler.serializer())
}
fun getTags(): Promise<List<String>, Exception> {
return Fuel
fun getTags(): Promise<List<String>, Exception> =
Fuel
.getJson(endpoints.tagEndpoint)
.authorize(this.account)
.authentify(this.account)
.promiseWithSerializer(TagsDeserializer)
}
fun getUnreadItems(
olderTimestamp: Long,
......@@ -60,7 +61,7 @@ class Api(private val account: Account) {
return Fuel
.getJson(endpoints.unreadItemsEndpoint, params)
.authorize(account)
.authentify(account)
.promise<ContentItemsHandler>()
.bind {it1 ->
if(it1.continuation.isNotBlank() && it1.continuation != continuation) {
......@@ -70,6 +71,19 @@ class Api(private val account: Account) {
}
}
fun postReadStatus(itemId: ItemId, 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()
}
companion object {
fun login(instance: String, login: String, password: String): Promise<Account, Exception> {
val params = listOf(
......@@ -86,14 +100,6 @@ class Api(private val account: Account) {
.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}
}
}
}
}
......@@ -12,6 +12,7 @@ class Endpoints(base: String) {
fun streamContentsEndpoint(id: String) = "${this.base}/reader/api/0/stream/contents/$id"
val tagEndpoint = "${this.base}/reader/api/0/tag/list"
val unreadItemsEndpoint = "${this.base}/reader/api/0/stream/contents/user/-/state/com.google/reading-list"
val editTagEnpoint = "${this.base}/reader/api/0/edit-tag"
companion object {
const val defaultProtocol = "https://"
......
......@@ -15,7 +15,16 @@ data class Account(
@PrimaryKey
var id = 1
set(_) = Unit
@Ignore
var writeToken = ""
set(value) {
field = value
writeTokenBirth = Date()
}
@Ignore
private var writeTokenBirth = Date(0)
val isWriteTokenExpired get() = writeTokenBirth.before(Date().apply {minutes -= 30})
val canPostRequests get() = writeToken.isNotBlank()
companion object: ResponseDeserializable<Account> {
override fun deserialize(content: String): fr.chenry.android.freshrss.store.dao.Account {
......
......@@ -3,7 +3,6 @@ package fr.chenry.android.freshrss.store.dao.common
import androidx.databinding.BaseObservable
import androidx.databinding.Bindable
import fr.chenry.android.freshrss.BR
import fr.chenry.android.freshrss.store.dao.store.ItemId
import fr.chenry.android.freshrss.utils.escapeHtml4
import fr.chenry.android.freshrss.utils.unescapeHtml4
import kotlinx.serialization.*
......@@ -31,8 +30,6 @@ data class Subscription(
field = value
notifyPropertyChanged(BR.unreadCount)
}
@Transient
var unreadList = listOf<ItemId>()
}
@Serializable
......
package fr.chenry.android.freshrss.store.dao.store
import android.net.Uri
import androidx.databinding.BaseObservable
import androidx.lifecycle.MutableLiveData
import androidx.room.Ignore
import fr.chenry.android.freshrss.store.dao.api.ContentItem
import fr.chenry.android.freshrss.store.dao.common.StreamId
typealias ItemId = String
typealias Articles = List<Article>
......@@ -9,5 +16,32 @@ data class Article(
val href: String,
val categories: List<String>,
val author: String,
val content: String
)
\ No newline at end of file
val content: String,
val streamId: StreamId
): BaseObservable() {
@Ignore
val readStatus = MutableLiveData<ReadStatus>().apply {this.postValue(ReadStatus.READ)}
@Ignore
val url = try { Uri.parse(href) } catch(_: Throwable) {null}
companion object {
fun fromContentItem(item: ContentItem) = Article(
item.id,
item.title,
item.alternate.firstOrNull()?.href ?: "",
item.categories,
item.author,
item.summary.content,
item.origin.streamId
)
}
}
enum class ReadStatus {
READ, UNREAD;
fun toggle() = when(this) {
READ -> UNREAD
UNREAD -> READ
}
}
\ No newline at end of file
package fr.chenry.android.freshrss.store.databindingsupport.viewmodels
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.dao.store.*
import fr.chenry.android.freshrss.utils.*
import fr.chenry.android.freshrss.store.dao.store.Articles
import fr.chenry.android.freshrss.store.dao.store.ReadStatus
import fr.chenry.android.freshrss.utils.w
class UnreadArticlesViewModel: ArticlesViewModel() {
override val articles: Articles get() = liveData.value ?: listOf()
override fun load(): Articles {
val subscription = Store.subscriptions[streamId.value!!]
if(subscription == null) {
val allArticles = Store.contentItems[streamId.value!!]
if(allArticles == null) {
this.w("Unable to find unread articles for stream id ${streamId.value}")
return listOf()
}
return subscription.unreadList.mapNotNull {Store.contentItems[streamId.value!!]?.get(it)}
return allArticles.values.filter {it.readStatus.value == ReadStatus.UNREAD}
}
}
\ No newline at end of file
......@@ -66,7 +66,8 @@ 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(account: Account) = this.header("Authorization" to "GoogleLogin auth=${account.SID}")
fun Request.authentify(account: Account) = this.header("Authorization" to "GoogleLogin auth=${account.SID}")
fun Request.authorize(account: Account) = this.header("T" to account.writeToken)
fun Any.v(message: String) = Log.v(this::class.qualifiedName, message)
fun Any.v(message: Throwable) = Log.v(this::class.qualifiedName, "VERBOSE", message)
......
<vector android:height="24dp" android:viewportHeight="25"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFF"
android:pathData="
M20,4
L4,4
c-1.1,0 -1.99,0.9 -1.99,2
L2,18
c0,1.1 0.9,2 2,2
h16c1.1,0 2,-0.9 2,-2
L22,6
c0,-1.1 -0.9,-2 -2,-2z
M20,18
L4,18
L4,4
l16,0z
M12,13
L4,8
V6
L12,11
L20,6
V8z
M12,0
L3.8,4
L4,6
L12,2
L20,6
L20.3,4
L12,0z
"/>
</vector>
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFF" android:pathData="M20,4L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8l8,5 8,-5v10zM12,11L4,6h16l-8,5z"/>
</vector>
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
<menu
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_mark_read_status"
android:orderInCategory="1"
app:showAsAction="always"
android:visible="false" />
<item
android:id="@+id/action_share"
android:orderInCategory="100"
android:orderInCategory="2"
android:icon="@drawable/ic_share_black_24dp"
android:title="@string/share"
app:showAsAction="ifRoom"
......@@ -14,5 +20,5 @@
android:icon="@drawable/ic_web_black_24dp"
android:title="@string/original_page"
app:showAsAction="collapseActionView"
android:visible="false"/>
android:visible="false" />
</menu>
\ No newline at end of file