feat: Use /sources/stats in the home (#133)

## Types of changes

- [x] I have read the **CONTRIBUTING** document.
- [x] My code follows the code style of this project.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This is **NOT** translation related.

This is implements feature #131 and it will allow implementing #132
With this, public mode functions perfectly and also has source filtering.

Co-authored-by: davidoskky <davidoskky@yahoo.it>
Reviewed-on: https://gitea.amine-louveau.fr/Louvorg/ReaderForSelfoss-multiplatform/pulls/133
Co-authored-by: davidoskky <davidoskky@hidden.hidden>
Co-committed-by: davidoskky <davidoskky@hidden.hidden>
This commit is contained in:
davidoskky 2023-03-13 16:26:54 +00:00 committed by Amine Louveau
parent 9d2cc32bc9
commit e21906e70d
9 changed files with 130 additions and 75 deletions

View File

@ -49,13 +49,13 @@ class SourcesActivity : AppCompatActivity(), DIAware {
super.onResume() super.onResume()
val mLayoutManager = LinearLayoutManager(this) val mLayoutManager = LinearLayoutManager(this)
var items: ArrayList<SelfossModel.Source> var items: ArrayList<SelfossModel.SourceDetail>
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = mLayoutManager binding.recyclerView.layoutManager = mLayoutManager
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
val response = repository.getSources() val response = repository.getSourcesDetails()
if (response.isNotEmpty()) { if (response.isNotEmpty()) {
items = response items = response
val mAdapter = SourcesListAdapter( val mAdapter = SourcesListAdapter(

View File

@ -24,7 +24,7 @@ import org.kodein.di.instance
class UpsertSourceActivity : AppCompatActivity(), DIAware { class UpsertSourceActivity : AppCompatActivity(), DIAware {
private var existingSource: SelfossModel.Source? = null private var existingSource: SelfossModel.SourceDetail? = null
private var mSpoutsValue: String? = null private var mSpoutsValue: String? = null
private lateinit var binding: ActivityUpsertSourceBinding private lateinit var binding: ActivityUpsertSourceBinding
@ -68,7 +68,7 @@ class UpsertSourceActivity : AppCompatActivity(), DIAware {
private fun initFields(items: Map<String, SelfossModel.Spout>) { private fun initFields(items: Map<String, SelfossModel.Spout>) {
binding.nameInput.setText(existingSource!!.title) binding.nameInput.setText(existingSource!!.title)
binding.tags.setText(existingSource!!.tags.joinToString(", ")) binding.tags.setText(existingSource!!.tags?.joinToString(", "))
binding.sourceUri.setText(existingSource!!.params?.url) binding.sourceUri.setText(existingSource!!.params?.url)
binding.spoutsSpinner.setSelection(items.keys.indexOf(existingSource!!.spout)) binding.spoutsSpinner.setSelection(items.keys.indexOf(existingSource!!.spout))
binding.progress.visibility = View.GONE binding.progress.visibility = View.GONE

View File

@ -31,7 +31,7 @@ import org.kodein.di.instance
class SourcesListAdapter( class SourcesListAdapter(
private val app: Activity, private val app: Activity,
private val items: ArrayList<SelfossModel.Source> private val items: ArrayList<SelfossModel.SourceDetail>
) : RecyclerView.Adapter<SourcesListAdapter.ViewHolder>(), DIAware { ) : RecyclerView.Adapter<SourcesListAdapter.ViewHolder>(), DIAware {
private val c: Context = app.baseContext private val c: Context = app.baseContext
private val generator: ColorGenerator = ColorGenerator.MATERIAL private val generator: ColorGenerator = ColorGenerator.MATERIAL
@ -61,7 +61,7 @@ class SourcesListAdapter(
c.circularBitmapDrawable(itm.getIcon(repository.baseUrl), binding.itemImage) c.circularBitmapDrawable(itm.getIcon(repository.baseUrl), binding.itemImage)
} }
if (itm.error.isNotBlank()) { if (!itm.error.isNullOrBlank()) {
binding.errorText.visibility = View.VISIBLE binding.errorText.visibility = View.VISIBLE
binding.errorText.text = itm.error binding.errorText.text = itm.error
} else { } else {

View File

@ -82,7 +82,7 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
) { ) {
val sourceGroup = binding.sourcesGroup val sourceGroup = binding.sourcesGroup
repository.getSources().forEach { source -> repository.getSourcesDetailsOrStats().forEach { source ->
val c = Chip(context) val c = Chip(context)
c.ellipsize = TextUtils.TruncateAt.END c.ellipsize = TextUtils.TruncateAt.END
@ -127,9 +127,9 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
selectedChip = c selectedChip = c
} }
c.isEnabled = source.error.isBlank() c.isEnabled = source.error.isNullOrBlank()
if (source.error.isNotBlank() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!source.error.isNullOrBlank() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
c.tooltipText = source.error c.tooltipText = source.error
} }

View File

@ -300,9 +300,10 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
repository.setSourceFilter(SelfossModel.Source( repository.setSourceFilter(SelfossModel.SourceDetail(
1, 1,
"Test", "Test",
null,
listOf("tags"), listOf("tags"),
SPOUT, SPOUT,
"", "",
@ -609,30 +610,32 @@ class RepositoryTest {
fun get_sources() { fun get_sources() {
val (sources, sourcesDB) = prepareSources() val (sources, sourcesDB) = prepareSources()
initializeRepository() initializeRepository()
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
assertSame(sources, testSources) assertEquals(sources, testSources)
assertNotEquals(sourcesDB.map { it.toView() }, testSources) assertNotEquals(sourcesDB.map { it.toView() }, testSources)
coVerify(exactly = 1) { api.sources() } coVerify(exactly = 1) { api.sourcesDetailed() }
} }
private fun prepareSources(): Pair<ArrayList<SelfossModel.Source>, List<SOURCE>> { private fun prepareSources(): Pair<ArrayList<SelfossModel.SourceDetail>, List<SOURCE>> {
val sources = arrayListOf( val sources = arrayListOf(
SelfossModel.Source( SelfossModel.SourceDetail(
1, 1,
"First source", "First source",
null,
listOf("Test", "second"), listOf("Test", "second"),
SPOUT, SPOUT,
"", "",
IMAGE_URL_2, IMAGE_URL_2,
SelfossModel.SourceParams("url") SelfossModel.SourceParams("url")
), ),
SelfossModel.Source( SelfossModel.SourceDetail(
2, 2,
"Second source", "Second source",
null,
listOf("second"), listOf("second"),
SPOUT, SPOUT,
"", "",
@ -661,7 +664,7 @@ class RepositoryTest {
) )
) )
coEvery { api.sources() } returns StatusAndData(success = true, data = sources) coEvery { api.sourcesDetailed() } returns StatusAndData(success = true, data = sources)
every { db.sourcesQueries.sources().executeAsList() } returns sourcesDB every { db.sourcesQueries.sources().executeAsList() } returns sourcesDB
return Pair(sources, sourcesDB) return Pair(sources, sourcesDB)
} }
@ -675,13 +678,13 @@ class RepositoryTest {
initializeRepository() initializeRepository()
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>?
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
// Sources will be fetched from the database on the second call, thus testSources != sources // Sources will be fetched from the database on the second call, thus testSources != sources
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
coVerify(exactly = 1) { api.sources() } coVerify(exactly = 1) { api.sourcesDetailed() }
assertNotSame(sources, testSources) assertNotEquals(sources, testSources)
assertEquals(sourcesDB.map { it.toView() }, testSources) assertEquals(sourcesDB.map { it.toView() }, testSources)
verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() } verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() }
} }
@ -693,13 +696,13 @@ class RepositoryTest {
every { appSettingsService.isUpdateSourcesEnabled() } returns true every { appSettingsService.isUpdateSourcesEnabled() } returns true
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
initializeRepository() initializeRepository()
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
assertSame(sources, testSources) assertEquals(sources, testSources)
coVerify(exactly = 1) { api.sources() } coVerify(exactly = 1) { api.sourcesDetailed() }
verify(exactly = 0) { db.sourcesQueries } verify(exactly = 0) { db.sourcesQueries }
} }
@ -710,13 +713,13 @@ class RepositoryTest {
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
initializeRepository() initializeRepository()
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
assertSame(sources, testSources) assertEquals(sources, testSources)
coVerify(exactly = 1) { api.sources() } coVerify(exactly = 1) { api.sourcesDetailed() }
verify(atLeast = 1) { db.sourcesQueries } verify(atLeast = 1) { db.sourcesQueries }
} }
@ -724,13 +727,13 @@ class RepositoryTest {
fun get_sources_without_connection() { fun get_sources_without_connection() {
val (_, sourcesDB) = prepareSources() val (_, sourcesDB) = prepareSources()
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
assertEquals(sourcesDB.map { it.toView() }, testSources) assertEquals(sourcesDB.map { it.toView() }, testSources)
coVerify(exactly = 0) { api.sources() } coVerify(exactly = 0) { api.sourcesDetailed() }
verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() } verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() }
} }
@ -741,13 +744,13 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
every { appSettingsService.isUpdateSourcesEnabled() } returns true every { appSettingsService.isUpdateSourcesEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
assertEquals(emptyList<SelfossModel.Source>(), testSources) assertEquals(emptyList<SelfossModel.Source>(), testSources)
coVerify(exactly = 0) { api.sources() } coVerify(exactly = 0) { api.sourcesDetailed() }
verify(exactly = 0) { db.sourcesQueries.sources().executeAsList() } verify(exactly = 0) { db.sourcesQueries.sources().executeAsList() }
} }
@ -758,13 +761,13 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
assertEquals(sourcesDB.map { it.toView() }, testSources) assertEquals(sourcesDB.map { it.toView() }, testSources)
coVerify(exactly = 0) { api.sources() } coVerify(exactly = 0) { api.sourcesDetailed() }
verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() } verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() }
} }
@ -775,13 +778,13 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
var testSources: List<SelfossModel.Source>? var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSources() testSources = repository.getSourcesDetails()
} }
assertEquals(sourcesDB.map { it.toView() }, testSources) assertEquals(sourcesDB.map { it.toView() }, testSources)
coVerify(exactly = 0) { api.sources() } coVerify(exactly = 0) { api.sourcesDetailed() }
verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() } verify(atLeast = 1) { db.sourcesQueries.sources().executeAsList() }
} }
@ -1102,9 +1105,10 @@ class RepositoryTest {
private fun prepareSearch() { private fun prepareSearch() {
repository.setTagFilter(SelfossModel.Tag("Tag", "read", 0)) repository.setTagFilter(SelfossModel.Tag("Tag", "read", 0))
repository.setSourceFilter( repository.setSourceFilter(
SelfossModel.Source( SelfossModel.SourceDetail(
1, 1,
"First source", "First source",
5,
listOf("Test", "second"), listOf("Test", "second"),
SPOUT, SPOUT,
"", "",

View File

@ -63,17 +63,36 @@ class SelfossModel {
fun isPublicModeEnabled() = publicMode ?: false fun isPublicModeEnabled() = publicMode ?: false
} }
interface Source {
val id: Int
var title: String
var unread: Int?
var error: String?
var icon: String?
}
@Serializable @Serializable
data class Source( data class SourceStats(
val id: Int, override val id: Int,
val title: String, override var title: String,
override var unread: Int?,
override var error: String? = null,
override var icon: String? = null
) : Source
@Serializable
data class SourceDetail(
override val id: Int,
override var title: String,
override var unread: Int? = null,
@Serializable(with = TagsListSerializer::class) @Serializable(with = TagsListSerializer::class)
val tags: List<String>, var tags: List<String>?,
val spout: String, var spout: String?,
val error: String, override var error: String?,
val icon: String?, override var icon: String?,
val params: SourceParams? var params: SourceParams?
) ) : Source
@Serializable @Serializable
data class SourceParams( data class SourceParams(
val url: String? = null val url: String? = null

View File

@ -44,11 +44,11 @@ class Repository(
private val _badgeStarred = MutableStateFlow(0) private val _badgeStarred = MutableStateFlow(0)
val badgeStarred = _badgeStarred.asStateFlow() val badgeStarred = _badgeStarred.asStateFlow()
private var fetchedSources = false
private var fetchedTags = false private var fetchedTags = false
private var fetchedSources = false
private var _readerItems = ArrayList<SelfossModel.Item>() private var _readerItems = ArrayList<SelfossModel.Item>()
private var _selectedSource: SelfossModel.Source? = null private var _selectedSource: SelfossModel.SourceDetail? = null
suspend fun getNewerItems(): ArrayList<SelfossModel.Item> { suspend fun getNewerItems(): ArrayList<SelfossModel.Item> {
var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error() var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error()
@ -180,23 +180,46 @@ class Repository(
} }
} }
suspend fun getSources(): ArrayList<SelfossModel.Source> { suspend fun getSourcesDetailsOrStats(): ArrayList<SelfossModel.Source> {
var sources = ArrayList<SelfossModel.Source>()
val isDatabaseEnabled = val isDatabaseEnabled =
appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled() appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled()
return if (isNetworkAvailable() && !fetchedSources) { val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true
val apiSources = api.sources() if (shouldFetch && isNetworkAvailable()) {
if (apiSources.success && apiSources.data != null && isDatabaseEnabled) { if (appSettingsService.getPublicAccess()) {
resetDBSourcesWithData(apiSources.data) val apiSources = api.sourcesStats()
if (!appSettingsService.isUpdateSourcesEnabled()) { if (apiSources.success && apiSources.data != null) {
fetchedSources = true fetchedSources = true
sources = apiSources.data as ArrayList<SelfossModel.Source>
} }
}
apiSources.data ?: ArrayList()
} else if (isDatabaseEnabled) {
ArrayList(getDBSources().map { it.toView() })
} else { } else {
ArrayList() sources = getSourcesDetails() as ArrayList<SelfossModel.Source>
} }
} else if (isDatabaseEnabled) {
sources = getDBSources().map { it.toView() } as ArrayList<SelfossModel.Source>
}
return sources
}
suspend fun getSourcesDetails(): ArrayList<SelfossModel.SourceDetail> {
var sources = ArrayList<SelfossModel.SourceDetail>()
val isDatabaseEnabled =
appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled()
val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true
if (shouldFetch && isNetworkAvailable()) {
val apiSources = api.sourcesDetailed()
if (apiSources.success && apiSources.data != null) {
fetchedSources = true
sources = apiSources.data
if (isDatabaseEnabled) {
resetDBSourcesWithData(sources)
}
}
} else if (isDatabaseEnabled) {
sources = getDBSources().map { it.toView() } as ArrayList<SelfossModel.SourceDetail>
}
return sources
} }
suspend fun markAsRead(item: SelfossModel.Item): Boolean { suspend fun markAsRead(item: SelfossModel.Item): Boolean {
@ -482,7 +505,7 @@ class Repository(
} }
} }
private fun resetDBSourcesWithData(sources: List<SelfossModel.Source>) { private fun resetDBSourcesWithData(sources: List<SelfossModel.SourceDetail>) {
db.sourcesQueries.deleteAllSources() db.sourcesQueries.deleteAllSources()
db.sourcesQueries.transaction { db.sourcesQueries.transaction {
@ -592,7 +615,7 @@ class Repository(
ReaderForSelfossDB.Schema.migrate(driverFactory.createDriver(), 0, 1) ReaderForSelfossDB.Schema.migrate(driverFactory.createDriver(), 0, 1)
} }
fun setSelectedSource(source: SelfossModel.Source) { fun setSelectedSource(source: SelfossModel.SourceDetail) {
_selectedSource = source _selectedSource = source
} }
@ -600,7 +623,7 @@ class Repository(
_selectedSource = null _selectedSource = null
} }
fun getSelectedSource(): SelfossModel.Source? { fun getSelectedSource(): SelfossModel.SourceDetail? {
return _selectedSource return _selectedSource
} }
} }

View File

@ -183,7 +183,15 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
} }
}) })
suspend fun sources(): StatusAndData<ArrayList<SelfossModel.Source>> = suspend fun sourcesStats(): StatusAndData<ArrayList<SelfossModel.SourceStats>> =
bodyOrFailure(client.tryToGet(url("/sources/stats")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
})
suspend fun sourcesDetailed(): StatusAndData<ArrayList<SelfossModel.SourceDetail>> =
bodyOrFailure(client.tryToGet(url("/sources/list")) { bodyOrFailure(client.tryToGet(url("/sources/list")) {
if (!shouldHavePostLogin()) { if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName()) parameter("username", appSettingsService.getUserName())

View File

@ -12,24 +12,25 @@ fun TAG.toView(): SelfossModel.Tag =
this.unread.toInt() this.unread.toInt()
) )
fun SOURCE.toView(): SelfossModel.Source = fun SOURCE.toView(): SelfossModel.SourceDetail =
SelfossModel.Source( SelfossModel.SourceDetail(
this.id.toInt(), this.id.toInt(),
this.title, this.title,
this.tags.split(","), null,
this.tags?.split(","),
this.spout, this.spout,
this.error, this.error,
this.icon, this.icon,
if (this.url != null) SelfossModel.SourceParams(this.url) else null if (this.url != null) SelfossModel.SourceParams(this.url) else null
) )
fun SelfossModel.Source.toEntity(): SOURCE = fun SelfossModel.SourceDetail.toEntity(): SOURCE =
SOURCE( SOURCE(
this.id.toString(), this.id.toString(),
this.title.getHtmlDecoded(), this.title.getHtmlDecoded(),
this.tags.joinToString(","), this.tags?.joinToString(",").orEmpty(),
this.spout, this.spout.orEmpty(),
this.error, this.error.orEmpty(),
this.icon.orEmpty(), this.icon.orEmpty(),
this.params?.url this.params?.url
) )