From 337da06ab53b9fbdf6be8567f9167e970015134e Mon Sep 17 00:00:00 2001 From: Ash Date: Thu, 18 Jan 2024 10:06:38 +0800 Subject: [PATCH] feat(greader): support google reader api --- .../domain/service/AbstractRssRepository.kt | 24 +- .../reader/domain/service/FeverRssService.kt | 10 +- .../domain/service/GoogleReaderRssService.kt | 318 ++++++++++++++++++ .../ash/reader/domain/service/RssService.kt | 4 +- .../reader/infrastructure/rss/RssHelper.kt | 44 +-- .../rss/provider/fever/FeverAPI.kt | 4 + .../rss/provider/greader/GoogleReaderAPI.kt | 292 ++++++++++++++++ .../provider/greader/GoogleReaderApiDto.kt | 78 ----- .../rss/provider/greader/GoogleReaderDTO.kt | 180 ++++++++++ .../me/ash/reader/ui/component/FeedIcon.kt | 34 +- .../home/feeds/subscribe/SubscribeDialog.kt | 17 +- .../feeds/subscribe/SubscribeViewModel.kt | 36 +- .../settings/accounts/AccountViewModel.kt | 7 +- .../page/settings/accounts/AddAccountsPage.kt | 5 +- .../addition/AddFeverAccountDialog.kt | 39 ++- .../addition/AddGoogleReaderAccountDialog.kt | 149 ++++++++ .../addition/AddLocalAccountDialog.kt | 8 +- .../accounts/addition/AdditionViewModel.kt | 19 +- 18 files changed, 1082 insertions(+), 186 deletions(-) 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/AddGoogleReaderAccountDialog.kt 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..3274847d8 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,6 @@ 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.article.ArticleWithFeed import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.feed.FeedWithArticle @@ -51,7 +51,22 @@ abstract class AbstractRssRepository( open suspend fun validCredentials(): 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) @@ -148,7 +163,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}") } 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..cadde6a75 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,6 +6,7 @@ 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 @@ -74,7 +75,14 @@ class FeverRssService @Inject constructor( override suspend fun validCredentials(): 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") } 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..9c49c1346 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt @@ -0,0 +1,318 @@ +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.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.ofCategoryPathToId +import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofFeedPathToId +import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofItemPathToId +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 subscribe: Boolean = true + // override val move: Boolean = true + // override val delete: Boolean = true + // override val update: 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(): Boolean = getGoogleReaderAPI().validCredentials() + + 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?.ofFeedPathToId()!! + getGoogleReaderAPI().subscriptionEdit(feedId, groupId.dollarLast()) + // TODO: Support rename while adding a subscription + 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(name: String): String { + throw Exception("Unsupported") + } + + /** + * 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 + */ + 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?.ofCategoryPathToId()!!) + + // 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?.ofFeedPathToId()!!) + 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!!.ofItemPathToId() + 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?.ofFeedPathToId() ?: 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. Remove items that are no longer in unread/starred/tagged ids lists from your local database + + // 8. Fetch contents of items missing in database. + + // 9. 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 beforeUnixTimestamp = (before?.time ?: Date(Long.MAX_VALUE).time) / 1000 + when { + groupId != null -> { + // googleReaderAPI.markGroup( + // status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read, + // id = groupId.dollarLast().toLong(), + // before = beforeUnixTimestamp + // ) + } + + feedId != null -> { + // googleReaderAPI.markFeed( + // status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read, + // id = feedId.dollarLast().toLong(), + // before = beforeUnixTimestamp + // ) + } + + articleId != null -> { + // googleReaderAPI.markItem( + // status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read, + // id = articleId.dollarLast(), + // ) + } + + else -> { + feedDao.queryAll(context.currentAccountId).forEach { + // googleReaderAPI.markFeed( + // status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read, + // id = it.id.dollarLast().toLong(), + // before = beforeUnixTimestamp + // ) + } + } + } + } + + override suspend fun markAsStarred(articleId: String, isStarred: Boolean) { + super.markAsStarred(articleId, isStarred) + val googleReaderAPI = getGoogleReaderAPI() + // googleReaderAPI.markItem( + // status = if (isStarred) FeverDTO.StatusEnum.Saved else FeverDTO.StatusEnum.Unsaved, + // id = articleId.dollarLast() + // ) + } +} 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..94c30db7a 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,7 @@ 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.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..653798131 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderAPI.kt @@ -0,0 +1,292 @@ +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() { + + data class AuthData( + var clientLoginToken: String?, + var actionToken: String?, + ) + + private val authData = AuthData(null, null) + + suspend fun validCredentials(): Boolean { + reAuthentication() + return authData.clientLoginToken?.isNotEmpty() ?: false + } + + private suspend fun reAuthentication() { + // 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()) { + reAuthentication() + } + + 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()) { + reAuthentication() + } + 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", "user/-/state/com.google/read"), + 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", "user/-/state/com.google/reading-list"), + Pair("xt", "user/-/state/com.google/read"), + Pair("n", MAXIMUM_ITEMS_LIMIT), + )) + + suspend fun getStarredItemIds(): GoogleReaderDTO.ItemIds = + retryableGetRequest( + query = "reader/api/0/stream/items/ids", + params = listOf( + Pair("s", "user/-/state/com.google/starred"), + 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 subscriptionEdit(feedId: String, categoryId: String): String = + retryablePostRequest( + query = "reader/api/0/subscription/edit", + form = listOf( + Pair("ac", "edit"), + Pair("s", feedId.ofFeedIdToPath()), + Pair("a", categoryId.ofCategoryIdToPath()), + ) + ) + + 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.ofItemPathToHexId(): String { + return replace("tag:google.com,2005:reader/item/", "") + } + + fun String.ofItemPathToId(): String { + return ofItemPathToHexId().ofItemHexIdToId() + } + + fun String.ofItemHexIdToPath(): String { + return "tag:google.com,2005:reader/item/$this" + } + + fun String.ofItemIdToPath(): String { + return "tag:google.com,2005:reader/item/${ofItemIdToHexId()}" + } + + fun String.ofFeedIdToPath(): String { + return "feed/$this" + } + + fun String.ofFeedPathToId(): String { + return replace("feed/", "") + } + + fun String.ofCategoryIdToPath(): String { + return "user/-/label/$this" + } + + fun String.ofCategoryPathToId(): 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/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..92a95c80f 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 @@ -94,7 +93,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 +118,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 +140,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 +189,7 @@ class SubscribeViewModel @Inject constructor( _subscribeUiState.update { it.copy( renameDialogVisible = true, - newName = _subscribeUiState.value.feed?.name ?: "", + newName = _subscribeUiState.value.searchedFeed?.title ?: "", ) } } @@ -213,13 +208,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 +218,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/settings/accounts/AccountViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt index e7524aa19..fb956c43b 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()) { 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..b7151ca55 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 @@ -24,6 +24,7 @@ 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.AddGoogleReaderAccountDialog 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.theme.palette.onLight @@ -105,12 +106,11 @@ fun AddAccountsPage( }, ) {} 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 +133,7 @@ fun AddAccountsPage( AddLocalAccountDialog(navController) AddFeverAccountDialog(navController) + AddGoogleReaderAccountDialog(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..298368bf9 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 @@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.padding 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 +18,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 +48,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 +62,7 @@ fun AddFeverAccountDialog( }, icon = { Icon( - imageVector = Icons.Rounded.RssFeed, + painter = painterResource(id = R.drawable.ic_fever), contentDescription = stringResource(R.string.fever), ) }, @@ -80,24 +79,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 +107,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/AddGoogleReaderAccountDialog.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddGoogleReaderAccountDialog.kt new file mode 100644 index 000000000..6374559ff --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddGoogleReaderAccountDialog.kt @@ -0,0 +1,149 @@ +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.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( + 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() + 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..c35ad0146 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 @@ -89,13 +89,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..17ff9dd26 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,26 @@ class AdditionViewModel @Inject constructor( ) } } + + fun showAddGoogleReaderAccountDialog() { + _additionUiState.update { + it.copy( + addGoogleReaderAccountDialogVisible = true, + ) + } + } + + fun hideAddGoogleReaderAccountDialog() { + _additionUiState.update { + it.copy( + addGoogleReaderAccountDialogVisible = false, + ) + } + } } data class AdditionUiState( val addLocalAccountDialogVisible: Boolean = false, val addFeverAccountDialogVisible: Boolean = false, + val addGoogleReaderAccountDialogVisible: Boolean = false, )