From fa2787bee58decfbd49f20c5fb0e15e728cac7d4 Mon Sep 17 00:00:00 2001
From: Ash
Date: Fri, 19 Jan 2024 00:00:25 +0800
Subject: [PATCH] feat(greader): support google reader api (#536)
* feat(greader): support google reader api
* feat(greader): support groups and feeds
* feat(freshrss): support fresh rss api
* feat(freshrss): support fresh rss api
* feat(greader): support mark as read or starred
* feat(greader): support mark as read or starred
---
.../account/security/FreshRSSSecurityKey.kt | 22 +
.../model/general/MarkAsReadConditions.kt | 2 +-
.../ash/reader/domain/repository/FeedDao.kt | 9 +
.../domain/service/AbstractRssRepository.kt | 62 ++-
.../reader/domain/service/FeverRssService.kt | 48 ++-
.../domain/service/GoogleReaderRssService.kt | 388 ++++++++++++++++++
.../ash/reader/domain/service/RssService.kt | 5 +-
.../reader/infrastructure/rss/RssHelper.kt | 44 +-
.../rss/provider/fever/FeverAPI.kt | 4 +
.../rss/provider/greader/GoogleReaderAPI.kt | 339 +++++++++++++++
.../provider/greader/GoogleReaderApiDto.kt | 78 ----
.../rss/provider/greader/GoogleReaderDTO.kt | 180 ++++++++
.../me/ash/reader/ui/component/FeedIcon.kt | 34 +-
.../reader/ui/page/home/feeds/FeedsPage.kt | 2 +-
.../feeds/drawer/feed/FeedOptionDrawer.kt | 8 +-
.../feeds/drawer/feed/FeedOptionViewModel.kt | 13 +-
.../feeds/drawer/group/GroupOptionDrawer.kt | 6 +-
.../drawer/group/GroupOptionViewModel.kt | 2 +-
.../home/feeds/subscribe/SubscribeDialog.kt | 17 +-
.../feeds/subscribe/SubscribeViewModel.kt | 39 +-
.../ash/reader/ui/page/home/flow/FlowPage.kt | 2 +-
.../settings/accounts/AccountViewModel.kt | 9 +-
.../page/settings/accounts/AddAccountsPage.kt | 12 +-
.../addition/AddFeverAccountDialog.kt | 45 +-
.../addition/AddFreshRSSAccountDialog.kt | 149 +++++++
.../addition/AddGoogleReaderAccountDialog.kt | 150 +++++++
.../addition/AddLocalAccountDialog.kt | 14 +-
.../accounts/addition/AdditionViewModel.kt | 36 +-
.../accounts/connection/AccountConnection.kt | 4 +-
.../accounts/connection/FreshRSSConnection.kt | 132 ++++++
.../connection/GoogleReaderConnection.kt | 132 ++++++
31 files changed, 1752 insertions(+), 235 deletions(-)
create mode 100644 app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt
create mode 100644 app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt
create mode 100644 app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderAPI.kt
delete mode 100644 app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderApiDto.kt
create mode 100644 app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt
create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFreshRSSAccountDialog.kt
create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddGoogleReaderAccountDialog.kt
create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FreshRSSConnection.kt
create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/GoogleReaderConnection.kt
diff --git a/app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt b/app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt
new file mode 100644
index 000000000..8ab1f3b7e
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt
@@ -0,0 +1,22 @@
+package me.ash.reader.domain.model.account.security
+
+class FreshRSSSecurityKey private constructor() : SecurityKey() {
+
+ var serverUrl: String? = null
+ var username: String? = null
+ var password: String? = null
+
+ constructor(serverUrl: String?, username: String?, password: String?) : this() {
+ this.serverUrl = serverUrl
+ this.username = username
+ this.password = password
+ }
+
+ constructor(value: String? = DESUtils.empty) : this() {
+ decode(value, FreshRSSSecurityKey::class.java).let {
+ serverUrl = it.serverUrl
+ username = it.username
+ password = it.password
+ }
+ }
+}
diff --git a/app/src/main/java/me/ash/reader/domain/model/general/MarkAsReadConditions.kt b/app/src/main/java/me/ash/reader/domain/model/general/MarkAsReadConditions.kt
index 256ddc0a8..397d559e9 100644
--- a/app/src/main/java/me/ash/reader/domain/model/general/MarkAsReadConditions.kt
+++ b/app/src/main/java/me/ash/reader/domain/model/general/MarkAsReadConditions.kt
@@ -20,7 +20,7 @@ enum class MarkAsReadConditions {
;
fun toDate(): Date? = when (this) {
- All -> Date()
+ All -> null
else -> Calendar.getInstance().apply {
time = Date()
add(Calendar.DAY_OF_MONTH, when (this@MarkAsReadConditions) {
diff --git a/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt b/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt
index 526dee6d0..8b004c640 100644
--- a/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt
+++ b/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt
@@ -45,6 +45,15 @@ interface FeedDao {
isNotification: Boolean,
)
+ @Query(
+ """
+ SELECT * FROM feed
+ WHERE groupId = :groupId
+ AND accountId = :accountId
+ """
+ )
+ suspend fun queryByGroupId(accountId: Int, groupId: String): List
+
@Query(
"""
DELETE FROM feed
diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt
index 5728fccab..f4ba9895d 100644
--- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt
+++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt
@@ -6,6 +6,7 @@ import androidx.paging.PagingSource
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkManager
+import com.rometools.rome.feed.synd.SyndFeed
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -13,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.supervisorScope
-import me.ash.reader.domain.model.article.Article
+import me.ash.reader.domain.model.account.Account
import me.ash.reader.domain.model.article.ArticleWithFeed
import me.ash.reader.domain.model.feed.Feed
import me.ash.reader.domain.model.feed.FeedWithArticle
@@ -44,27 +45,45 @@ abstract class AbstractRssRepository(
private val dispatcherDefault: CoroutineDispatcher,
) {
- open val subscribe: Boolean = true
- open val move: Boolean = true
- open val delete: Boolean = true
- open val update: Boolean = true
+ open val addSubscription: Boolean = true
+ open val moveSubscription: Boolean = true
+ open val deleteSubscription: Boolean = true
+ open val updateSubscription: Boolean = true
- open suspend fun validCredentials(): Boolean = true
+ open suspend fun validCredentials(account: Account): Boolean = true
- open suspend fun subscribe(feed: Feed, articles: List) {
+ open suspend fun clearAuthorization() {}
+
+ open suspend fun subscribe(
+ feedLink: String, searchedFeed: SyndFeed, groupId: String,
+ isNotification: Boolean, isFullContent: Boolean
+ ) {
+ val accountId = context.currentAccountId
+ val feed = Feed(
+ id = accountId.spacerDollar(UUID.randomUUID().toString()),
+ name = searchedFeed.title!!,
+ url = feedLink,
+ groupId = groupId,
+ accountId = accountId,
+ icon = searchedFeed.icon?.link
+ )
+ val articles = searchedFeed.entries.map { rssHelper.buildArticleFromSyndEntry(feed, accountId, it) }
feedDao.insert(feed)
articleDao.insertList(articles.map {
it.copy(feedId = feed.id)
})
}
- open suspend fun addGroup(name: String): String {
+ open suspend fun addGroup(
+ destFeed: Feed?,
+ newGroupName: String
+ ): String {
context.currentAccountId.let { accountId ->
return accountId.spacerDollar(UUID.randomUUID().toString()).also {
groupDao.insert(
Group(
id = it,
- name = name,
+ name = newGroupName,
accountId = accountId
)
)
@@ -148,7 +167,10 @@ abstract class AbstractRssRepository(
val articles = rssHelper.queryRssXml(feed, latest?.link)
if (feed.icon == null) {
try {
- rssHelper.queryRssIcon(feedDao, feed)
+ val iconLink = rssHelper.queryRssIconLink(feed.url)
+ if (iconLink != null) {
+ rssHelper.saveRssIcon(feedDao, feed, iconLink)
+ }
} catch (e: Exception) {
Log.i("RLog", "queryRssIcon is failed: ${e.message}")
}
@@ -272,21 +294,33 @@ abstract class AbstractRssRepository(
suspend fun isFeedExist(url: String): Boolean = feedDao.queryByLink(context.currentAccountId, url).isNotEmpty()
- suspend fun updateGroup(group: Group) {
+ open suspend fun renameGroup(group: Group) {
groupDao.update(group)
}
- suspend fun updateFeed(feed: Feed) {
+ open suspend fun renameFeed(feed: Feed) {
+ updateFeed(feed)
+ }
+
+ open suspend fun moveFeed(originGroupId: String, feed: Feed) {
+ updateFeed(feed)
+ }
+
+ open suspend fun changeFeedUrl(feed: Feed) {
+ updateFeed(feed)
+ }
+
+ internal suspend fun updateFeed(feed: Feed) {
feedDao.update(feed)
}
- suspend fun deleteGroup(group: Group) {
+ open suspend fun deleteGroup(group: Group) {
deleteArticles(group = group)
feedDao.deleteByGroupId(context.currentAccountId, group.id)
groupDao.delete(group)
}
- suspend fun deleteFeed(feed: Feed) {
+ open suspend fun deleteFeed(feed: Feed) {
deleteArticles(feed = feed)
feedDao.delete(feed)
}
diff --git a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt
index e1d8f87fe..397ba5417 100644
--- a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt
+++ b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt
@@ -6,11 +6,13 @@ import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkManager
+import com.rometools.rome.feed.synd.SyndFeed
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import me.ash.reader.R
+import me.ash.reader.domain.model.account.Account
import me.ash.reader.domain.model.account.security.FeverSecurityKey
import me.ash.reader.domain.model.article.Article
import me.ash.reader.domain.model.article.ArticleMeta
@@ -56,10 +58,10 @@ class FeverRssService @Inject constructor(
feedDao, workManager, rssHelper, notificationHelper, ioDispatcher, defaultDispatcher
) {
- override val subscribe: Boolean = false
- override val move: Boolean = false
- override val delete: Boolean = false
- override val update: Boolean = false
+ override val addSubscription: Boolean = false
+ override val moveSubscription: Boolean = false
+ override val deleteSubscription: Boolean = false
+ override val updateSubscription: Boolean = false
private suspend fun getFeverAPI() =
FeverSecurityKey(accountDao.queryById(context.currentAccountId)!!.securityKey).run {
@@ -72,13 +74,45 @@ class FeverRssService @Inject constructor(
)
}
- override suspend fun validCredentials(): Boolean = getFeverAPI().validCredentials() > 0
+ override suspend fun validCredentials(account: Account): Boolean =
+ getFeverAPI().validCredentials() > 0
- override suspend fun subscribe(feed: Feed, articles: List) {
+ override suspend fun clearAuthorization() {
+ FeverAPI.clearInstance()
+ }
+
+ override suspend fun subscribe(
+ feedLink: String, searchedFeed: SyndFeed, groupId: String,
+ isNotification: Boolean, isFullContent: Boolean,
+ ) {
+ throw Exception("Unsupported")
+ }
+
+ override suspend fun addGroup(destFeed: Feed?, newGroupName: String): String {
+ throw Exception("Unsupported")
+ }
+
+ override suspend fun renameGroup(group: Group) {
+ throw Exception("Unsupported")
+ }
+
+ override suspend fun renameFeed(feed: Feed) {
+ throw Exception("Unsupported")
+ }
+
+ override suspend fun deleteGroup(group: Group) {
+ throw Exception("Unsupported")
+ }
+
+ override suspend fun deleteFeed(feed: Feed) {
+ throw Exception("Unsupported")
+ }
+
+ override suspend fun moveFeed(originGroupId: String, feed: Feed) {
throw Exception("Unsupported")
}
- override suspend fun addGroup(name: String): String {
+ override suspend fun changeFeedUrl(feed: Feed) {
throw Exception("Unsupported")
}
diff --git a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt
new file mode 100644
index 000000000..4c2cf10ae
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt
@@ -0,0 +1,388 @@
+package me.ash.reader.domain.service
+
+import android.content.Context
+import android.text.Html
+import android.util.Log
+import androidx.work.CoroutineWorker
+import androidx.work.ListenableWorker
+import androidx.work.WorkManager
+import com.rometools.rome.feed.synd.SyndFeed
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.supervisorScope
+import kotlinx.coroutines.withContext
+import me.ash.reader.R
+import me.ash.reader.domain.model.account.Account
+import me.ash.reader.domain.model.account.security.GoogleReaderSecurityKey
+import me.ash.reader.domain.model.article.Article
+import me.ash.reader.domain.model.article.ArticleMeta
+import me.ash.reader.domain.model.feed.Feed
+import me.ash.reader.domain.model.group.Group
+import me.ash.reader.domain.repository.AccountDao
+import me.ash.reader.domain.repository.ArticleDao
+import me.ash.reader.domain.repository.FeedDao
+import me.ash.reader.domain.repository.GroupDao
+import me.ash.reader.infrastructure.android.NotificationHelper
+import me.ash.reader.infrastructure.di.DefaultDispatcher
+import me.ash.reader.infrastructure.di.IODispatcher
+import me.ash.reader.infrastructure.di.MainDispatcher
+import me.ash.reader.infrastructure.rss.RssHelper
+import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI
+import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofCategoryIdToStreamId
+import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofCategoryStreamIdToId
+import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofFeedIdToStreamId
+import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofFeedStreamIdToId
+import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofItemStreamIdToId
+import me.ash.reader.ui.ext.currentAccountId
+import me.ash.reader.ui.ext.dollarLast
+import me.ash.reader.ui.ext.showToast
+import me.ash.reader.ui.ext.spacerDollar
+import net.dankito.readability4j.extended.Readability4JExtended
+import java.util.*
+import javax.inject.Inject
+
+class GoogleReaderRssService @Inject constructor(
+ @ApplicationContext
+ private val context: Context,
+ private val articleDao: ArticleDao,
+ private val feedDao: FeedDao,
+ private val rssHelper: RssHelper,
+ private val notificationHelper: NotificationHelper,
+ private val accountDao: AccountDao,
+ private val groupDao: GroupDao,
+ @IODispatcher
+ private val ioDispatcher: CoroutineDispatcher,
+ @MainDispatcher
+ private val mainDispatcher: CoroutineDispatcher,
+ @DefaultDispatcher
+ private val defaultDispatcher: CoroutineDispatcher,
+ private val workManager: WorkManager,
+) : AbstractRssRepository(
+ context, accountDao, articleDao, groupDao,
+ feedDao, workManager, rssHelper, notificationHelper, ioDispatcher, defaultDispatcher
+) {
+
+ override val addSubscription: Boolean = true
+ override val moveSubscription: Boolean = true
+ override val deleteSubscription: Boolean = true
+ override val updateSubscription: Boolean = true
+
+ private suspend fun getGoogleReaderAPI() =
+ GoogleReaderSecurityKey(accountDao.queryById(context.currentAccountId)!!.securityKey).run {
+ GoogleReaderAPI.getInstance(
+ serverUrl = serverUrl!!,
+ username = username!!,
+ password = password!!,
+ httpUsername = null,
+ httpPassword = null,
+ )
+ }
+
+ override suspend fun validCredentials(account: Account): Boolean {
+ return getGoogleReaderAPI().validCredentials().also { success ->
+ if (success) try {
+ getGoogleReaderAPI().getUserInfo().userName?.let {
+ accountDao.update(account.copy(name = it))
+ }
+ } catch (ignore: Exception) {
+ Log.e("RLog", "get user info is failed: ", ignore)
+ }
+ }
+ }
+
+ override suspend fun clearAuthorization() {
+ GoogleReaderAPI.clearInstance()
+ }
+
+ override suspend fun subscribe(
+ feedLink: String, searchedFeed: SyndFeed, groupId: String,
+ isNotification: Boolean, isFullContent: Boolean,
+ ) {
+ val accountId = context.currentAccountId
+ val quickAdd = getGoogleReaderAPI().subscriptionQuickAdd(feedLink)
+ val feedId = quickAdd.streamId?.ofFeedStreamIdToId()!!
+ getGoogleReaderAPI().subscriptionEdit(
+ destFeedId = feedId,
+ destCategoryId = groupId.dollarLast(),
+ destFeedName = searchedFeed.title!!
+ )
+ feedDao.insert(Feed(
+ id = accountId.spacerDollar(feedId),
+ name = searchedFeed.title!!,
+ url = feedLink,
+ groupId = groupId,
+ accountId = accountId,
+ isNotification = isNotification,
+ isFullContent = isFullContent,
+ ))
+ SyncWorker.enqueueOneTimeWork(workManager)
+ }
+
+ override suspend fun addGroup(
+ destFeed: Feed?,
+ newGroupName: String,
+ ): String {
+ val accountId = context.currentAccountId
+ getGoogleReaderAPI().subscriptionEdit(
+ destFeedId = destFeed?.id?.dollarLast(),
+ destCategoryId = newGroupName
+ )
+ val id = accountId.spacerDollar(newGroupName)
+ groupDao.insert(
+ Group(
+ id = id,
+ name = newGroupName,
+ accountId = accountId
+ )
+ )
+ return id
+ }
+
+ override suspend fun renameGroup(group: Group) {
+ getGoogleReaderAPI().renameTag(
+ categoryId = group.id.dollarLast(),
+ renameToName = group.name
+ )
+ // TODO: Whether to switch the old ID to the new ID?
+ super.renameGroup(group)
+ }
+
+ override suspend fun moveFeed(originGroupId: String, feed: Feed) {
+ getGoogleReaderAPI().subscriptionEdit(
+ destFeedId = feed.id.dollarLast(),
+ destCategoryId = feed.groupId.dollarLast(),
+ originCategoryId = originGroupId.dollarLast(),
+ )
+ super.moveFeed(originGroupId, feed)
+ }
+
+ override suspend fun changeFeedUrl(feed: Feed) {
+ throw Exception("Unsupported")
+ }
+
+ override suspend fun renameFeed(feed: Feed) {
+ getGoogleReaderAPI().subscriptionEdit(
+ destFeedId = feed.id.dollarLast(),
+ destFeedName = feed.name
+ )
+ // TODO: Whether to switch the old ID to the new ID?
+ super.renameFeed(feed)
+ }
+
+ override suspend fun deleteGroup(group: Group) {
+ feedDao.queryByGroupId(context.currentAccountId, group.id)
+ .forEach { deleteFeed(it) }
+ getGoogleReaderAPI().disableTag(group.id.dollarLast())
+ super.deleteGroup(group)
+ }
+
+ override suspend fun deleteFeed(feed: Feed) {
+ getGoogleReaderAPI().subscriptionEdit(
+ action = "unsubscribe",
+ destFeedId = feed.id.dollarLast()
+ )
+ super.deleteFeed(feed)
+ }
+
+ /**
+ * Google Reader API synchronous processing with object's ID to ensure idempotence
+ * and handle foreign key relationships such as read status, starred status, etc.
+ *
+ * 1. Fetch list of feeds and folders.
+ * 2. Fetch list of tags (it contains folders too, so you need to remove folders found in previous call to get
+ * tags).
+ * 3. Fetch ids of unread items (user can easily have 1000000 unread items so, please, add a limit on how many
+ * articles you sync, 25000 could be a good default, customizable limit is even better).
+ * 4. Fetch ids of starred items (100k starred items are possible, so, please, limit them too, 10-25k limit is a
+ * good default).
+ * 5. Fetch tagged item ids by passing s=user/-/label/TagName parameter.
+ * 6. Remove items that are no longer in unread/starred/tagged ids lists from your local database.
+ * 7. Fetch contents of items missing in database.
+ * 8. Mark/unmark items read/starred/tagged in you app comparing local state and ids you've got from the Google Reader API.
+ * Use edit-tag to sync read/starred/tagged status from your app to Google Reader API.
+ *
+ * @link https://github.com/bazqux/bazqux-api?tab=readme-ov-file
+ * @link https://github.com/theoldreader/api
+ */
+ override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result = supervisorScope {
+ coroutineWorker.setProgress(SyncWorker.setIsSyncing(true))
+
+ try {
+ val preTime = System.currentTimeMillis()
+ val accountId = context.currentAccountId
+ val account = accountDao.queryById(accountId)!!
+ val googleReaderAPI = getGoogleReaderAPI()
+ val groupIds = mutableSetOf()
+ val feedIds = mutableSetOf()
+
+ // 1. Fetch list of feeds and folders
+ googleReaderAPI.getSubscriptionList()
+ .subscriptions.groupBy { it.categories?.first() }
+ .forEach { (category, feeds) ->
+ val groupId = accountId.spacerDollar(category?.id?.ofCategoryStreamIdToId()!!)
+
+ // Handle folders
+ groupDao.insert(
+ Group(
+ id = groupId,
+ name = category.label!!,
+ accountId = accountId,
+ )
+ )
+ groupIds.add(groupId)
+
+ // Handle feeds
+ feedDao.insert(
+ *feeds.map {
+ val feedId = accountId.spacerDollar(it.id?.ofFeedStreamIdToId()!!)
+ Feed(
+ id = feedId,
+ name = it.title ?: context.getString(R.string.empty),
+ url = it.url!!,
+ groupId = groupId,
+ accountId = accountId,
+ icon = it.iconUrl
+ ).also {
+ feedIds.add(feedId)
+ }
+ }.toTypedArray()
+ )
+ }
+
+ // Remove orphaned groups and feeds
+ groupDao.queryAll(accountId)
+ .filter { it.id !in groupIds }
+ .forEach { super.deleteGroup(it) }
+ feedDao.queryAll(accountId)
+ .filter { it.id !in feedIds }
+ .forEach { super.deleteFeed(it) }
+
+ // 3. Fetch ids of unread items
+ val unreadIds = googleReaderAPI.getUnreadItemIds().itemRefs?.map { it.id }
+
+ // 4. Fetch ids of starred items
+ val starredIds = googleReaderAPI.getStarredItemIds().itemRefs?.map { it.id }
+
+ // 5. Fetch ids of read items since last month
+ val readIds = googleReaderAPI.getReadItemIds(
+ Calendar.getInstance().apply {
+ time = Date()
+ add(Calendar.DAY_OF_MONTH, -1)
+ }.time.time / 1000
+ ).itemRefs
+
+ // 6. Fetch items contents for ids
+ readIds?.map { it.id!! }?.chunked(100)?.forEach { chunkedIds ->
+ articleDao.insert(
+ *googleReaderAPI.getItemsContents(chunkedIds).items?.map {
+ val articleId = it.id!!.ofItemStreamIdToId()
+ Article(
+ id = accountId.spacerDollar(articleId),
+ date = it.published?.run { Date(this * 1000) } ?: Date(),
+ title = Html.fromHtml(it.title ?: context.getString(R.string.empty)).toString(),
+ author = it.author,
+ rawDescription = it.summary?.content ?: "",
+ shortDescription = (Readability4JExtended("", it.summary?.content ?: "")
+ .parse().textContent ?: "")
+ .take(110)
+ .trim(),
+ fullContent = it.summary?.content ?: "",
+ img = rssHelper.findImg(it.summary?.content ?: ""),
+ link = it.canonical?.first()?.href
+ ?: it.alternate?.first()?.href
+ ?: it.origin?.htmlUrl ?: "",
+ feedId = accountId.spacerDollar(it.origin?.streamId?.ofFeedStreamIdToId()
+ ?: feedIds.first()),
+ accountId = accountId,
+ isUnread = unreadIds?.contains(articleId) ?: true,
+ isStarred = starredIds?.contains(articleId) ?: false,
+ updateAt = it.crawlTimeMsec?.run { Date(this.toLong()) } ?: Date(),
+ )
+ }?.toTypedArray() ?: emptyArray()
+ )
+ }
+
+ // 7. Mark/unmark items read/starred/tagged in you app comparing
+ // local state and ids you've got from the GoogleReader
+ val articlesMeta = articleDao.queryArticleMetadataAll(accountId)
+ for (meta: ArticleMeta in articlesMeta) {
+ val articleId = meta.id.dollarLast()
+ val shouldBeUnread = unreadIds?.contains(articleId)
+ val shouldBeStarred = starredIds?.contains(articleId)
+ if (meta.isUnread != shouldBeUnread) {
+ articleDao.markAsReadByArticleId(accountId, meta.id, shouldBeUnread ?: true)
+ }
+ if (meta.isStarred != shouldBeStarred) {
+ articleDao.markAsStarredByArticleId(accountId, meta.id, shouldBeStarred ?: false)
+ }
+ }
+
+ Log.i("RLog", "onCompletion: ${System.currentTimeMillis() - preTime}")
+ accountDao.update(account.apply {
+ updateAt = Date()
+ readIds?.takeIf { it.isNotEmpty() }?.first()?.id?.let {
+ lastArticleId = accountId.spacerDollar(it)
+ }
+ })
+ ListenableWorker.Result.success(SyncWorker.setIsSyncing(false))
+ } catch (e: Exception) {
+ Log.e("RLog", "On sync exception: ${e.message}", e)
+ withContext(mainDispatcher) {
+ context.showToast(e.message)
+ }
+ ListenableWorker.Result.failure(SyncWorker.setIsSyncing(false))
+ }
+ }
+
+ override suspend fun markAsRead(
+ groupId: String?,
+ feedId: String?,
+ articleId: String?,
+ before: Date?,
+ isUnread: Boolean,
+ ) {
+ super.markAsRead(groupId, feedId, articleId, before, isUnread)
+ val googleReaderAPI = getGoogleReaderAPI()
+ val sinceTime = before?.time
+ when {
+ groupId != null -> {
+ googleReaderAPI.markAllAsRead(
+ streamId = groupId.dollarLast().ofCategoryIdToStreamId(),
+ sinceTimestamp = sinceTime
+ )
+ }
+
+ feedId != null -> {
+ // TODO: Nothing happened???
+ googleReaderAPI.markAllAsRead(
+ streamId = feedId.dollarLast().ofFeedIdToStreamId(),
+ sinceTimestamp = sinceTime
+ )
+ }
+
+ articleId != null -> {
+ googleReaderAPI.editTag(
+ itemIds = listOf(articleId.dollarLast()),
+ mark = if (!isUnread) GoogleReaderAPI.Stream.READ.tag else null,
+ unmark = if (isUnread) GoogleReaderAPI.Stream.READ.tag else null,
+ )
+ }
+
+ else -> {
+ googleReaderAPI.markAllAsRead(
+ streamId = GoogleReaderAPI.Stream.ALL_ITEMS.tag,
+ sinceTimestamp = sinceTime
+ )
+ }
+ }
+ }
+
+ override suspend fun markAsStarred(articleId: String, isStarred: Boolean) {
+ super.markAsStarred(articleId, isStarred)
+ getGoogleReaderAPI().editTag(
+ itemIds = listOf(articleId.dollarLast()),
+ mark = if (isStarred) GoogleReaderAPI.Stream.STARRED.tag else null,
+ unmark = if (!isStarred) GoogleReaderAPI.Stream.STARRED.tag else null,
+ )
+ }
+}
diff --git a/app/src/main/java/me/ash/reader/domain/service/RssService.kt b/app/src/main/java/me/ash/reader/domain/service/RssService.kt
index 1cb64009b..48fd07015 100644
--- a/app/src/main/java/me/ash/reader/domain/service/RssService.kt
+++ b/app/src/main/java/me/ash/reader/domain/service/RssService.kt
@@ -11,7 +11,7 @@ class RssService @Inject constructor(
private val context: Context,
private val localRssService: LocalRssService,
private val feverRssService: FeverRssService,
-// private val googleReaderRssRepository: GoogleReaderRssRepository,
+ private val googleReaderRssService: GoogleReaderRssService,
) {
fun get() = get(context.currentAccountType)
@@ -19,7 +19,8 @@ class RssService @Inject constructor(
fun get(accountTypeId: Int) = when (accountTypeId) {
AccountType.Local.id -> localRssService
AccountType.Fever.id -> feverRssService
- AccountType.GoogleReader.id -> localRssService
+ AccountType.GoogleReader.id -> googleReaderRssService
+ AccountType.FreshRSS.id -> googleReaderRssService
AccountType.Inoreader.id -> localRssService
AccountType.Feedly.id -> localRssService
else -> localRssService
diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt
index 8febb4bd8..ca0dff32a 100644
--- a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt
+++ b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt
@@ -5,6 +5,8 @@ import android.text.Html
import android.util.Log
import com.google.gson.Gson
import com.rometools.rome.feed.synd.SyndEntry
+import com.rometools.rome.feed.synd.SyndFeed
+import com.rometools.rome.feed.synd.SyndImageImpl
import com.rometools.rome.io.SyndFeedInput
import com.rometools.rome.io.XmlReader
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -12,7 +14,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import me.ash.reader.domain.model.article.Article
import me.ash.reader.domain.model.feed.Feed
-import me.ash.reader.domain.model.feed.FeedWithArticle
import me.ash.reader.domain.repository.FeedDao
import me.ash.reader.infrastructure.di.IODispatcher
import me.ash.reader.ui.ext.currentAccountId
@@ -37,19 +38,13 @@ class RssHelper @Inject constructor(
) {
@Throws(Exception::class)
- suspend fun searchFeed(feedLink: String): FeedWithArticle {
+ suspend fun searchFeed(feedLink: String): SyndFeed {
return withContext(ioDispatcher) {
- val accountId = context.currentAccountId
- val syndFeed = SyndFeedInput().build(XmlReader(inputStream(okHttpClient, feedLink)))
- val feed = Feed(
- id = accountId.spacerDollar(UUID.randomUUID().toString()),
- name = syndFeed.title!!,
- url = feedLink,
- groupId = "",
- accountId = accountId,
- )
- val list = syndFeed.entries.map { article(feed, context.currentAccountId, it) }
- FeedWithArticle(feed, list)
+ SyndFeedInput().build(XmlReader(inputStream(okHttpClient, feedLink))).also {
+ it.icon = SyndImageImpl()
+ it.icon.link = queryRssIconLink(feedLink)
+ it.icon.url = it.icon.link
+ }
}
}
@@ -84,7 +79,7 @@ class RssHelper @Inject constructor(
.entries
.asSequence()
.takeWhile { latestLink == null || latestLink != it.link }
- .map { article(feed, accountId, it) }
+ .map { buildArticleFromSyndEntry(feed, accountId, it) }
.toList()
}
} catch (e: Exception) {
@@ -93,7 +88,7 @@ class RssHelper @Inject constructor(
listOf()
}
- private fun article(
+ fun buildArticleFromSyndEntry(
feed: Feed,
accountId: Int,
syndEntry: SyndEntry,
@@ -141,21 +136,14 @@ class RssHelper @Inject constructor(
}
@Throws(Exception::class)
- suspend fun queryRssIcon(
- feedDao: FeedDao,
- feed: Feed,
- ) {
- withContext(ioDispatcher) {
- val request = response(okHttpClient, "https://besticon-demo.herokuapp.com/allicons.json?url=${feed.url}")
- val content = request.body.string()
- val favicon = Gson().fromJson(content, Favicon::class.java)
- favicon?.icons?.first { it.width != null && it.width >= 20 }?.url?.let {
- saveRssIcon(feedDao, feed, it)
- }?: return@withContext
- }
+ suspend fun queryRssIconLink(feedLink: String): String? {
+ val request = response(okHttpClient, "https://besticon-demo.herokuapp.com/allicons.json?url=${feedLink}")
+ val content = request.body.string()
+ val favicon = Gson().fromJson(content, Favicon::class.java)
+ return favicon?.icons?.first { it.width != null && it.width >= 20 }?.url
}
- private suspend fun saveRssIcon(feedDao: FeedDao, feed: Feed, iconLink: String) {
+ suspend fun saveRssIcon(feedDao: FeedDao, feed: Feed, iconLink: String) {
feedDao.update(
feed.apply {
icon = iconLink
diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/fever/FeverAPI.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/fever/FeverAPI.kt
index ba9e7b135..3d90ab056 100644
--- a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/fever/FeverAPI.kt
+++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/fever/FeverAPI.kt
@@ -113,5 +113,9 @@ class FeverAPI private constructor(
FeverAPI(serverUrl, this, httpUsername, httpPassword)
}
}
+
+ fun clearInstance() {
+ instances.clear()
+ }
}
}
diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderAPI.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderAPI.kt
new file mode 100644
index 000000000..138a26bd9
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderAPI.kt
@@ -0,0 +1,339 @@
+package me.ash.reader.infrastructure.rss.provider.greader
+
+import me.ash.reader.infrastructure.di.USER_AGENT_STRING
+import me.ash.reader.infrastructure.rss.provider.ProviderAPI
+import okhttp3.FormBody
+import okhttp3.Headers.Companion.toHeaders
+import okhttp3.Request
+import okhttp3.executeAsync
+import java.util.concurrent.ConcurrentHashMap
+
+class GoogleReaderAPI private constructor(
+ private val serverUrl: String,
+ private val username: String,
+ private val password: String,
+ private val httpUsername: String? = null,
+ private val httpPassword: String? = null,
+) : ProviderAPI() {
+
+ enum class Stream(val tag: String) {
+ ALL_ITEMS("user/-/state/com.google/reading-list"),
+ READ("user/-/state/com.google/read"),
+ STARRED("user/-/state/com.google/starred"),
+ LIKE("user/-/state/com.google/like"),
+ BROADCAST("user/-/state/com.google/broadcast"),
+ ;
+ }
+
+ private data class AuthData(
+ var clientLoginToken: String?,
+ var actionToken: String?,
+ )
+
+ private val authData = AuthData(null, null)
+
+ suspend fun validCredentials(): Boolean {
+ reauthenticate()
+ return authData.clientLoginToken?.isNotEmpty() ?: false
+ }
+
+ private suspend fun reauthenticate() {
+ // Get client login token
+ val clResponse = client.newCall(
+ Request.Builder()
+ .url("${serverUrl}accounts/ClientLogin")
+ .post(FormBody.Builder()
+ .add("output", "json")
+ .add("Email", username)
+ .add("Passwd", password)
+ .add("client", USER_AGENT_STRING)
+ .add("accountType", "HOSTED_OR_GOOGLE")
+ .add("service", "reader")
+ .build())
+ .build())
+ .executeAsync()
+
+ val clBody = clResponse.body.string()
+ when (clResponse.code) {
+ 400 -> throw Exception("BadRequest for CL Token")
+ 401 -> throw Exception("Unauthorized for CL Token")
+ !in 200..299 -> {
+ throw Exception(clBody)
+ }
+ }
+
+ authData.clientLoginToken = clBody
+ .split("\n")
+ .find { it.startsWith("Auth=") }
+ ?.substring(5)
+ ?: throw Exception("body format error for CL Token:\n$clBody")
+
+ // Get action token
+ val actResponse = client.newCall(
+ Request.Builder()
+ .url("${serverUrl}reader/api/0/token")
+ .header("Authorization", "GoogleLogin auth=${authData.clientLoginToken}")
+ .get()
+ .build())
+ .executeAsync()
+ val actBody = actResponse.body.string()
+ if (actResponse.code !in 200..299) {
+ // It's not used currently but may be used later the same way Google Reader uses it
+ // (expires in 30 minutes, with "x-reader-google-bad-token: true" header set).
+ }
+ authData.actionToken = actBody
+ }
+
+ class RetryException(message: String) : Exception(message)
+
+ private suspend inline fun retryableGetRequest(
+ query: String,
+ params: List>? = null,
+ ): T {
+ return try {
+ getRequest(query, params)
+ } catch (e: RetryException) {
+ authData.clientLoginToken = null
+ authData.actionToken = null
+ getRequest(query, params)
+ }
+ }
+
+ private suspend inline fun retryablePostRequest(
+ query: String,
+ params: List>? = null,
+ form: List>? = null,
+ ): T {
+ return try {
+ postRequest(query, params, form)
+ } catch (e: RetryException) {
+ authData.clientLoginToken = null
+ authData.actionToken = null
+ postRequest(query, params, form)
+ }
+ }
+
+ private suspend inline fun getRequest(
+ query: String,
+ params: List>? = null,
+ ): T {
+ if (authData.clientLoginToken.isNullOrEmpty()) {
+ reauthenticate()
+ }
+
+ val response = client.newCall(
+ Request.Builder()
+ .url("${serverUrl}${query}?output=json${params?.joinToString(separator = "") { "&${it.first}=${it.second}" } ?: ""}")
+ .header("Authorization", "GoogleLogin auth=${authData.clientLoginToken}")
+ .get()
+ .build())
+ .executeAsync()
+
+ val body = response.body.string()
+ when (response.code) {
+ 400 -> throw Exception("BadRequest")
+ 401 -> throw RetryException("Unauthorized")
+ !in 200..299 -> {
+ val gReaderError = try {
+ toDTO(body)
+ } catch (ignore: Exception) {
+ GoogleReaderDTO.GReaderError(listOf(body))
+ }
+ throw Exception(gReaderError.errors.joinToString(";\n "))
+ }
+ }
+
+ return toDTO(body)
+ }
+
+ private suspend inline fun postRequest(
+ query: String,
+ params: List>? = null,
+ form: List>? = null,
+ ): T {
+ if (authData.clientLoginToken.isNullOrEmpty()) {
+ reauthenticate()
+ }
+ val response = client.newCall(
+ Request.Builder()
+ .url("${serverUrl}${query}?output=json${params?.joinToString(separator = "") { "&${it.first}=${it.second}" } ?: ""}")
+ .headers(mapOf(
+ "Authorization" to "GoogleLogin auth=${authData.clientLoginToken}",
+ "Content-Type" to "application/x-www-form-urlencoded",
+ ).toHeaders())
+ .post(FormBody.Builder()
+ .apply {
+ form?.forEach { add(it.first, it.second) }
+ authData.actionToken?.let { add("T", it) }
+ }.build())
+ .build())
+ .executeAsync()
+
+ val responseBody = response.body.string()
+ when (response.code) {
+ 400 -> throw Exception("BadRequest")
+ 401 -> throw RetryException("Unauthorized")
+ !in 200..299 -> {
+ throw Exception(responseBody)
+ }
+ }
+
+ return toDTO(responseBody)
+ }
+
+ suspend fun getUserInfo(): GoogleReaderDTO.User =
+ retryableGetRequest("reader/api/0/user-info")
+
+ suspend fun getSubscriptionList(): GoogleReaderDTO.SubscriptionList =
+ retryableGetRequest("reader/api/0/subscription/list")
+
+ suspend fun getReadItemIds(since: Long): GoogleReaderDTO.ItemIds =
+ retryableGetRequest(
+ query = "reader/api/0/stream/items/ids",
+ params = listOf(
+ Pair("s", Stream.READ.tag),
+ Pair("ot", since.toString()),
+ Pair("n", MAXIMUM_ITEMS_LIMIT),
+ ))
+
+ suspend fun getUnreadItemIds(): GoogleReaderDTO.ItemIds =
+ retryableGetRequest(
+ query = "reader/api/0/stream/items/ids",
+ params = listOf(
+ Pair("s", Stream.ALL_ITEMS.tag),
+ Pair("xt", Stream.READ.tag),
+ Pair("n", MAXIMUM_ITEMS_LIMIT),
+ ))
+
+ suspend fun getStarredItemIds(): GoogleReaderDTO.ItemIds =
+ retryableGetRequest(
+ query = "reader/api/0/stream/items/ids",
+ params = listOf(
+ Pair("s", Stream.STARRED.tag),
+ Pair("n", MAXIMUM_ITEMS_LIMIT),
+ ))
+
+ suspend fun getItemsContents(ids: List?) =
+ retryablePostRequest(
+ query = "reader/api/0/stream/items/contents",
+ form = ids?.map {
+ Pair("i", it.ofItemIdToHexId())
+ }
+ )
+
+ suspend fun subscriptionQuickAdd(feedUrl: String): GoogleReaderDTO.QuickAddFeed =
+ retryablePostRequest(
+ query = "reader/api/0/subscription/quickadd",
+ params = listOf(Pair("quickadd", feedUrl)),
+ form = listOf(Pair("quickadd", feedUrl))
+ )
+
+ suspend fun editTag(itemIds: List, mark: String? = null, unmark: String? = null): String =
+ retryablePostRequest(
+ query = "reader/api/0/edit-tag",
+ form = mutableListOf>().apply {
+ itemIds.forEach { add(Pair("i", it.ofItemIdToStreamId())) }
+ mark?.let { add(Pair("a", mark)) }
+ unmark?.let { add(Pair("r", unmark)) }
+ }
+ )
+
+ suspend fun disableTag(categoryId: String): String =
+ retryablePostRequest(
+ query = "reader/api/0/disable-tag",
+ form = listOf(Pair("s", categoryId.ofCategoryIdToStreamId()))
+ )
+
+ suspend fun renameTag(categoryId: String, renameToName: String): String =
+ retryablePostRequest(
+ query = "reader/api/0/rename-tag",
+ form = listOf(
+ Pair("s", categoryId.ofCategoryIdToStreamId()),
+ Pair("dest", renameToName.ofCategoryIdToStreamId()),
+ )
+ )
+
+ suspend fun subscriptionEdit(
+ action: String = "edit", destFeedId: String? = null, destCategoryId: String? = null,
+ originCategoryId: String? = null, destFeedName: String? = null,
+ ): String = retryablePostRequest(
+ query = "reader/api/0/subscription/edit",
+ form = mutableListOf(Pair("ac", action)).apply {
+ destFeedId?.let { add(Pair("s", it.ofFeedIdToStreamId())) }
+ destCategoryId?.let { add(Pair("a", it.ofCategoryIdToStreamId())) }
+ originCategoryId?.let { add(Pair("r", it.ofCategoryIdToStreamId())) }
+ destFeedName?.takeIf { it.isNotBlank() }?.let { add(Pair("t", destFeedName)) }
+ }
+ )
+
+ suspend fun markAllAsRead(streamId: String, sinceTimestamp: Long? = null): String =
+ retryablePostRequest(
+ query = "reader/api/0/mark-all-as-read",
+ form = mutableListOf(
+ Pair("s", streamId),
+ ).apply {
+ sinceTimestamp?.let { add(Pair("ts", it.toString())) }
+ }
+ )
+
+ companion object {
+
+ const val MAXIMUM_ITEMS_LIMIT = "10000"
+
+ fun String.ofItemIdToHexId(): String {
+ return String.format("%016x", toLong())
+ }
+
+ fun String.ofItemHexIdToId(): String {
+ return toLong(16).toString()
+ }
+
+ fun String.ofItemStreamIdToHexId(): String {
+ return replace("tag:google.com,2005:reader/item/", "")
+ }
+
+ fun String.ofItemStreamIdToId(): String {
+ return ofItemStreamIdToHexId().ofItemHexIdToId()
+ }
+
+ fun String.ofItemHexIdToStreamId(): String {
+ return "tag:google.com,2005:reader/item/$this"
+ }
+
+ fun String.ofItemIdToStreamId(): String {
+ return "tag:google.com,2005:reader/item/${ofItemIdToHexId()}"
+ }
+
+ fun String.ofFeedIdToStreamId(): String {
+ return "feed/$this"
+ }
+
+ fun String.ofFeedStreamIdToId(): String {
+ return replace("feed/", "")
+ }
+
+ fun String.ofCategoryIdToStreamId(): String {
+ return "user/-/label/$this"
+ }
+
+ fun String.ofCategoryStreamIdToId(): String {
+ return replace("user/-/label/", "")
+ }
+
+ private val instances: ConcurrentHashMap = ConcurrentHashMap()
+
+ fun getInstance(
+ serverUrl: String,
+ username: String,
+ password: String,
+ httpUsername: String? = null,
+ httpPassword: String? = null,
+ ): GoogleReaderAPI = instances.getOrPut("$serverUrl$username$password$httpUsername$httpPassword") {
+ GoogleReaderAPI(serverUrl, username, password, httpUsername, httpPassword)
+ }
+
+ fun clearInstance() {
+ instances.clear()
+ }
+ }
+}
diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderApiDto.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderApiDto.kt
deleted file mode 100644
index 6a5de97d9..000000000
--- a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderApiDto.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package me.ash.reader.infrastructure.rss.provider.greader
-
-object GoogleReaderApiDto {
- // subscription/list?output=json
- data class SubscriptionList(
- val subscriptions: List? = null,
- )
-
- data class SubscriptionItem(
- val id: String? = null,
- val title: String? = null,
- val categories: List? = null,
- val url: String? = null,
- val htmlUrl: String? = null,
- val iconUrl: String? = null,
- )
-
- data class CategoryItem(
- val id: String? = null,
- val label: String? = null,
- )
-
- // unread-count?output=json
- data class UnreadCount(
- val max: Int? = null,
- val unreadcounts: List? = null,
- )
-
- data class UnreadCountItem(
- val id: String? = null,
- val count: Int? = null,
- val newestItemTimestampUsec: String? = null,
- )
-
- // tag/list?output=json
- data class TagList(
- val tags: List? = null,
- )
-
- data class TagItem(
- val id: String? = null,
- val type: String? = null,
- )
-
- // stream/contents/reading-list?output=json
- data class ReadingList(
- val id: String? = null,
- val updated: Long? = null,
- val items: List- ? = null,
- )
-
- data class Item(
- val id: String? = null,
- val crawlTimeMsec: String? = null,
- val timestampUsec: String? = null,
- val published: Long? = null,
- val title: String? = null,
- val summary: Summary? = null,
- val categories: List? = null,
- val origin: List? = null,
- val author: String? = null,
- )
-
- data class Summary(
- val content: String? = null,
- val canonical: List? = null,
- val alternate: List? = null,
- )
-
- data class CanonicalItem(
- val href: String? = null,
- )
-
- data class OriginItem(
- val streamId: String? = null,
- val title: String? = null,
- )
-}
diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt
new file mode 100644
index 000000000..fbcfaec60
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt
@@ -0,0 +1,180 @@
+package me.ash.reader.infrastructure.rss.provider.greader
+
+import com.google.gson.annotations.SerializedName
+
+object GoogleReaderDTO {
+
+ data class GReaderError(
+ @SerializedName("errors") val errors: List,
+ )
+
+ /**
+ * @link reader/api/0/user-info?output=json
+ * @sample
+ * {
+ * "userId": "demo",
+ * "userName": "demo",
+ * "userProfileId": "demo",
+ * "userEmail": ""
+ * }
+ */
+ data class User(
+ val userId: String?,
+ val userName: String?,
+ val userProfileId: String?,
+ val userEmail: String?,
+ )
+
+ /**
+ * @link reader/api/0/subscription/list?output=json
+ * @sample
+ * {
+ * "subscriptions": [
+ * {
+ * "id": "feed/3",
+ * "title": "Fedora Magazine",
+ * "categories": [
+ * {
+ * "id": "user/-/label/Blogs",
+ * "label": "Blogs"
+ * }
+ * ],
+ * "url": "http://fedoramagazine.org/feed/",
+ * "htmlUrl": "http://fedoramagazine.org/",
+ * "iconUrl": "https://demo.freshrss.org/f.php?f2b1439b"
+ * }
+ * ]
+ * }
+ */
+ data class SubscriptionList(
+ val subscriptions: List,
+ )
+
+ data class Feed(
+ val id: String?,
+ val title: String?,
+ val categories: List?,
+ val url: String?,
+ val htmlUrl: String?,
+ val iconUrl: String?,
+ val sortid: String?,
+ )
+
+ data class Category(
+ val id: String?,
+ val label: String?,
+ )
+
+ /**
+ * @link reader/api/0/subscription/quickadd?quickadd=https%3A%2F%2Fblog.com%2Ffeed
+ * @sample
+ * {
+ * "numResults": 1,
+ * "query": "https://blog.com/feed",
+ * "streamId": "feed/10",
+ * "streamName": "blog"
+ * }
+ *
+ */
+ data class QuickAddFeed(
+ val numResults: Long?,
+ val query: String?,
+ val streamId: String?,
+ val streamName: String?,
+ )
+
+ /**
+ * @link reader/api/0/stream/items/ids?s=user/-/state/com.google/starred&output=json
+ * @sample
+ * {
+ * "itemRefs": [
+ * {
+ * "id": "1705042807944418"
+ * }
+ * ]
+ * }
+ */
+ data class ItemIds(
+ val itemRefs: List
- ?,
+ )
+
+ /**
+ * @link reader/api/0/stream/items/contents
+ * @sample
+ * {
+ * "id": "user/-/state/com.google/reading-list",
+ * "updated": 1705045799,
+ * "items": [
+ * {
+ * "id": "tag:google.com,2005:reader/item/00060eba36e4f4e1",
+ * "crawlTimeMsec": "1705042807944",
+ * "timestampUsec": "1705042807944417",
+ * "published": 1704982200,
+ * "title": "Andy Wingo: micro macro story time",
+ * "canonical": [
+ * {
+ * "href": "https://wingolog.org/archives/2024/01/11/micro-macro-story-time"
+ * }
+ * ],
+ * "alternate": [
+ * {
+ * "href": "https://wingolog.org/archives/2024/01/11/micro-macro-story-time"
+ * }
+ * ],
+ * "categories": [
+ * "user/-/state/com.google/reading-list",
+ * "user/-/label/Blogs",
+ * "user/-/state/com.google/read"
+ * ],
+ * "origin": {
+ * "streamId": "feed/2",
+ * "htmlUrl": "https://planet.gnome.org/",
+ * "title": "Planet GNOME"
+ * },
+ * "summary": {
+ * "content": "",
+ * "expand": "\ndoesn’t sound fancy enough. In a way it’s similar to the original SSA\ndevelopers thinking that ",
+ * "phony functions": " wouldn’t get\npublished.
So Dybvig calls the expansion function ",
+ * "χ": ", because the Greek chi looks\nlike the X in ",
+ * "expand": ". Fine for the paper, whatever paper that might\nbe, but then in psyntax, there are all these functions named\nchi and chi-lambda and all sorts of nonsense.
In early years I was often confused by these names; I wasn’t in on the\npun, and I didn’t feel like I had enough responsibility for this code to\nthink what the name should be. I finally broke down and changed all\ninstances of ",
+ * "chi": " to ",
+ * "expand": " back in 2011, and never looked back.
Anyway, this is a story with a very specific moral: don’t name your\nfunctions chi.
"
+ * }
+ * }
+ * ]
+ * }
+ */
+ data class ItemsContents(
+ val id: String? = null,
+ val updated: Long? = null,
+ val items: List- ? = null,
+ )
+
+ data class Item(
+ val id: String? = null,
+ val crawlTimeMsec: String? = null,
+ val timestampUsec: String? = null,
+ val published: Long? = null,
+ val title: String? = null,
+ val summary: Summary? = null,
+ val categories: List? = null,
+ val origin: OriginItem? = null,
+ val author: String? = null,
+ val canonical: List? = null,
+ val alternate: List? = null,
+ )
+
+ data class Summary(
+ val content: String? = null,
+ )
+
+ data class CanonicalItem(
+ val href: String? = null,
+ )
+
+ data class OriginItem(
+ val streamId: String? = null,
+ val htmlUrl: String? = null,
+ val title: String? = null,
+ )
+}
diff --git a/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt b/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt
index d404a94a1..8bc02f54e 100644
--- a/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt
+++ b/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt
@@ -4,12 +4,14 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -23,20 +25,28 @@ fun FeedIcon(
feedName: String,
iconUrl: String?,
size: Dp = 20.dp,
+ placeholderIcon: ImageVector? = null,
) {
if (iconUrl == null) {
- Box(
- modifier = Modifier
- .size(size)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primary),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = feedName.ifEmpty { " " }.first().toString(),
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimary,
- fontSize = 10.sp,
+ if (placeholderIcon == null) {
+ Box(
+ modifier = Modifier
+ .size(size)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = feedName.ifEmpty { " " }.first().toString(),
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimary,
+ fontSize = 10.sp,
+ )
+ }
+ } else {
+ Icon(
+ imageVector = placeholderIcon,
+ contentDescription = feedName,
)
}
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt
index 3de0427a7..3bd96400d 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt
@@ -148,7 +148,7 @@ fun FeedsPage(
) {
if (!isSyncing) homeViewModel.sync()
}
- if (subscribeViewModel.rssService.get().subscribe) {
+ if (subscribeViewModel.rssService.get().addSubscription) {
FeedbackIconButton(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.subscribe),
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt
index c8e139a49..da0082111 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt
@@ -73,7 +73,7 @@ fun FeedOptionDrawer(
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier.roundClick {
- if (feedOptionViewModel.rssService.get().update) {
+ if (feedOptionViewModel.rssService.get().updateSubscription) {
feedOptionViewModel.showRenameDialog()
}
},
@@ -92,8 +92,8 @@ fun FeedOptionDrawer(
?: false,
selectedParseFullContentPreset = feedOptionUiState.feed?.isFullContent ?: false,
isMoveToGroup = true,
- showGroup = feedOptionViewModel.rssService.get().move,
- showUnsubscribe = feedOptionViewModel.rssService.get().delete,
+ showGroup = feedOptionViewModel.rssService.get().moveSubscription,
+ showUnsubscribe = feedOptionViewModel.rssService.get().deleteSubscription,
notSubscribeMode = true,
selectedGroupId = feedOptionUiState.feed?.groupId ?: "",
allowNotificationPresetOnClick = {
@@ -118,7 +118,7 @@ fun FeedOptionDrawer(
context.openURL(feed?.url, openLink, openLinkSpecificBrowser)
},
onFeedUrlLongClick = {
- if (feedOptionViewModel.rssService.get().update) {
+ if (feedOptionViewModel.rssService.get().updateSubscription) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
feedOptionViewModel.showFeedUrlDialog()
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt
index 098f9f12f..f87ac2f92 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt
@@ -88,7 +88,9 @@ class FeedOptionViewModel @Inject constructor(
fun addNewGroup() {
if (_feedOptionUiState.value.newGroupContent.isNotBlank()) {
viewModelScope.launch {
- selectedGroup(rssService.get().addGroup(_feedOptionUiState.value.newGroupContent))
+ selectedGroup(rssService.get().addGroup(
+ destFeed = _feedOptionUiState.value.feed,
+ newGroupName = _feedOptionUiState.value.newGroupContent))
hideNewGroupDialog()
}
}
@@ -97,7 +99,10 @@ class FeedOptionViewModel @Inject constructor(
fun selectedGroup(groupId: String) {
viewModelScope.launch(ioDispatcher) {
_feedOptionUiState.value.feed?.let {
- rssService.get().updateFeed(it.copy(groupId = groupId))
+ rssService.get().moveFeed(
+ originGroupId = it.groupId,
+ feed = it.copy(groupId = groupId)
+ )
fetchFeed(it.id)
}
}
@@ -162,7 +167,7 @@ class FeedOptionViewModel @Inject constructor(
fun renameFeed() {
_feedOptionUiState.value.feed?.let {
viewModelScope.launch {
- rssService.get().updateFeed(it.copy(name = _feedOptionUiState.value.newName))
+ rssService.get().renameFeed(it.copy(name = _feedOptionUiState.value.newName))
_feedOptionUiState.update { it.copy(renameDialogVisible = false) }
}
}
@@ -215,7 +220,7 @@ class FeedOptionViewModel @Inject constructor(
fun changeFeedUrl() {
_feedOptionUiState.value.feed?.let {
viewModelScope.launch {
- rssService.get().updateFeed(it.copy(url = _feedOptionUiState.value.newUrl))
+ rssService.get().changeFeedUrl(it.copy(url = _feedOptionUiState.value.newUrl))
_feedOptionUiState.update { it.copy(changeUrlDialogVisible = false) }
}
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt
index 9b1449677..0e5e24f6b 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionDrawer.kt
@@ -73,7 +73,7 @@ fun GroupOptionDrawer(
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier.roundClick {
- if (viewModel.rssService.get().update) {
+ if (viewModel.rssService.get().updateSubscription) {
viewModel.showRenameDialog()
}
},
@@ -106,7 +106,7 @@ fun GroupOptionDrawer(
Spacer(modifier = Modifier.height(10.dp))
Preset(viewModel, group, context)
- if (viewModel.rssService.get().move && groupOptionUiState.groups.size != 1) {
+ if (viewModel.rssService.get().moveSubscription && groupOptionUiState.groups.size != 1) {
Spacer(modifier = Modifier.height(26.dp))
Subtitle(text = stringResource(R.string.move_to_group))
Spacer(modifier = Modifier.height(10.dp))
@@ -199,7 +199,7 @@ private fun Preset(
) {
viewModel.showClearDialog()
}
- if (viewModel.rssService.get().delete && group?.id != context.currentAccountId.getDefaultGroupId()) {
+ if (viewModel.rssService.get().deleteSubscription && group?.id != context.currentAccountId.getDefaultGroupId()) {
RYSelectionChip(
modifier = Modifier.animateContentSize(),
content = stringResource(R.string.delete_group),
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt
index 2ae8ea11b..20bd78cd7 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt
@@ -162,7 +162,7 @@ class GroupOptionViewModel @Inject constructor(
fun rename() {
_groupOptionUiState.value.group?.let {
viewModelScope.launch {
- rssService.get().updateGroup(it.copy(name = _groupOptionUiState.value.newName))
+ rssService.get().renameGroup(it.copy(name = _groupOptionUiState.value.newName))
_groupOptionUiState.update { it.copy(renameDialogVisible = false) }
}
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt
index 619c5e42a..782be4e43 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CreateNewFolder
import androidx.compose.material.icons.rounded.RssFeed
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -25,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.R
+import me.ash.reader.ui.component.FeedIcon
import me.ash.reader.ui.component.RenameDialog
import me.ash.reader.ui.component.base.ClipboardTextField
import me.ash.reader.ui.component.base.RYDialog
@@ -71,9 +71,10 @@ fun SubscribeDialog(
subscribeViewModel.hideDrawer()
},
icon = {
- Icon(
- imageVector = Icons.Rounded.RssFeed,
- contentDescription = stringResource(R.string.subscribe),
+ FeedIcon(
+ feedName = subscribeUiState.searchedFeed?.title ?: stringResource(R.string.subscribe),
+ iconUrl = subscribeUiState.searchedFeed?.icon?.url,
+ placeholderIcon = Icons.Rounded.RssFeed,
)
},
title = {
@@ -83,10 +84,10 @@ fun SubscribeDialog(
subscribeViewModel.showRenameDialog()
}
},
- text = if (subscribeUiState.isSearchPage) {
- subscribeUiState.title
- } else {
- subscribeUiState.feed?.name ?: stringResource(R.string.unknown)
+ text = when {
+ subscribeUiState.isSearchPage -> subscribeUiState.title
+ subscribeUiState.searchedFeed?.title != null -> subscribeUiState.searchedFeed.title
+ else -> stringResource(R.string.unknown)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt
index 9f470b503..d735f3bdc 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt
@@ -3,17 +3,16 @@ package me.ash.reader.ui.page.home.feeds.subscribe
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.rometools.rome.feed.synd.SyndFeed
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import me.ash.reader.R
-import me.ash.reader.domain.model.article.Article
-import me.ash.reader.domain.model.feed.Feed
import me.ash.reader.domain.model.group.Group
-import me.ash.reader.infrastructure.android.AndroidStringsHelper
import me.ash.reader.domain.service.OpmlService
import me.ash.reader.domain.service.RssService
+import me.ash.reader.infrastructure.android.AndroidStringsHelper
import me.ash.reader.infrastructure.rss.RssHelper
import me.ash.reader.ui.ext.formatUrl
import java.io.InputStream
@@ -66,7 +65,8 @@ class SubscribeViewModel @Inject constructor(
fun addNewGroup() {
if (_subscribeUiState.value.newGroupContent.isNotBlank()) {
viewModelScope.launch {
- selectedGroup(rssService.get().addGroup(_subscribeUiState.value.newGroupContent))
+ // TODO: How to add a single group without no feeds via Google Reader API?
+ selectedGroup(rssService.get().addGroup(null, _subscribeUiState.value.newGroupContent))
hideNewGroupDialog()
_subscribeUiState.update { it.copy(newGroupContent = "") }
}
@@ -94,7 +94,7 @@ class SubscribeViewModel @Inject constructor(
errorMessage = "",
)
}
- _subscribeUiState.value.linkContent.formatUrl().let { str ->
+ _subscribeUiState.value.linkContent.trim().formatUrl().let { str ->
if (str != _subscribeUiState.value.linkContent) {
_subscribeUiState.update {
it.copy(
@@ -119,11 +119,9 @@ class SubscribeViewModel @Inject constructor(
}
return@launch
}
- val feedWithArticle = rssHelper.searchFeed(_subscribeUiState.value.linkContent)
_subscribeUiState.update {
it.copy(
- feed = feedWithArticle.feed,
- articles = feedWithArticle.articles,
+ searchedFeed = rssHelper.searchFeed(_subscribeUiState.value.linkContent),
)
}
switchPage(false)
@@ -143,15 +141,13 @@ class SubscribeViewModel @Inject constructor(
}
fun subscribe() {
- val feed = _subscribeUiState.value.feed ?: return
- val articles = _subscribeUiState.value.articles
viewModelScope.launch {
rssService.get().subscribe(
- feed.copy(
- groupId = _subscribeUiState.value.selectedGroupId,
- isNotification = _subscribeUiState.value.allowNotificationPreset,
- isFullContent = _subscribeUiState.value.parseFullContentPreset,
- ), articles
+ searchedFeed = _subscribeUiState.value.searchedFeed ?: return@launch,
+ feedLink = _subscribeUiState.value.linkContent,
+ groupId = _subscribeUiState.value.selectedGroupId,
+ isNotification = _subscribeUiState.value.allowNotificationPreset,
+ isFullContent = _subscribeUiState.value.parseFullContentPreset,
)
hideDrawer()
}
@@ -194,7 +190,7 @@ class SubscribeViewModel @Inject constructor(
_subscribeUiState.update {
it.copy(
renameDialogVisible = true,
- newName = _subscribeUiState.value.feed?.name ?: "",
+ newName = _subscribeUiState.value.searchedFeed?.title ?: "",
)
}
}
@@ -213,13 +209,7 @@ class SubscribeViewModel @Inject constructor(
}
fun renameFeed() {
- _subscribeUiState.value.feed?.let {
- _subscribeUiState.update {
- it.copy(
- feed = it.feed?.copy(name = _subscribeUiState.value.newName),
- )
- }
- }
+ _subscribeUiState.value.searchedFeed?.title = _subscribeUiState.value.newName
}
}
@@ -229,8 +219,7 @@ data class SubscribeUiState(
val errorMessage: String = "",
val linkContent: String = "",
val lockLinkInput: Boolean = false,
- val feed: Feed? = null,
- val articles: List = emptyList(),
+ val searchedFeed: SyndFeed? = null,
val allowNotificationPreset: Boolean = false,
val parseFullContentPreset: Boolean = false,
val selectedGroupId: String = "",
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt
index 243eecd56..9c37b13d1 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt
@@ -250,7 +250,7 @@ fun FlowPage(
feedId = null,
articleId = it.article.id,
MarkAsReadConditions.All
- )
+ )
}
item {
Spacer(modifier = Modifier.height(128.dp))
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt
index e7524aa19..4b3c2a82f 100644
--- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt
@@ -43,6 +43,7 @@ class AccountViewModel @Inject constructor(
fun update(accountId: Int, block: Account.() -> Unit) {
viewModelScope.launch(ioDispatcher) {
accountService.update(accountId, block)
+ rssService.get(accountId).clearAuthorization()
}
}
@@ -90,13 +91,13 @@ class AccountViewModel @Inject constructor(
}
}
- fun addAccount(account: Account, callback: (Account?) -> Unit = {}) {
+ fun addAccount(account: Account, callback: (account: Account?, exception: Exception?) -> Unit) {
viewModelScope.launch(ioDispatcher) {
val addAccount = accountService.addAccount(account)
try {
- if (rssService.get(addAccount.type.id).validCredentials()) {
+ if (rssService.get(addAccount.type.id).validCredentials(account)) {
withContext(mainDispatcher) {
- callback(addAccount)
+ callback(addAccount, null)
}
} else {
throw Exception("Unauthorized")
@@ -104,7 +105,7 @@ class AccountViewModel @Inject constructor(
} catch (e: Exception) {
accountService.delete(account.id!!)
withContext(mainDispatcher) {
- callback(null)
+ callback(null, e)
}
}
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt
index cd3cbbe87..75abf8e3f 100644
--- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt
@@ -23,9 +23,7 @@ import me.ash.reader.ui.component.base.FeedbackIconButton
import me.ash.reader.ui.component.base.RYScaffold
import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.page.settings.SettingItem
-import me.ash.reader.ui.page.settings.accounts.addition.AddFeverAccountDialog
-import me.ash.reader.ui.page.settings.accounts.addition.AddLocalAccountDialog
-import me.ash.reader.ui.page.settings.accounts.addition.AdditionViewModel
+import me.ash.reader.ui.page.settings.accounts.addition.*
import me.ash.reader.ui.theme.palette.onLight
@OptIn(ExperimentalAnimationApi::class)
@@ -96,21 +94,19 @@ fun AddAccountsPage(
text = stringResource(R.string.self_hosted),
)
SettingItem(
- enable = false,
title = stringResource(R.string.fresh_rss),
desc = stringResource(R.string.fresh_rss_desc),
iconPainter = painterResource(id = R.drawable.ic_freshrss),
onClick = {
-
+ additionViewModel.showAddFreshRSSAccountDialog()
},
) {}
SettingItem(
- enable = false,
title = stringResource(R.string.google_reader),
desc = stringResource(R.string.google_reader_desc),
icon = Icons.Rounded.RssFeed,
onClick = {
-
+ additionViewModel.showAddGoogleReaderAccountDialog()
},
) {}
SettingItem(
@@ -133,6 +129,8 @@ fun AddAccountsPage(
AddLocalAccountDialog(navController)
AddFeverAccountDialog(navController)
+ AddGoogleReaderAccountDialog(navController)
+ AddFreshRSSAccountDialog(navController)
}
@Preview
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt
index 2a5b1b4dd..c90afb625 100644
--- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt
@@ -1,14 +1,9 @@
package me.ash.reader.ui.page.settings.accounts.addition
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.RssFeed
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -20,6 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
@@ -49,9 +45,9 @@ fun AddFeverAccountDialog(
val focusManager = LocalFocusManager.current
val uiState = viewModel.additionUiState.collectAsStateValue()
- var serverUrl by rememberSaveable { mutableStateOf("") }
- var username by rememberSaveable { mutableStateOf("") }
- var password by rememberSaveable { mutableStateOf("") }
+ var feverServerUrl by rememberSaveable { mutableStateOf("") }
+ var feverUsername by rememberSaveable { mutableStateOf("") }
+ var feverPassword by rememberSaveable { mutableStateOf("") }
RYDialog(
modifier = Modifier.padding(horizontal = 44.dp),
@@ -63,7 +59,8 @@ fun AddFeverAccountDialog(
},
icon = {
Icon(
- imageVector = Icons.Rounded.RssFeed,
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = R.drawable.ic_fever),
contentDescription = stringResource(R.string.fever),
)
},
@@ -80,24 +77,24 @@ fun AddFeverAccountDialog(
) {
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
- value = serverUrl,
- onValueChange = { serverUrl = it },
+ value = feverServerUrl,
+ onValueChange = { feverServerUrl = it },
label = stringResource(R.string.server_url),
placeholder = "https://demo.freshrss.org/api/fever.php",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
- value = username,
- onValueChange = { username = it },
+ value = feverUsername,
+ onValueChange = { feverUsername = it },
label = stringResource(R.string.username),
placeholder = "demo",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
)
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
- value = password,
- onValueChange = { password = it },
+ value = feverPassword,
+ onValueChange = { feverPassword = it },
isPassword = true,
label = stringResource(R.string.password),
placeholder = "demodemo",
@@ -108,24 +105,24 @@ fun AddFeverAccountDialog(
},
confirmButton = {
TextButton(
- enabled = serverUrl.isNotBlank() && username.isNotEmpty() && password.isNotEmpty(),
+ enabled = feverServerUrl.isNotBlank() && feverUsername.isNotEmpty() && feverPassword.isNotEmpty(),
onClick = {
focusManager.clearFocus()
accountViewModel.addAccount(Account(
type = AccountType.Fever,
name = context.getString(R.string.fever),
securityKey = FeverSecurityKey(
- serverUrl = serverUrl,
- username = username,
- password = password,
+ serverUrl = feverServerUrl,
+ username = feverUsername,
+ password = feverPassword,
).toString(),
- )) {
- if (it == null) {
- context.showToast("Not valid credentials")
+ )) { account, exception ->
+ if (account == null) {
+ context.showToast(exception?.message ?: "Not valid credentials")
} else {
viewModel.hideAddFeverAccountDialog()
navController.popBackStack()
- navController.navigate("${RouteName.ACCOUNT_DETAILS}/${it.id}") {
+ navController.navigate("${RouteName.ACCOUNT_DETAILS}/${account.id}") {
launchSingleTop = true
}
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFreshRSSAccountDialog.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFreshRSSAccountDialog.kt
new file mode 100644
index 000000000..1cd459b5a
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFreshRSSAccountDialog.kt
@@ -0,0 +1,149 @@
+package me.ash.reader.ui.page.settings.accounts.addition
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import me.ash.reader.R
+import me.ash.reader.domain.model.account.Account
+import me.ash.reader.domain.model.account.AccountType
+import me.ash.reader.domain.model.account.security.FreshRSSSecurityKey
+import me.ash.reader.ui.component.base.RYDialog
+import me.ash.reader.ui.component.base.RYOutlineTextField
+import me.ash.reader.ui.ext.collectAsStateValue
+import me.ash.reader.ui.ext.showToast
+import me.ash.reader.ui.page.common.RouteName
+import me.ash.reader.ui.page.settings.accounts.AccountViewModel
+
+@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
+@Composable
+fun AddFreshRSSAccountDialog(
+ navController: NavHostController,
+ viewModel: AdditionViewModel = hiltViewModel(),
+ accountViewModel: AccountViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val focusManager = LocalFocusManager.current
+ val uiState = viewModel.additionUiState.collectAsStateValue()
+
+ var freshRSSServerUrl by rememberSaveable { mutableStateOf("") }
+ var freshRSSUsername by rememberSaveable { mutableStateOf("") }
+ var freshRSSPassword by rememberSaveable { mutableStateOf("") }
+
+ RYDialog(
+ modifier = Modifier.padding(horizontal = 44.dp),
+ visible = uiState.addFreshRSSAccountDialogVisible,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ onDismissRequest = {
+ focusManager.clearFocus()
+ viewModel.hideAddFreshRSSAccountDialog()
+ },
+ icon = {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = R.drawable.ic_freshrss),
+ contentDescription = stringResource(R.string.fresh_rss),
+ )
+ },
+ title = {
+ Text(
+ text = stringResource(R.string.fresh_rss),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState())
+ ) {
+ Spacer(modifier = Modifier.height(10.dp))
+ RYOutlineTextField(
+ value = freshRSSServerUrl,
+ onValueChange = { freshRSSServerUrl = it },
+ label = stringResource(R.string.server_url),
+ placeholder = "https://demo.freshrss.org/api/greader.php",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ RYOutlineTextField(
+ value = freshRSSUsername,
+ onValueChange = { freshRSSUsername = it },
+ label = stringResource(R.string.username),
+ placeholder = "demo",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ RYOutlineTextField(
+ value = freshRSSPassword,
+ onValueChange = { freshRSSPassword = it },
+ isPassword = true,
+ label = stringResource(R.string.password),
+ placeholder = "demodemo",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ }
+ },
+ confirmButton = {
+ TextButton(
+ enabled = freshRSSServerUrl.isNotBlank() && freshRSSUsername.isNotEmpty() && freshRSSPassword.isNotEmpty(),
+ onClick = {
+ focusManager.clearFocus()
+ if (!freshRSSServerUrl.endsWith("/")) {
+ freshRSSServerUrl += "/"
+ }
+ accountViewModel.addAccount(Account(
+ type = AccountType.FreshRSS,
+ name = context.getString(R.string.fresh_rss),
+ securityKey = FreshRSSSecurityKey(
+ serverUrl = freshRSSServerUrl,
+ username = freshRSSUsername,
+ password = freshRSSPassword,
+ ).toString(),
+ )) { account, exception ->
+ if (account == null) {
+ context.showToast(exception?.message ?: "Not valid credentials")
+ } else {
+ viewModel.hideAddFreshRSSAccountDialog()
+ navController.popBackStack()
+ navController.navigate("${RouteName.ACCOUNT_DETAILS}/${account.id}") {
+ launchSingleTop = true
+ }
+ }
+ }
+ }
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ focusManager.clearFocus()
+ viewModel.hideAddFreshRSSAccountDialog()
+ }
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddGoogleReaderAccountDialog.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddGoogleReaderAccountDialog.kt
new file mode 100644
index 000000000..4f0b841da
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddGoogleReaderAccountDialog.kt
@@ -0,0 +1,150 @@
+package me.ash.reader.ui.page.settings.accounts.addition
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.RssFeed
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import me.ash.reader.R
+import me.ash.reader.domain.model.account.Account
+import me.ash.reader.domain.model.account.AccountType
+import me.ash.reader.domain.model.account.security.GoogleReaderSecurityKey
+import me.ash.reader.ui.component.base.RYDialog
+import me.ash.reader.ui.component.base.RYOutlineTextField
+import me.ash.reader.ui.ext.collectAsStateValue
+import me.ash.reader.ui.ext.showToast
+import me.ash.reader.ui.page.common.RouteName
+import me.ash.reader.ui.page.settings.accounts.AccountViewModel
+
+@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
+@Composable
+fun AddGoogleReaderAccountDialog(
+ navController: NavHostController,
+ viewModel: AdditionViewModel = hiltViewModel(),
+ accountViewModel: AccountViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val focusManager = LocalFocusManager.current
+ val uiState = viewModel.additionUiState.collectAsStateValue()
+
+ var googleReaderServerUrl by rememberSaveable { mutableStateOf("") }
+ var googleReaderUsername by rememberSaveable { mutableStateOf("") }
+ var googleReaderPassword by rememberSaveable { mutableStateOf("") }
+
+ RYDialog(
+ modifier = Modifier.padding(horizontal = 44.dp),
+ visible = uiState.addGoogleReaderAccountDialogVisible,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ onDismissRequest = {
+ focusManager.clearFocus()
+ viewModel.hideAddGoogleReaderAccountDialog()
+ },
+ icon = {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = Icons.Rounded.RssFeed,
+ contentDescription = stringResource(R.string.google_reader),
+ )
+ },
+ title = {
+ Text(
+ text = stringResource(R.string.google_reader),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState())
+ ) {
+ Spacer(modifier = Modifier.height(10.dp))
+ RYOutlineTextField(
+ value = googleReaderServerUrl,
+ onValueChange = { googleReaderServerUrl = it },
+ label = stringResource(R.string.server_url),
+ placeholder = "https://demo.freshrss.org/api/greader.php",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ RYOutlineTextField(
+ value = googleReaderUsername,
+ onValueChange = { googleReaderUsername = it },
+ label = stringResource(R.string.username),
+ placeholder = "demo",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ RYOutlineTextField(
+ value = googleReaderPassword,
+ onValueChange = { googleReaderPassword = it },
+ isPassword = true,
+ label = stringResource(R.string.password),
+ placeholder = "demodemo",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ }
+ },
+ confirmButton = {
+ TextButton(
+ enabled = googleReaderServerUrl.isNotBlank() && googleReaderUsername.isNotEmpty() && googleReaderPassword.isNotEmpty(),
+ onClick = {
+ focusManager.clearFocus()
+ if (!googleReaderServerUrl.endsWith("/")) {
+ googleReaderServerUrl += "/"
+ }
+ accountViewModel.addAccount(Account(
+ type = AccountType.GoogleReader,
+ name = context.getString(R.string.google_reader),
+ securityKey = GoogleReaderSecurityKey(
+ serverUrl = googleReaderServerUrl,
+ username = googleReaderUsername,
+ password = googleReaderPassword,
+ ).toString(),
+ )) { account, exception ->
+ if (account == null) {
+ context.showToast(exception?.message ?: "Not valid credentials")
+ } else {
+ viewModel.hideAddGoogleReaderAccountDialog()
+ navController.popBackStack()
+ navController.navigate("${RouteName.ACCOUNT_DETAILS}/${account.id}") {
+ launchSingleTop = true
+ }
+ }
+ }
+ }
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ focusManager.clearFocus()
+ viewModel.hideAddGoogleReaderAccountDialog()
+ }
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt
index b3697b0a3..3b5f82aa3 100644
--- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt
@@ -1,9 +1,6 @@
package me.ash.reader.ui.page.settings.accounts.addition
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -56,6 +53,7 @@ fun AddLocalAccountDialog(
},
icon = {
Icon(
+ modifier = Modifier.size(24.dp),
imageVector = Icons.Rounded.RssFeed,
contentDescription = stringResource(R.string.local),
)
@@ -89,13 +87,13 @@ fun AddLocalAccountDialog(
accountViewModel.addAccount(Account(
type = AccountType.Local,
name = name,
- )) {
- if (it == null) {
- context.showToast("Not valid credentials")
+ )) { account, exception ->
+ if (account == null) {
+ context.showToast(exception?.message ?: "Not valid credentials")
} else {
viewModel.hideAddLocalAccountDialog()
navController.popBackStack()
- navController.navigate("${RouteName.ACCOUNT_DETAILS}/${it.id}") {
+ navController.navigate("${RouteName.ACCOUNT_DETAILS}/${account.id}") {
launchSingleTop = true
}
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AdditionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AdditionViewModel.kt
index 2108196ef..e3c0c704c 100644
--- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AdditionViewModel.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AdditionViewModel.kt
@@ -7,9 +7,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import me.ash.reader.domain.service.OpmlService
-import me.ash.reader.infrastructure.rss.RssHelper
import me.ash.reader.domain.service.RssService
import me.ash.reader.infrastructure.android.AndroidStringsHelper
+import me.ash.reader.infrastructure.rss.RssHelper
import javax.inject.Inject
@HiltViewModel
@@ -54,9 +54,43 @@ class AdditionViewModel @Inject constructor(
)
}
}
+
+ fun showAddGoogleReaderAccountDialog() {
+ _additionUiState.update {
+ it.copy(
+ addGoogleReaderAccountDialogVisible = true,
+ )
+ }
+ }
+
+ fun hideAddGoogleReaderAccountDialog() {
+ _additionUiState.update {
+ it.copy(
+ addGoogleReaderAccountDialogVisible = false,
+ )
+ }
+ }
+
+ fun showAddFreshRSSAccountDialog() {
+ _additionUiState.update {
+ it.copy(
+ addFreshRSSAccountDialogVisible = true,
+ )
+ }
+ }
+
+ fun hideAddFreshRSSAccountDialog() {
+ _additionUiState.update {
+ it.copy(
+ addFreshRSSAccountDialogVisible = false,
+ )
+ }
+ }
}
data class AdditionUiState(
val addLocalAccountDialogVisible: Boolean = false,
val addFeverAccountDialogVisible: Boolean = false,
+ val addGoogleReaderAccountDialogVisible: Boolean = false,
+ val addFreshRSSAccountDialogVisible: Boolean = false,
)
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt
index d9418368c..b775f1766 100644
--- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt
@@ -25,8 +25,8 @@ fun LazyItemScope.AccountConnection(
}
when (account.type.id) {
AccountType.Fever.id -> FeverConnection(account)
- AccountType.GoogleReader.id -> {}
- AccountType.FreshRSS.id -> {}
+ AccountType.GoogleReader.id -> GoogleReaderConnection(account)
+ AccountType.FreshRSS.id -> FreshRSSConnection(account)
AccountType.Feedly.id -> {}
AccountType.Inoreader.id -> {}
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FreshRSSConnection.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FreshRSSConnection.kt
new file mode 100644
index 000000000..6e54749d6
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FreshRSSConnection.kt
@@ -0,0 +1,132 @@
+package me.ash.reader.ui.page.settings.accounts.connection
+
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.runtime.*
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import me.ash.reader.R
+import me.ash.reader.domain.model.account.Account
+import me.ash.reader.domain.model.account.security.FreshRSSSecurityKey
+import me.ash.reader.ui.component.base.TextFieldDialog
+import me.ash.reader.ui.ext.mask
+import me.ash.reader.ui.page.settings.SettingItem
+import me.ash.reader.ui.page.settings.accounts.AccountViewModel
+
+@Composable
+fun LazyItemScope.FreshRSSConnection(
+ account: Account,
+ viewModel: AccountViewModel = hiltViewModel(),
+) {
+ val securityKey by remember {
+ derivedStateOf { FreshRSSSecurityKey(account.securityKey) }
+ }
+
+ var passwordMask by remember { mutableStateOf(securityKey.password?.mask()) }
+
+ var serverUrlValue by remember { mutableStateOf(securityKey.serverUrl) }
+ var usernameValue by remember { mutableStateOf(securityKey.username) }
+ var passwordValue by remember { mutableStateOf(securityKey.password) }
+
+ var serverUrlDialogVisible by remember { mutableStateOf(false) }
+ var usernameDialogVisible by remember { mutableStateOf(false) }
+ var passwordDialogVisible by remember { mutableStateOf(false) }
+
+ LaunchedEffect(securityKey.password) {
+ passwordMask = securityKey.password?.mask()
+ }
+
+ SettingItem(
+ title = stringResource(R.string.server_url),
+ desc = securityKey.serverUrl ?: "",
+ onClick = {
+ serverUrlDialogVisible = true
+ },
+ ) {}
+ SettingItem(
+ title = stringResource(R.string.username),
+ desc = securityKey.username ?: "",
+ onClick = {
+ usernameDialogVisible = true
+ },
+ ) {}
+ SettingItem(
+ title = stringResource(R.string.password),
+ desc = passwordMask,
+ onClick = {
+ passwordDialogVisible = true
+ },
+ ) {}
+
+ TextFieldDialog(
+ visible = serverUrlDialogVisible,
+ title = stringResource(R.string.server_url),
+ value = serverUrlValue ?: "",
+ placeholder = "https://demo.freshrss.org/api/greader.php",
+ onValueChange = {
+ serverUrlValue = it
+ },
+ onDismissRequest = {
+ serverUrlDialogVisible = false
+ },
+ onConfirm = {
+ if (securityKey.serverUrl?.isNotBlank() == true) {
+ securityKey.serverUrl = serverUrlValue
+ save(account, viewModel, securityKey)
+ serverUrlDialogVisible = false
+ }
+ }
+ )
+
+ TextFieldDialog(
+ visible = usernameDialogVisible,
+ title = stringResource(R.string.username),
+ value = usernameValue ?: "",
+ placeholder = "demo",
+ onValueChange = {
+ usernameValue = it
+ },
+ onDismissRequest = {
+ usernameDialogVisible = false
+ },
+ onConfirm = {
+ if (securityKey.username?.isNotEmpty() == true) {
+ securityKey.username = usernameValue
+ save(account, viewModel, securityKey)
+ usernameDialogVisible = false
+ }
+ }
+ )
+
+ TextFieldDialog(
+ visible = passwordDialogVisible,
+ title = stringResource(R.string.password),
+ value = passwordValue ?: "",
+ placeholder = "demodemo",
+ isPassword = true,
+ onValueChange = {
+ passwordValue = it
+ },
+ onDismissRequest = {
+ passwordDialogVisible = false
+ },
+ onConfirm = {
+ if (securityKey.password?.isNotEmpty() == true) {
+ securityKey.password = passwordValue
+ save(account, viewModel, securityKey)
+ passwordDialogVisible = false
+ }
+ }
+ )
+}
+
+private fun save(
+ account: Account,
+ viewModel: AccountViewModel,
+ securityKey: FreshRSSSecurityKey,
+) {
+ account.id?.let {
+ viewModel.update(it) {
+ this.securityKey = securityKey.toString()
+ }
+ }
+}
diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/GoogleReaderConnection.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/GoogleReaderConnection.kt
new file mode 100644
index 000000000..1980964af
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/GoogleReaderConnection.kt
@@ -0,0 +1,132 @@
+package me.ash.reader.ui.page.settings.accounts.connection
+
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.runtime.*
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import me.ash.reader.R
+import me.ash.reader.domain.model.account.Account
+import me.ash.reader.domain.model.account.security.GoogleReaderSecurityKey
+import me.ash.reader.ui.component.base.TextFieldDialog
+import me.ash.reader.ui.ext.mask
+import me.ash.reader.ui.page.settings.SettingItem
+import me.ash.reader.ui.page.settings.accounts.AccountViewModel
+
+@Composable
+fun LazyItemScope.GoogleReaderConnection(
+ account: Account,
+ viewModel: AccountViewModel = hiltViewModel(),
+) {
+ val securityKey by remember {
+ derivedStateOf { GoogleReaderSecurityKey(account.securityKey) }
+ }
+
+ var passwordMask by remember { mutableStateOf(securityKey.password?.mask()) }
+
+ var serverUrlValue by remember { mutableStateOf(securityKey.serverUrl) }
+ var usernameValue by remember { mutableStateOf(securityKey.username) }
+ var passwordValue by remember { mutableStateOf(securityKey.password) }
+
+ var serverUrlDialogVisible by remember { mutableStateOf(false) }
+ var usernameDialogVisible by remember { mutableStateOf(false) }
+ var passwordDialogVisible by remember { mutableStateOf(false) }
+
+ LaunchedEffect(securityKey.password) {
+ passwordMask = securityKey.password?.mask()
+ }
+
+ SettingItem(
+ title = stringResource(R.string.server_url),
+ desc = securityKey.serverUrl ?: "",
+ onClick = {
+ serverUrlDialogVisible = true
+ },
+ ) {}
+ SettingItem(
+ title = stringResource(R.string.username),
+ desc = securityKey.username ?: "",
+ onClick = {
+ usernameDialogVisible = true
+ },
+ ) {}
+ SettingItem(
+ title = stringResource(R.string.password),
+ desc = passwordMask,
+ onClick = {
+ passwordDialogVisible = true
+ },
+ ) {}
+
+ TextFieldDialog(
+ visible = serverUrlDialogVisible,
+ title = stringResource(R.string.server_url),
+ value = serverUrlValue ?: "",
+ placeholder = "https://demo.freshrss.org/api/greader.php",
+ onValueChange = {
+ serverUrlValue = it
+ },
+ onDismissRequest = {
+ serverUrlDialogVisible = false
+ },
+ onConfirm = {
+ if (securityKey.serverUrl?.isNotBlank() == true) {
+ securityKey.serverUrl = serverUrlValue
+ save(account, viewModel, securityKey)
+ serverUrlDialogVisible = false
+ }
+ }
+ )
+
+ TextFieldDialog(
+ visible = usernameDialogVisible,
+ title = stringResource(R.string.username),
+ value = usernameValue ?: "",
+ placeholder = "demo",
+ onValueChange = {
+ usernameValue = it
+ },
+ onDismissRequest = {
+ usernameDialogVisible = false
+ },
+ onConfirm = {
+ if (securityKey.username?.isNotEmpty() == true) {
+ securityKey.username = usernameValue
+ save(account, viewModel, securityKey)
+ usernameDialogVisible = false
+ }
+ }
+ )
+
+ TextFieldDialog(
+ visible = passwordDialogVisible,
+ title = stringResource(R.string.password),
+ value = passwordValue ?: "",
+ placeholder = "demodemo",
+ isPassword = true,
+ onValueChange = {
+ passwordValue = it
+ },
+ onDismissRequest = {
+ passwordDialogVisible = false
+ },
+ onConfirm = {
+ if (securityKey.password?.isNotEmpty() == true) {
+ securityKey.password = passwordValue
+ save(account, viewModel, securityKey)
+ passwordDialogVisible = false
+ }
+ }
+ )
+}
+
+private fun save(
+ account: Account,
+ viewModel: AccountViewModel,
+ securityKey: GoogleReaderSecurityKey,
+) {
+ account.id?.let {
+ viewModel.update(it) {
+ this.securityKey = securityKey.toString()
+ }
+ }
+}