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": "

Today, a tiny tale

", + * "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() + } + } +}