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