Commit 7ef529c4 authored by Christophe Henry's avatar Christophe Henry

Merge branch 'grouping-subscription-by' into 'develop'

Add sectionning of subscriptions

See merge request !5
parents 02423ab0 6dcd628a
...@@ -125,6 +125,7 @@ dependencies { ...@@ -125,6 +125,7 @@ dependencies {
implementation "org.apache.commons:commons-text:1.4" implementation "org.apache.commons:commons-text:1.4"
implementation "joda-time:joda-time:2.10.1" implementation "joda-time:joda-time:2.10.1"
implementation "com.squareup.picasso:picasso:2.71828" implementation "com.squareup.picasso:picasso:2.71828"
implementation "io.github.luizgrp.sectionedrecyclerviewadapter:sectionedrecyclerviewadapter:2.0.0"
// Tests // Tests
testImplementation "junit:junit:4.12" testImplementation "junit:junit:4.12"
......
package fr.chenry.android.freshrss.components.subscriptions package fr.chenry.android.freshrss.components.subscriptions
import android.view.LayoutInflater import android.view.View
import android.view.ViewGroup import android.widget.TextView
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import fr.chenry.android.freshrss.BR import fr.chenry.android.freshrss.BR
import fr.chenry.android.freshrss.R import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.databinding.FragmentSubscriptionBinding import fr.chenry.android.freshrss.databinding.FragmentSubscriptionBinding
import fr.chenry.android.freshrss.store.database.models.Subscription import fr.chenry.android.freshrss.store.database.models.Subscription
import fr.chenry.android.freshrss.store.database.models.Subscriptions
class RecyclerViewAdapter(private val fragment: SubscriptionsFragment): import io.github.luizgrp.sectionedrecyclerviewadapter.*
RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder>()
{ class RecyclerViewAdapter(private val fragment: SubscriptionsFragment): SectionedRecyclerViewAdapter() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { init {
val layoutInflater = LayoutInflater.from(parent.context) fragment.model.sectionnedLiveData.observe(fragment, Observer {
this.removeAllSections()
val binding: FragmentSubscriptionBinding it.entries.forEach {self -> this.addSection(self.key, RecyclerViewAdapterSection(self.toPair()))}
= DataBindingUtil.inflate(layoutInflater, R.layout.fragment_subscription, parent, false) this.notifyDataSetChanged()
})
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val subscription = fragment.subscriptions[position]
holder.bind(subscription)
holder.binding.root.setOnClickListener {fragment.onClick(subscription)}
} }
override fun getItemCount(): Int = fragment.subscriptions.size inner class ViewHolder(val binding: FragmentSubscriptionBinding): RecyclerView.ViewHolder(binding.root) {
inner class ViewHolder(val binding: FragmentSubscriptionBinding):
RecyclerView.ViewHolder(binding.root) {
fun bind(subscription: Subscription) { fun bind(subscription: Subscription) {
binding.setVariable(BR.subscription, subscription) binding.setVariable(BR.subscription, subscription)
binding.executePendingBindings() binding.executePendingBindings()
} }
} }
inner class RecyclerViewAdapterSection(private val entry: Pair<String, Subscriptions>):
StatelessSection(getSectionParameters()) {
override fun onBindItemViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val subscription = entry.second[position]
(holder as ViewHolder).apply {
bind(subscription)
binding.root.setOnClickListener {fragment.onClick(subscription)}
}
}
override fun onBindHeaderViewHolder(holder: RecyclerView.ViewHolder) {
holder.itemView.findViewById<TextView>(R.id.subscription_section_title).text = entry.first
}
override fun getItemViewHolder(view: View) = ViewHolder(DataBindingUtil.bind(view)!!)
override fun getContentItemsTotal() = entry.second.size
}
companion object {
fun getSectionParameters(): SectionParameters =
SectionParameters.builder()
.headerResourceId(R.layout.fragment_subscription_header)
.itemResourceId(R.layout.fragment_subscription)
.build()
}
} }
...@@ -23,7 +23,7 @@ class SubscriptionsFragment: Fragment(), Observer<Subscriptions> { ...@@ -23,7 +23,7 @@ class SubscriptionsFragment: Fragment(), Observer<Subscriptions> {
arguments?.getParcelable(argumentKey) ?: SubscriptionSection.ALL arguments?.getParcelable(argumentKey) ?: SubscriptionSection.ALL
} }
private val model by lazy { val model by lazy {
when(subscriptionSection) { when(subscriptionSection) {
ALL -> ViewModelProviders.of(this).get(AllSubscriptionsVM::class.java) ALL -> ViewModelProviders.of(this).get(AllSubscriptionsVM::class.java)
UNREAD -> ViewModelProviders.of(this).get(UnreadSubscriptionsVM::class.java) UNREAD -> ViewModelProviders.of(this).get(UnreadSubscriptionsVM::class.java)
......
...@@ -44,8 +44,8 @@ object Store { ...@@ -44,8 +44,8 @@ object Store {
task { task {
FreshRSSApplication.database.let {db -> FreshRSSApplication.database.let {db ->
db.getAllSubcriptionsWithImageToUpdate() db.getAllSubcriptionsWithImageToUpdate()
.blockingFirst() .blockingFirst()
.forEach {sub -> db.insertSubscriptionImage(sub.id, sub.fetchImage())} .forEach {sub -> db.insertSubscriptionImage(sub.id, sub.fetchImage())}
} }
} }
}.toSuccessVoid() }.toSuccessVoid()
...@@ -54,18 +54,16 @@ object Store { ...@@ -54,18 +54,16 @@ object Store {
fun getUnreadCount(): Promise<Unit, Exception> = fun getUnreadCount(): Promise<Unit, Exception> =
api.getUnreadCount() then { api.getUnreadCount() then {
totalUnreadCount.postValue(it.max) totalUnreadCount.postValue(it.max)
it.unreadcounts.forEach {self -> it.unreadcounts.forEach {self -> FreshRSSApplication.database.updateSubscriptionCount(self.id, self.count)}
FreshRSSApplication.database.updateSubscriptionCount(self.id, self.count)
}
} }
fun getStreamItems(id: String): Promise<Unit, Exception> =
api.getStreamItems(id) then {this.e("::getStreamItems: TODO"); Unit}
fun getStreamContents(id: String): Promise<Unit, Exception> = fun getStreamContents(id: String): Promise<Unit, Exception> =
api.getStreamContents(id) bind { api.getStreamContents(id) bind {
val insertPromises = it.items.map {item -> val insertPromises = it.items.map {item ->
task {FreshRSSDabatabase.instance.insertArticle(Article.fromContentItem(item))} task {
FreshRSSApplication.database.insertArticle(Article.fromContentItem(item))
FreshRSSApplication.database.updateSubscriptionNewestArticleDate(id, item.crawled)
}
} }
all(insertPromises, cancelOthersOnError = false).toSuccessVoid() all(insertPromises, cancelOthersOnError = false).toSuccessVoid()
} }
......
...@@ -6,7 +6,6 @@ import fr.chenry.android.freshrss.store.database.models.* ...@@ -6,7 +6,6 @@ import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.utils.* import fr.chenry.android.freshrss.utils.*
import nl.komponents.kovenant.* import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.ui.successUi
class Api(private val account: Account) { class Api(private val account: Account) {
private val endpoints = Endpoints(account.serverInstance) private val endpoints = Endpoints(account.serverInstance)
...@@ -29,12 +28,6 @@ class Api(private val account: Account) { ...@@ -29,12 +28,6 @@ class Api(private val account: Account) {
.authentify(this.account) .authentify(this.account)
.promise() .promise()
fun getStreamItems(id: String): Promise<String, Exception> =
Fuel
.getJson(endpoints.streamItemsEndpoint, listOf("s" to id))
.authentify(this.account)
.promiseString() successUi {this.e("::getStreamItems: TODO")}
fun getStreamContents(id: String): Promise<ContentItemsHandler, Exception> = fun getStreamContents(id: String): Promise<ContentItemsHandler, Exception> =
Fuel Fuel
.getJson(endpoints.streamContentsEndpoint(id)) .getJson(endpoints.streamContentsEndpoint(id))
......
...@@ -8,7 +8,6 @@ class Endpoints(base: String) { ...@@ -8,7 +8,6 @@ class Endpoints(base: String) {
val tokenEndpoint = "${this.base}/reader/api/0/token" val tokenEndpoint = "${this.base}/reader/api/0/token"
val subscriptionEndpoint = "${this.base}/reader/api/0/subscription/list" val subscriptionEndpoint = "${this.base}/reader/api/0/subscription/list"
val unreadCountEndpoint = "${this.base}/reader/api/0/unread-count" val unreadCountEndpoint = "${this.base}/reader/api/0/unread-count"
val streamItemsEndpoint = "${this.base}/reader/api/0/stream/items/ids"
fun streamContentsEndpoint(id: String) = "${this.base}/reader/api/0/stream/contents/$id" fun streamContentsEndpoint(id: String) = "${this.base}/reader/api/0/stream/contents/$id"
val tagEndpoint = "${this.base}/reader/api/0/tag/list" 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 unreadItemsEndpoint = "${this.base}/reader/api/0/stream/contents/user/-/state/com.google/reading-list"
......
package fr.chenry.android.freshrss.store.api.models package fr.chenry.android.freshrss.store.api.models
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import org.joda.time.LocalDateTime
data class ContentItemsHandler( data class ContentItemsHandler(
val id: String, val id: String,
val updated: Long, val updated: Long,
...@@ -11,9 +15,13 @@ typealias ContentItems = List<ContentItem> ...@@ -11,9 +15,13 @@ typealias ContentItems = List<ContentItem>
data class ContentItem( data class ContentItem(
val id: String, val id: String,
val crawlTimeMsec: Long, @JsonProperty("crawlTimeMsec")
val timestampUsec: Long, @JsonDeserialize(using = MilliSecTimestampDeserializer::class)
val published: Long, val crawled: LocalDateTime,
@JsonProperty("timestampUsec")
@JsonDeserialize(using = MicroSecTimestampDeserializer::class)
val timestamp: LocalDateTime,
val published: LocalDateTime,
val title: String, val title: String,
val alternate: List<Href>, val alternate: List<Href>,
val categories: List<String>, val categories: List<String>,
......
package fr.chenry.android.freshrss.store.api.models
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.datatype.joda.deser.LocalDateTimeDeserializer
import org.joda.time.DateTimeZone
import org.joda.time.LocalDateTime
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)
}
}
\ No newline at end of file
package fr.chenry.android.freshrss.store.api.models 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.annotation.JsonDeserialize
import com.fasterxml.jackson.datatype.joda.deser.LocalDateTimeDeserializer
import org.joda.time.DateTimeZone
import org.joda.time.LocalDateTime
data class UnreadCountsHandler(val max: Int, val unreadcounts: List<UnreadCount>) data class UnreadCountsHandler(val max: Int, val unreadcounts: List<UnreadCount>)
data class UnreadCount( data class UnreadCount(
val id: String, val id: String,
val count: Int, val count: Int
@JsonDeserialize(using = MicroSecTimestampDeserializer::class) )
val newestItemTimestampUsec: LocalDateTime = LocalDateTime.now() \ No newline at end of file
)
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), tz)
}
}
\ No newline at end of file
...@@ -5,8 +5,9 @@ import android.graphics.BitmapFactory ...@@ -5,8 +5,9 @@ import android.graphics.BitmapFactory
import androidx.room.TypeConverter import androidx.room.TypeConverter
import fr.chenry.android.freshrss.store.database.models.ReadStatus import fr.chenry.android.freshrss.store.database.models.ReadStatus
import fr.chenry.android.freshrss.store.database.models.ReadStatus.READ import fr.chenry.android.freshrss.store.database.models.ReadStatus.READ
import fr.chenry.android.freshrss.utils.escapeHtml4 import fr.chenry.android.freshrss.utils.*
import fr.chenry.android.freshrss.utils.unescapeHtml4 import org.joda.time.DateTimeZone
import org.joda.time.LocalDateTime
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
class Converters { class Converters {
...@@ -36,4 +37,10 @@ class Converters { ...@@ -36,4 +37,10 @@ class Converters {
@TypeConverter @TypeConverter
fun blobToBitmap(byteArray: ByteArray?) = fun blobToBitmap(byteArray: ByteArray?) =
if(byteArray?.isEmpty() != false) null else BitmapFactory.decodeStream(byteArray.inputStream()) if(byteArray?.isEmpty() != false) null else BitmapFactory.decodeStream(byteArray.inputStream())
@TypeConverter
fun localDateTimeToLong(localDateTime: LocalDateTime) = localDateTime.toDateTime(DateTimeZone.UTC).millis
@TypeConverter
fun longToLocalDateTime(epoch: Long) = Try{LocalDateTime(epoch)}.getOrDefault(LocalDateTime(0))
} }
...@@ -11,9 +11,10 @@ import fr.chenry.android.freshrss.utils.Try ...@@ -11,9 +11,10 @@ import fr.chenry.android.freshrss.utils.Try
import fr.chenry.android.freshrss.utils.getOrDefault import fr.chenry.android.freshrss.utils.getOrDefault
import nl.komponents.kovenant.* import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
import org.joda.time.LocalDateTime
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@Database(version = 3, entities = [Account::class, Article::class, Subscription::class]) @Database(version = 4, entities = [Account::class, Article::class, Subscription::class])
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@GenerateRoomMigrations @GenerateRoomMigrations
abstract class FreshRSSDabatabase: RoomDatabase() { abstract class FreshRSSDabatabase: RoomDatabase() {
...@@ -61,10 +62,13 @@ abstract class FreshRSSDabatabase: RoomDatabase() { ...@@ -61,10 +62,13 @@ abstract class FreshRSSDabatabase: RoomDatabase() {
}.toSuccessVoid() }.toSuccessVoid()
fun updateSubscriptionCount(id: String, count: Int) = getSubscriptionsDAO().updateCount(id, count) fun updateSubscriptionCount(id: String, count: Int) = getSubscriptionsDAO().updateCount(id, count)
fun updateSubscriptionNewestArticleDate(id: String, newestArticleDate: LocalDateTime) =
getSubscriptionsDAO().updateNewestArticleDate(id, newestArticleDate)
fun incrementSubscriptionCount(id: String) = getSubscriptionsDAO().incrementCount(id) fun incrementSubscriptionCount(id: String) = getSubscriptionsDAO().incrementCount(id)
fun decrementSubscriptionCount(id: String) = getSubscriptionsDAO().decrementCount(id) fun decrementSubscriptionCount(id: String) = getSubscriptionsDAO().decrementCount(id)
fun insertSubscriptionImage(id: String, bitmap: Bitmap) = getSubscriptionsDAO().insertImage(id, bitmap) fun insertSubscriptionImage(id: String, bitmap: Bitmap) = getSubscriptionsDAO().insertImage(id, bitmap)
fun getAllSubcriptions() = getSubscriptionsDAO().getAll() fun getAllSubcriptions() = getSubscriptionsDAO().getAll()
fun getAllUnreadSubcriptions() = getSubscriptionsDAO().getAllUnread()
fun getAllSubcriptionsIds() = getSubscriptionsDAO().getAllIds() fun getAllSubcriptionsIds() = getSubscriptionsDAO().getAllIds()
fun getAllSubcriptionsWithImageToUpdate() = getSubscriptionsDAO().withImageToUpdate() fun getAllSubcriptionsWithImageToUpdate() = getSubscriptionsDAO().withImageToUpdate()
......
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "ebcce02af6cef8a0085a6b22d710c6cb",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `SID` TEXT NOT NULL, `Auth` TEXT NOT NULL, `login` TEXT NOT NULL, `serverInstance` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "SID",
"columnName": "SID",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "Auth",
"columnName": "Auth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "login",
"columnName": "login",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverInstance",
"columnName": "serverInstance",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "articles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `href` TEXT NOT NULL, `categories` TEXT NOT NULL, `author` TEXT NOT NULL, `content` TEXT NOT NULL, `streamId` TEXT NOT NULL, `readStatus` TEXT NOT NULL, `crawled` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "streamId",
"columnName": "streamId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "readStatus",
"columnName": "readStatus",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "crawled",
"columnName": "crawled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "subscriptions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`imageBitmap` BLOB, `id` TEXT NOT NULL, `title` TEXT NOT NULL, `iconUrl` TEXT NOT NULL, `unreadCount` INTEGER NOT NULL, `newestArticleDate` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "imageBitmap",
"columnName": "imageBitmap",
"affinity": "BLOB",
"notNull": false
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconUrl",
"columnName": "iconUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "newestArticleDate",
"columnName": "newestArticleDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"ebcce02af6cef8a0085a6b22d710c6cb\")"
]
}
}
\ No newline at end of file
...@@ -3,6 +3,7 @@ package fr.chenry.android.freshrss.store.database.models ...@@ -3,6 +3,7 @@ package fr.chenry.android.freshrss.store.database.models
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.databinding.BaseObservable import androidx.databinding.BaseObservable
import androidx.databinding.Bindable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.room.* import androidx.room.*
import fr.chenry.android.freshrss.store.api.models.ContentItem import fr.chenry.android.freshrss.store.api.models.ContentItem
...@@ -10,7 +11,7 @@ import fr.chenry.android.freshrss.store.api.models.StreamId ...@@ -10,7 +11,7 @@ import fr.chenry.android.freshrss.store.api.models.StreamId
import fr.chenry.android.freshrss.utils.Try import fr.chenry.android.freshrss.utils.Try
import io.reactivex.Flowable import io.reactivex.Flowable
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import org.joda.time.LocalDateTime
typealias ItemId = String typealias ItemId = String