From 1b2c8f982156e4b086ad33ab6cdc7b5a6feea4c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 13:30:48 +0000 Subject: [PATCH 1/2] Add Feedly integration and support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full Feedly cloud RSS reader integration following the same architecture patterns as the existing Fever and Google Reader providers. New files: - FeedlyAPIException – exception class for Feedly API errors - FeedlySecurityKey – DES-encrypted credential storage (accessToken + userId) - FeedlyDTO – data transfer objects for Feedly REST API responses - FeedlyAPI – OkHttp REST client for cloud.feedly.com/v3/ (subscriptions, collections, stream contents, markers for read/unread/saved state) - FeedlyRssService – AbstractRssRepository implementation with full sync (collections→groups, subscriptions→feeds, stream pagination with newerThan, read/starred status, orphan cleanup, notifications) - FeedlyConnection – settings UI composable for entering the developer access token Updated files: - RssService: route AccountType.Feedly.id → FeedlyRssService - AccountConnection: render FeedlyConnection for Feedly accounts - strings.xml: add feedly_access_token and feedly_access_token_hint https://claude.ai/code/session_013Muau5j88pVaYL377NBcRB --- .../account/security/FeedlySecurityKey.kt | 19 + .../reader/domain/service/FeedlyRssService.kt | 434 ++++++++++++++++++ .../ash/reader/domain/service/RssService.kt | 3 +- .../exception/FeedlyAPIException.kt | 14 + .../rss/provider/feedly/FeedlyAPI.kt | 240 ++++++++++ .../rss/provider/feedly/FeedlyDTO.kt | 93 ++++ .../accounts/connection/AccountConnection.kt | 2 +- .../accounts/connection/FeedlyConnection.kt | 60 +++ app/src/main/res/values/strings.xml | 2 + 9 files changed, 865 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/domain/model/account/security/FeedlySecurityKey.kt create mode 100644 app/src/main/java/me/ash/reader/domain/service/FeedlyRssService.kt create mode 100644 app/src/main/java/me/ash/reader/infrastructure/exception/FeedlyAPIException.kt create mode 100644 app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyAPI.kt create mode 100644 app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyDTO.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeedlyConnection.kt diff --git a/app/src/main/java/me/ash/reader/domain/model/account/security/FeedlySecurityKey.kt b/app/src/main/java/me/ash/reader/domain/model/account/security/FeedlySecurityKey.kt new file mode 100644 index 000000000..bf934ac75 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/model/account/security/FeedlySecurityKey.kt @@ -0,0 +1,19 @@ +package me.ash.reader.domain.model.account.security + +class FeedlySecurityKey private constructor() : SecurityKey() { + + var accessToken: String? = null + var userId: String? = null + + constructor(accessToken: String?, userId: String?) : this() { + this.accessToken = accessToken + this.userId = userId + } + + constructor(value: String? = DESUtils.empty) : this() { + decode(value, FeedlySecurityKey::class.java).let { + accessToken = it.accessToken + userId = it.userId + } + } +} diff --git a/app/src/main/java/me/ash/reader/domain/service/FeedlyRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeedlyRssService.kt new file mode 100644 index 000000000..580d73aab --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/service/FeedlyRssService.kt @@ -0,0 +1,434 @@ +package me.ash.reader.domain.service + +import android.content.Context +import android.util.Log +import androidx.compose.ui.util.fastFilter +import androidx.work.ListenableWorker +import androidx.work.WorkManager +import com.rometools.rome.feed.synd.SyndFeed +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Date +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.coroutineScope +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.FeedlySecurityKey +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.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.exception.FeedlyAPIException +import me.ash.reader.infrastructure.html.Readability +import me.ash.reader.infrastructure.rss.RssHelper +import me.ash.reader.infrastructure.rss.provider.feedly.FeedlyAPI +import me.ash.reader.ui.ext.decodeHTML +import me.ash.reader.ui.ext.dollarLast +import me.ash.reader.ui.ext.isFuture +import me.ash.reader.ui.ext.spacerDollar + +private const val TAG = "FeedlyRssService" + +class FeedlyRssService +@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 groupDao: GroupDao, + @IODispatcher private val ioDispatcher: CoroutineDispatcher, + @MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + workManager: WorkManager, + private val accountService: AccountService, +) : + AbstractRssRepository( + articleDao, + groupDao, + feedDao, + workManager, + rssHelper, + notificationHelper, + ioDispatcher, + defaultDispatcher, + accountService, + ) { + + override val importSubscription: Boolean = false + override val addSubscription: Boolean = true + override val moveSubscription: Boolean = true + override val deleteSubscription: Boolean = true + override val updateSubscription: Boolean = true + + private suspend fun getFeedlyAPI(): FeedlyAPI = + FeedlySecurityKey(accountService.getCurrentAccount().securityKey).run { + FeedlyAPI.getInstance( + context = context, + accessToken = accessToken ?: throw FeedlyAPIException("Access token is not set"), + ) + } + + override suspend fun validCredentials(account: Account): Boolean = + try { + val key = FeedlySecurityKey(account.securityKey) + if (key.accessToken.isNullOrBlank()) return false + val api = FeedlyAPI.getInstance(context, key.accessToken!!) + val profile = api.getProfile() + val userId = profile.id + if (!userId.isNullOrBlank() && userId != key.userId) { + accountService.update(account.copy(securityKey = FeedlySecurityKey(key.accessToken, userId).toString())) + } + true + } catch (e: Exception) { + Log.e(TAG, "validCredentials failed: ${e.message}", e) + false + } + + override suspend fun clearAuthorization() { + FeedlyAPI.clearInstance() + } + + override suspend fun subscribe( + feedLink: String, + searchedFeed: SyndFeed, + groupId: String, + isNotification: Boolean, + isFullContent: Boolean, + isBrowser: Boolean, + ) { + val accountId = accountService.getCurrentAccountId() + val key = FeedlySecurityKey(accountService.getCurrentAccount().securityKey) + val userId = key.userId ?: throw FeedlyAPIException("User ID is not available") + val api = getFeedlyAPI() + + val feedId = "feed/$feedLink" + val categoryId = "user/$userId/category/${groupId.dollarLast()}" + + val subscription = + api.addSubscription( + feedId = feedId, + title = searchedFeed.title, + categoryId = categoryId, + ) + + feedDao.insert( + Feed( + id = accountId.spacerDollar(feedId), + name = subscription.title ?: searchedFeed.title ?: context.getString(R.string.empty), + url = feedLink, + groupId = groupId, + accountId = accountId, + icon = subscription.iconUrl, + isNotification = isNotification, + isFullContent = isFullContent, + isBrowser = isBrowser, + ) + ) + } + + override suspend fun addGroup(destFeed: Feed?, newGroupName: String): String { + val accountId = accountService.getCurrentAccountId() + val key = FeedlySecurityKey(accountService.getCurrentAccount().securityKey) + val userId = key.userId ?: throw FeedlyAPIException("User ID is not available") + val categoryId = "user/$userId/category/$newGroupName" + val id = accountId.spacerDollar(categoryId) + groupDao.insert(Group(id = id, name = newGroupName, accountId = accountId)) + return id + } + + override suspend fun renameFeed(feed: Feed) { + super.renameFeed(feed) + } + + override suspend fun moveFeed(originGroupId: String, feed: Feed) { + val api = getFeedlyAPI() + val key = FeedlySecurityKey(accountService.getCurrentAccount().securityKey) + val userId = key.userId ?: throw FeedlyAPIException("User ID is not available") + val feedId = feed.id.dollarLast() + val newCategoryId = "user/$userId/category/${feed.groupId.dollarLast()}" + api.addSubscription(feedId = feedId, title = feed.name, categoryId = newCategoryId) + super.moveFeed(originGroupId, feed) + } + + override suspend fun changeFeedUrl(feed: Feed) { + throw FeedlyAPIException("Unsupported") + } + + override suspend fun deleteGroup(group: Group, onlyDeleteNoStarred: Boolean?) { + feedDao.queryByGroupId(accountService.getCurrentAccountId(), group.id).forEach { + deleteFeed(it, onlyDeleteNoStarred) + } + super.deleteGroup(group, false) + } + + override suspend fun deleteFeed(feed: Feed, onlyDeleteNoStarred: Boolean?) { + getFeedlyAPI().deleteSubscription(feed.id.dollarLast()) + super.deleteFeed(feed, false) + } + + /** + * Feedly sync strategy: + * 1. Fetch collections (categories/folders) and upsert groups + * 2. Fetch subscriptions and upsert feeds + * 3. Fetch stream contents for global.all since last sync, paginating with continuation tokens + * 4. Insert new articles; read/starred status comes directly from each stream item + * 5. Remove orphaned groups and feeds + */ + override suspend fun sync( + accountId: Int, + feedId: String?, + groupId: String?, + ): ListenableWorker.Result = coroutineScope { + try { + val preTime = System.currentTimeMillis() + val preDate = Date(preTime) + val account = accountService.getAccountById(accountId)!! + check(account.type.id == AccountType.Feedly.id) { "account type is invalid" } + + val key = FeedlySecurityKey(account.securityKey) + val accessToken = + key.accessToken ?: throw FeedlyAPIException("Access token is not set") + val userId = key.userId ?: run { + val profile = FeedlyAPI.getInstance(context, accessToken).getProfile() + val id = profile.id ?: throw FeedlyAPIException("Unable to retrieve user ID") + accountService.update( + account.copy( + securityKey = FeedlySecurityKey(accessToken, id).toString() + ) + ) + id + } + val feedlyAPI = FeedlyAPI.getInstance(context, accessToken) + + // 1. Fetch collections (categories/folders) + val collections = feedlyAPI.getCollections() + val remoteGroupIds = mutableSetOf() + + val groups = + collections.mapNotNull { collection -> + val id = collection.id ?: return@mapNotNull null + remoteGroupIds.add(accountId.spacerDollar(id)) + Group( + id = accountId.spacerDollar(id), + name = collection.label ?: context.getString(R.string.empty), + accountId = accountId, + ) + } + groupDao.insertOrUpdate(groups) + + // 2. Fetch subscriptions (feeds) and assign to groups + val subscriptions = feedlyAPI.getSubscriptions() + val remoteFeedIds = mutableSetOf() + + val feeds = + subscriptions.mapNotNull { subscription -> + val feedId = subscription.id ?: return@mapNotNull null + val firstCategoryId = subscription.categories?.firstOrNull()?.id + val groupDbId = + if (firstCategoryId != null) { + accountId.spacerDollar(firstCategoryId) + } else { + // If no category, skip — feed must be in a group + return@mapNotNull null + } + remoteFeedIds.add(accountId.spacerDollar(feedId)) + Feed( + id = accountId.spacerDollar(feedId), + name = subscription.title?.decodeHTML() + ?: context.getString(R.string.empty), + url = feedId.removePrefix("feed/"), + groupId = groupDbId, + accountId = accountId, + icon = subscription.iconUrl, + ) + } + feedDao.insertOrUpdate(feeds) + + // Handle feeds with no icon + val noIconFeeds = feedDao.queryNoIcon(accountId) + feedDao.update( + *noIconFeeds + .map { it.copy(icon = rssHelper.queryRssIconLink(it.url)) } + .toTypedArray() + ) + + // 3. Fetch stream contents for all articles since last sync + val allStreamId = "user/$userId/category/global.all" + val newerThan = account.updateAt?.time + + val allArticles = mutableListOf
() + var continuation: String? = null + val maxBatches = 40 + + repeat(maxBatches) { _ -> + val streamContents = + feedlyAPI.getStreamContents( + streamId = allStreamId, + count = 250, + continuation = continuation, + newerThan = newerThan, + ) + + val items = streamContents.items + if (items.isNullOrEmpty()) { + return@repeat + } + + items.forEach { item -> + val entryId = item.id ?: return@forEach + val feedIdRaw = item.origin?.feedId ?: return@forEach + val link = + item.canonical?.firstOrNull()?.href + ?: item.alternate?.firstOrNull()?.href + ?: "" + val content = item.content?.content ?: item.summary?.content ?: "" + val isStarred = + item.tags?.any { it.id?.contains("global.saved") == true } == true + + allArticles.add( + Article( + id = accountId.spacerDollar(entryId), + date = + item.published + ?.let { Date(it) } + ?.takeIf { !it.isFuture(preDate) } ?: preDate, + title = + item.title?.decodeHTML() + ?: context.getString(R.string.empty), + author = item.author, + rawDescription = content, + shortDescription = + Readability.parseToText(content, link).take(280), + img = + item.visual?.url + ?: item.thumbnail?.firstOrNull()?.url + ?: rssHelper.findThumbnail(content), + link = link, + feedId = accountId.spacerDollar(feedIdRaw), + accountId = accountId, + isUnread = item.unread != false, + isStarred = isStarred, + updateAt = preDate, + ) + ) + } + + continuation = streamContents.continuation + if (continuation == null) return@repeat + } + + if (allArticles.isNotEmpty()) { + articleDao.insert(*allArticles.toTypedArray()) + val notificationFeeds = + feedDao.queryNotificationEnabled(accountId).associateBy { it.id } + val notificationFeedIds = notificationFeeds.keys + allArticles + .fastFilter { it.isUnread && it.feedId in notificationFeedIds } + .groupBy { it.feedId } + .mapKeys { (feedId, _) -> notificationFeeds[feedId]!! } + .forEach { (feed, articles) -> notificationHelper.notify(feed, articles) } + } + + // 5. Remove orphaned groups and feeds + groupDao.queryAll(accountId).forEach { + if (!remoteGroupIds.contains(it.id)) { + super.deleteGroup(it, true) + } + } + feedDao.queryAll(accountId).forEach { + if (!remoteFeedIds.contains(it.id)) { + super.deleteFeed(it, true) + } + } + + Log.i(TAG, "sync completed in ${System.currentTimeMillis() - preTime}ms") + accountService.update(account.copy(updateAt = preDate)) + ListenableWorker.Result.success() + } catch (e: Exception) { + Log.e(TAG, "sync failed: ${e.message}", e) + ListenableWorker.Result.failure() + } + } + + override suspend fun markAsRead( + groupId: String?, + feedId: String?, + articleId: String?, + before: Date?, + isUnread: Boolean, + ) { + super.markAsRead(groupId, feedId, articleId, before, isUnread) + val asOf = before?.time ?: System.currentTimeMillis() + val api = getFeedlyAPI() + when { + groupId != null -> { + if (isUnread) { + // Feedly does not support "keepUnread" at category level via markers, + // so we only handle the local update already done by super. + } else { + api.markCategoryAsRead(groupId.dollarLast(), asOf) + } + } + + feedId != null -> { + if (isUnread) { + // Feedly does not support keepUnread at feed level via markers. + } else { + api.markFeedAsRead(feedId.dollarLast(), asOf) + } + } + + articleId != null -> { + if (isUnread) { + api.markEntriesAsUnread(listOf(articleId.dollarLast())) + } else { + api.markEntriesAsRead(listOf(articleId.dollarLast())) + } + } + + else -> { + if (!isUnread) { + val key = FeedlySecurityKey(accountService.getCurrentAccount().securityKey) + val userId = key.userId ?: return + api.markCategoryAsRead("user/$userId/category/global.all", asOf) + } + } + } + } + + override suspend fun syncReadStatus(articleIds: Set, isUnread: Boolean): Set { + val api = getFeedlyAPI() + val remoteIds = articleIds.map { it.dollarLast() } + return try { + if (isUnread) { + api.markEntriesAsUnread(remoteIds) + } else { + api.markEntriesAsRead(remoteIds) + } + articleIds + } catch (e: Exception) { + Log.e(TAG, "syncReadStatus failed: ${e.message}", e) + emptySet() + } + } + + override suspend fun markAsStarred(articleId: String, isStarred: Boolean) { + super.markAsStarred(articleId, isStarred) + val api = getFeedlyAPI() + val remoteId = articleId.dollarLast() + if (isStarred) { + api.markEntriesAsSaved(listOf(remoteId)) + } else { + api.markEntriesAsUnsaved(listOf(remoteId)) + } + } +} 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 e868d6eca..d06178c8f 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 @@ -18,6 +18,7 @@ constructor( private val localRssService: LocalRssService, private val feverRssService: FeverRssService, private val googleReaderRssService: GoogleReaderRssService, + private val feedlyRssService: FeedlyRssService, ) { private val currentServiceFlow = @@ -39,7 +40,7 @@ constructor( AccountType.GoogleReader.id -> googleReaderRssService AccountType.FreshRSS.id -> googleReaderRssService AccountType.Inoreader.id -> localRssService - AccountType.Feedly.id -> localRssService + AccountType.Feedly.id -> feedlyRssService else -> localRssService } } diff --git a/app/src/main/java/me/ash/reader/infrastructure/exception/FeedlyAPIException.kt b/app/src/main/java/me/ash/reader/infrastructure/exception/FeedlyAPIException.kt new file mode 100644 index 000000000..84c6d0138 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/exception/FeedlyAPIException.kt @@ -0,0 +1,14 @@ +package me.ash.reader.infrastructure.exception + +class FeedlyAPIException : BusinessException { + constructor() : super() + constructor(message: String) : super(message) + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(cause: Throwable) : super(cause) + constructor( + message: String, + cause: Throwable, + enableSuppression: Boolean, + writableStackTrace: Boolean, + ) : super(message, cause, enableSuppression, writableStackTrace) +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyAPI.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyAPI.kt new file mode 100644 index 000000000..387ca4543 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyAPI.kt @@ -0,0 +1,240 @@ +package me.ash.reader.infrastructure.rss.provider.feedly + +import android.content.Context +import java.net.URLEncoder +import java.util.concurrent.ConcurrentHashMap +import me.ash.reader.infrastructure.exception.FeedlyAPIException +import me.ash.reader.infrastructure.net.RetryConfig +import me.ash.reader.infrastructure.net.withRetries +import me.ash.reader.infrastructure.rss.provider.ProviderAPI +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.executeAsync + +class FeedlyAPI +private constructor( + context: Context, + private val accessToken: String, +) : ProviderAPI(context, null) { + + private val baseUrl = "https://cloud.feedly.com/v3/" + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + private val retryConfig = RetryConfig() + + private suspend inline fun getRequest( + path: String, + params: List>? = null, + ): T { + val urlBuilder = StringBuilder("$baseUrl$path") + if (!params.isNullOrEmpty()) { + urlBuilder.append("?") + urlBuilder.append(params.joinToString("&") { "${it.first}=${it.second}" }) + } + + val response = + client + .newCall( + Request.Builder() + .url(urlBuilder.toString()) + .addHeader("Authorization", "OAuth $accessToken") + .get() + .build() + ) + .executeAsync() + + val body = response.body.string() + when (response.code) { + 401 -> throw FeedlyAPIException("Unauthorized: Invalid access token") + 403 -> throw FeedlyAPIException("Forbidden") + !in 200..299 -> throw FeedlyAPIException("Error ${response.code}: $body") + } + return toDTO(body) + } + + private suspend inline fun postRequest(path: String, bodyObj: Any): T { + val jsonBody = gson.toJson(bodyObj).toRequestBody(jsonMediaType) + + val response = + client + .newCall( + Request.Builder() + .url("$baseUrl$path") + .addHeader("Authorization", "OAuth $accessToken") + .post(jsonBody) + .build() + ) + .executeAsync() + + val responseBody = response.body.string() + when (response.code) { + 401 -> throw FeedlyAPIException("Unauthorized: Invalid access token") + 403 -> throw FeedlyAPIException("Forbidden") + !in 200..299 -> throw FeedlyAPIException("Error ${response.code}: $responseBody") + } + @Suppress("UNCHECKED_CAST") + return if (responseBody.isBlank()) "" as T else toDTO(responseBody) + } + + private suspend fun deleteRequest(path: String) { + val response = + client + .newCall( + Request.Builder() + .url("$baseUrl$path") + .addHeader("Authorization", "OAuth $accessToken") + .delete() + .build() + ) + .executeAsync() + + val responseBody = response.body.string() + when (response.code) { + 401 -> throw FeedlyAPIException("Unauthorized: Invalid access token") + 403 -> throw FeedlyAPIException("Forbidden") + !in 200..299 -> throw FeedlyAPIException("Error ${response.code}: $responseBody") + } + } + + suspend fun getProfile(): FeedlyDTO.Profile = getRequest("profile") + + suspend fun getSubscriptions(): List = + getRequest>("subscriptions").toList() + + suspend fun getCollections(): List = + getRequest>("collections").toList() + + suspend fun getStreamContents( + streamId: String, + count: Int = 250, + continuation: String? = null, + newerThan: Long? = null, + ): FeedlyDTO.StreamContents { + val encodedStreamId = URLEncoder.encode(streamId, "UTF-8") + val params = + mutableListOf("count" to count.toString()).apply { + continuation?.let { add("continuation" to it) } + newerThan?.let { add("newerThan" to it.toString()) } + } + return getRequest("streams/$encodedStreamId/contents", params) + } + + suspend fun markEntriesAsRead(entryIds: List) { + if (entryIds.isEmpty()) return + withRetries(retryConfig) { + postRequest( + "markers", + FeedlyDTO.MarkersRequest( + action = "markAsRead", + type = "entries", + entryIds = entryIds, + ), + ) + }.getOrThrow() + } + + suspend fun markEntriesAsUnread(entryIds: List) { + if (entryIds.isEmpty()) return + withRetries(retryConfig) { + postRequest( + "markers", + FeedlyDTO.MarkersRequest( + action = "keepUnread", + type = "entries", + entryIds = entryIds, + ), + ) + }.getOrThrow() + } + + suspend fun markFeedAsRead(feedId: String, asOf: Long? = null) { + withRetries(retryConfig) { + postRequest( + "markers", + FeedlyDTO.MarkersRequest( + action = "markAsRead", + type = "feeds", + feedIds = listOf(feedId), + asOf = asOf, + ), + ) + }.getOrThrow() + } + + suspend fun markCategoryAsRead(categoryId: String, asOf: Long? = null) { + withRetries(retryConfig) { + postRequest( + "markers", + FeedlyDTO.MarkersRequest( + action = "markAsRead", + type = "categories", + categoryIds = listOf(categoryId), + asOf = asOf, + ), + ) + }.getOrThrow() + } + + suspend fun markEntriesAsSaved(entryIds: List) { + if (entryIds.isEmpty()) return + withRetries(retryConfig) { + postRequest( + "markers", + FeedlyDTO.MarkersRequest( + action = "markAsSaved", + type = "entries", + entryIds = entryIds, + ), + ) + }.getOrThrow() + } + + suspend fun markEntriesAsUnsaved(entryIds: List) { + if (entryIds.isEmpty()) return + withRetries(retryConfig) { + postRequest( + "markers", + FeedlyDTO.MarkersRequest( + action = "markAsUnsaved", + type = "entries", + entryIds = entryIds, + ), + ) + }.getOrThrow() + } + + suspend fun addSubscription( + feedId: String, + title: String?, + categoryId: String?, + ): FeedlyDTO.Subscription { + val body = + mutableMapOf("id" to feedId).apply { + title?.let { put("title", it) } + categoryId?.let { + put( + "categories", + listOf(mapOf("id" to it)), + ) + } + } + return postRequest("subscriptions", body) + } + + suspend fun deleteSubscription(feedId: String) { + val encodedFeedId = URLEncoder.encode(feedId, "UTF-8") + deleteRequest("subscriptions/$encodedFeedId") + } + + companion object { + + private val instances: ConcurrentHashMap = ConcurrentHashMap() + + fun getInstance(context: Context, accessToken: String): FeedlyAPI = + instances.getOrPut(accessToken) { FeedlyAPI(context, accessToken) } + + fun clearInstance() { + instances.clear() + } + } +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyDTO.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyDTO.kt new file mode 100644 index 000000000..0a365a8e6 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/feedly/FeedlyDTO.kt @@ -0,0 +1,93 @@ +package me.ash.reader.infrastructure.rss.provider.feedly + +object FeedlyDTO { + + data class Profile( + val id: String?, + val email: String?, + val givenName: String?, + val familyName: String?, + val fullName: String?, + ) + + data class Category( + val id: String?, + val label: String?, + ) + + data class Subscription( + val id: String?, + val title: String?, + val categories: List?, + val iconUrl: String?, + val website: String?, + val updated: Long?, + ) + + data class Collection( + val id: String?, + val label: String?, + val subscriptions: List?, + ) + + data class Tag( + val id: String?, + val label: String?, + ) + + data class Content( + val content: String?, + val direction: String?, + ) + + data class Link( + val href: String?, + val type: String?, + ) + + data class Visual( + val url: String?, + val width: Int?, + val height: Int?, + ) + + data class Origin( + val feedId: String?, + val title: String?, + val htmlUrl: String?, + ) + + data class StreamItem( + val id: String?, + val title: String?, + val author: String?, + val published: Long?, + val updated: Long?, + val unread: Boolean?, + val summary: Content?, + val content: Content?, + val canonical: List?, + val alternate: List?, + val origin: Origin?, + val tags: List?, + val enclosure: List?, + val visual: Visual?, + val thumbnail: List?, + ) + + data class StreamContents( + val id: String?, + val items: List?, + val continuation: String?, + val updated: Long?, + ) + + data class MarkersRequest( + val action: String, + val type: String, + val entryIds: List? = null, + val feedIds: List? = null, + val categoryIds: List? = null, + val asOf: Long? = null, + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt index b775f1766..7cfe7faac 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt @@ -27,7 +27,7 @@ fun LazyItemScope.AccountConnection( AccountType.Fever.id -> FeverConnection(account) AccountType.GoogleReader.id -> GoogleReaderConnection(account) AccountType.FreshRSS.id -> FreshRSSConnection(account) - AccountType.Feedly.id -> {} + AccountType.Feedly.id -> FeedlyConnection(account) AccountType.Inoreader.id -> {} } if (account.type.id != AccountType.Local.id) { diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeedlyConnection.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeedlyConnection.kt new file mode 100644 index 000000000..c7e6f7e99 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeedlyConnection.kt @@ -0,0 +1,60 @@ +package me.ash.reader.ui.page.settings.accounts.connection + +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import me.ash.reader.R +import me.ash.reader.domain.model.account.Account +import me.ash.reader.domain.model.account.security.FeedlySecurityKey +import me.ash.reader.ui.component.base.TextFieldDialog +import me.ash.reader.ui.ext.mask +import me.ash.reader.ui.page.settings.SettingItem +import me.ash.reader.ui.page.settings.accounts.AccountViewModel + +@Composable +fun LazyItemScope.FeedlyConnection( + account: Account, + viewModel: AccountViewModel = hiltViewModel(), +) { + val securityKey by remember { derivedStateOf { FeedlySecurityKey(account.securityKey) } } + + var tokenMask by remember { mutableStateOf(securityKey.accessToken?.mask()) } + var accessTokenValue by remember { mutableStateOf(securityKey.accessToken) } + var tokenDialogVisible by remember { mutableStateOf(false) } + + LaunchedEffect(securityKey.accessToken) { tokenMask = securityKey.accessToken?.mask() } + + SettingItem( + title = stringResource(R.string.feedly_access_token), + desc = tokenMask, + onClick = { tokenDialogVisible = true }, + ) {} + + TextFieldDialog( + visible = tokenDialogVisible, + title = stringResource(R.string.feedly_access_token), + value = accessTokenValue ?: "", + placeholder = stringResource(R.string.feedly_access_token_hint), + isPassword = true, + onValueChange = { accessTokenValue = it }, + onDismissRequest = { tokenDialogVisible = false }, + onConfirm = { + if (accessTokenValue?.isNotBlank() == true) { + securityKey.accessToken = accessTokenValue + save(account, viewModel, securityKey) + tokenDialogVisible = false + } + }, + ) +} + +private fun save(account: Account, viewModel: AccountViewModel, securityKey: FeedlySecurityKey) { + account.id?.let { viewModel.update(it) { copy(securityKey = securityKey.toString()) } } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f249f63..0bf152a8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -253,6 +253,8 @@ On this device Services feedly.com + Access Token + Paste your Feedly developer access token inoreader.com Self-hosted freshrss.org From 8122d4ae7d49ffefadf60277830d7047d41ad545 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 18:02:36 +0000 Subject: [PATCH 2/2] Add unit tests for Feedly DTO parsing and stream ID logic FeedlyDTOTest verifies Gson deserialisation of all Feedly API response shapes (Profile, Subscription, Collection, StreamContents, StreamItem) using real-world JSON fixtures, including edge cases for null unread fields, missing continuation tokens, and starred-tag detection logic. FeedlyStreamIdTest covers the DB ID helpers (spacerDollar/dollarLast), stream ID construction for global.all and per-category streams, URL encoding of stream IDs for HTTP requests, and feed URL extraction. https://claude.ai/code/session_013Muau5j88pVaYL377NBcRB --- .../infrastructure/rss/FeedlyDTOTest.kt | 341 ++++++++++++++++++ .../infrastructure/rss/FeedlyStreamIdTest.kt | 148 ++++++++ 2 files changed, 489 insertions(+) create mode 100644 app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyDTOTest.kt create mode 100644 app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyStreamIdTest.kt diff --git a/app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyDTOTest.kt b/app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyDTOTest.kt new file mode 100644 index 000000000..bcd90d8e8 --- /dev/null +++ b/app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyDTOTest.kt @@ -0,0 +1,341 @@ +package me.ash.reader.infrastructure.rss + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import me.ash.reader.infrastructure.rss.provider.feedly.FeedlyDTO +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Verifies that [FeedlyDTO] field names match the real Feedly API response shapes. + * + * Field-name mismatches between DTOs and the actual JSON are the most common + * integration bug and the easiest to catch with these offline parsing tests. + * All JSON fixtures are modelled on real Feedly API responses. + */ +class FeedlyDTOTest { + + private lateinit var gson: Gson + + @Before + fun setUp() { + gson = Gson() + } + + // ------------------------------------------------------------------------- + // Profile + // ------------------------------------------------------------------------- + + @Test + fun `profile parses id and display name fields`() { + val json = """ + { + "id": "c805fcbf-3acf-4302-a97e-d82f9d7c897f", + "email": "user@example.com", + "givenName": "Jane", + "familyName": "Doe", + "fullName": "Jane Doe" + } + """.trimIndent() + + val profile = gson.fromJson(json, FeedlyDTO.Profile::class.java) + + assertEquals("c805fcbf-3acf-4302-a97e-d82f9d7c897f", profile.id) + assertEquals("user@example.com", profile.email) + assertEquals("Jane", profile.givenName) + assertEquals("Doe", profile.familyName) + assertEquals("Jane Doe", profile.fullName) + } + + @Test + fun `profile handles missing optional fields`() { + val json = """{"id": "abc-123"}""" + + val profile = gson.fromJson(json, FeedlyDTO.Profile::class.java) + + assertEquals("abc-123", profile.id) + assertNull(profile.email) + assertNull(profile.givenName) + assertNull(profile.familyName) + assertNull(profile.fullName) + } + + // ------------------------------------------------------------------------- + // Subscriptions + // ------------------------------------------------------------------------- + + @Test + fun `subscription parses id, title, categories and iconUrl`() { + val json = """ + { + "id": "feed/http://feeds.example.com/rss", + "title": "Example Blog", + "categories": [ + {"id": "user/c805fcbf/category/tech", "label": "Tech"} + ], + "iconUrl": "https://storage.googleapis.com/feedly/icons/example.png", + "website": "https://example.com", + "updated": 1712345678000 + } + """.trimIndent() + + val sub = gson.fromJson(json, FeedlyDTO.Subscription::class.java) + + assertEquals("feed/http://feeds.example.com/rss", sub.id) + assertEquals("Example Blog", sub.title) + assertEquals(1, sub.categories?.size) + assertEquals("user/c805fcbf/category/tech", sub.categories?.first()?.id) + assertEquals("Tech", sub.categories?.first()?.label) + assertEquals("https://storage.googleapis.com/feedly/icons/example.png", sub.iconUrl) + assertEquals("https://example.com", sub.website) + assertEquals(1712345678000L, sub.updated) + } + + @Test + fun `subscription with no categories returns null or empty list`() { + val json = """{"id": "feed/http://feeds.example.com/rss", "title": "Blog"}""" + + val sub = gson.fromJson(json, FeedlyDTO.Subscription::class.java) + + assertTrue(sub.categories.isNullOrEmpty()) + } + + @Test + fun `subscription list parses array of subscriptions`() { + val json = """ + [ + {"id": "feed/http://a.com/rss", "title": "A"}, + {"id": "feed/http://b.com/rss", "title": "B"} + ] + """.trimIndent() + + val type = object : TypeToken>() {}.type + val subs = gson.fromJson>(json, type) + + assertEquals(2, subs.size) + assertEquals("feed/http://a.com/rss", subs[0].id) + assertEquals("feed/http://b.com/rss", subs[1].id) + } + + // ------------------------------------------------------------------------- + // Collections + // ------------------------------------------------------------------------- + + @Test + fun `collection parses id, label and nested subscriptions`() { + val json = """ + [ + { + "id": "user/c805fcbf/category/tech", + "label": "Tech", + "subscriptions": [ + {"id": "feed/http://feeds.example.com/rss", "title": "Example Blog"} + ] + } + ] + """.trimIndent() + + val type = object : TypeToken>() {}.type + val collections = gson.fromJson>(json, type) + + assertEquals(1, collections.size) + val col = collections[0] + assertEquals("user/c805fcbf/category/tech", col.id) + assertEquals("Tech", col.label) + assertEquals(1, col.subscriptions?.size) + assertEquals("feed/http://feeds.example.com/rss", col.subscriptions?.first()?.id) + } + + // ------------------------------------------------------------------------- + // StreamContents + StreamItem + // ------------------------------------------------------------------------- + + @Test + fun `stream contents parses id, continuation and items`() { + val json = """ + { + "id": "user/c805fcbf/category/global.all", + "updated": 1712345678000, + "continuation": "page2token", + "items": [] + } + """.trimIndent() + + val stream = gson.fromJson(json, FeedlyDTO.StreamContents::class.java) + + assertEquals("user/c805fcbf/category/global.all", stream.id) + assertEquals(1712345678000L, stream.updated) + assertEquals("page2token", stream.continuation) + assertEquals(0, stream.items?.size) + } + + @Test + fun `stream item parses all article fields`() { + val json = """ + { + "id": "tag:google.com,2013:googlealerts/feed:12345", + "title": "Article Title", + "author": "Jane Doe", + "published": 1712345678000, + "updated": 1712345699000, + "unread": true, + "summary": {"content": "

Summary text

", "direction": "ltr"}, + "content": {"content": "

Full article content

", "direction": "ltr"}, + "canonical": [{"href": "https://example.com/article", "type": "text/html"}], + "alternate": [{"href": "https://example.com/alt", "type": "text/html"}], + "origin": { + "feedId": "feed/http://feeds.example.com/rss", + "title": "Example Blog", + "htmlUrl": "https://example.com" + }, + "tags": [ + {"id": "user/c805fcbf/tag/global.saved", "label": "saved"} + ], + "visual": {"url": "https://example.com/image.jpg", "width": 800, "height": 600}, + "thumbnail": [{"url": "https://example.com/thumb.jpg", "width": 200, "height": 150}] + } + """.trimIndent() + + val item = gson.fromJson(json, FeedlyDTO.StreamItem::class.java) + + assertEquals("tag:google.com,2013:googlealerts/feed:12345", item.id) + assertEquals("Article Title", item.title) + assertEquals("Jane Doe", item.author) + assertEquals(1712345678000L, item.published) + assertEquals(1712345699000L, item.updated) + assertTrue(item.unread == true) + assertEquals("

Summary text

", item.summary?.content) + assertEquals("

Full article content

", item.content?.content) + assertEquals("https://example.com/article", item.canonical?.first()?.href) + assertEquals("https://example.com/alt", item.alternate?.first()?.href) + assertEquals("feed/http://feeds.example.com/rss", item.origin?.feedId) + assertEquals("https://example.com", item.origin?.htmlUrl) + assertEquals(1, item.tags?.size) + assertEquals("user/c805fcbf/tag/global.saved", item.tags?.first()?.id) + assertEquals("https://example.com/image.jpg", item.visual?.url) + assertEquals("https://example.com/thumb.jpg", item.thumbnail?.first()?.url) + } + + @Test + fun `stream item with unread false is not unread`() { + val json = """{"id": "entry1", "unread": false}""" + val item = gson.fromJson(json, FeedlyDTO.StreamItem::class.java) + assertFalse(item.unread == true) + } + + @Test + fun `stream item with null unread treated as unread in service logic`() { + // FeedlyRssService uses: item.unread != false + // So when unread is null (absent from JSON) the article is treated as unread. + val json = """{"id": "entry1", "title": "No unread field"}""" + val item = gson.fromJson(json, FeedlyDTO.StreamItem::class.java) + assertNull(item.unread) + assertTrue(item.unread != false) // mirrors the service logic + } + + @Test + fun `starred detection uses global saved tag`() { + // The service checks: tags?.any { it.id?.contains("global.saved") == true } + val savedTag = FeedlyDTO.Tag(id = "user/abc/tag/global.saved", label = "saved") + val otherTag = FeedlyDTO.Tag(id = "user/abc/tag/tech", label = "tech") + + assertTrue(savedTag.id?.contains("global.saved") == true) + assertFalse(otherTag.id?.contains("global.saved") == true) + + val tags = listOf(savedTag, otherTag) + assertTrue(tags.any { it.id?.contains("global.saved") == true }) + } + + @Test + fun `starred detection returns false when no tags`() { + val item = FeedlyDTO.StreamItem( + id = "entry1", title = null, author = null, published = null, + updated = null, unread = true, summary = null, content = null, + canonical = null, alternate = null, origin = null, + tags = null, enclosure = null, visual = null, thumbnail = null, + ) + assertFalse(item.tags?.any { it.id?.contains("global.saved") == true } == true) + } + + @Test + fun `stream contents with no continuation means last page`() { + val json = """ + { + "id": "user/c805fcbf/category/global.all", + "items": [{"id": "entry1"}] + } + """.trimIndent() + + val stream = gson.fromJson(json, FeedlyDTO.StreamContents::class.java) + + assertNull(stream.continuation) + } + + // ------------------------------------------------------------------------- + // Feed URL extraction + // ------------------------------------------------------------------------- + + @Test + fun `feed URL is extracted by removing feed prefix`() { + // FeedlyRssService does: feedId.removePrefix("feed/") + val feedId = "feed/http://feeds.example.com/rss" + assertEquals("http://feeds.example.com/rss", feedId.removePrefix("feed/")) + } + + @Test + fun `feed URL without prefix is unchanged`() { + val feedId = "http://feeds.example.com/rss" + assertEquals("http://feeds.example.com/rss", feedId.removePrefix("feed/")) + } + + // ------------------------------------------------------------------------- + // MarkersRequest serialisation + // ------------------------------------------------------------------------- + + @Test + fun `markers request serialises correctly for markAsRead entries`() { + val request = FeedlyDTO.MarkersRequest( + action = "markAsRead", + type = "entries", + entryIds = listOf("entry1", "entry2"), + ) + val json = gson.toJson(request) + + assertTrue(json.contains("\"action\":\"markAsRead\"")) + assertTrue(json.contains("\"type\":\"entries\"")) + assertTrue(json.contains("entry1")) + assertTrue(json.contains("entry2")) + } + + @Test + fun `markers request serialises correctly for markAsSaved`() { + val request = FeedlyDTO.MarkersRequest( + action = "markAsSaved", + type = "entries", + entryIds = listOf("entry1"), + ) + val json = gson.toJson(request) + + assertTrue(json.contains("\"action\":\"markAsSaved\"")) + assertFalse(json.contains("feedIds")) + } + + @Test + fun `markers request for feed includes asOf timestamp`() { + val request = FeedlyDTO.MarkersRequest( + action = "markAsRead", + type = "feeds", + feedIds = listOf("feed/http://example.com/rss"), + asOf = 1712345678000L, + ) + val json = gson.toJson(request) + + assertTrue(json.contains("\"type\":\"feeds\"")) + assertTrue(json.contains("1712345678000")) + } +} diff --git a/app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyStreamIdTest.kt b/app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyStreamIdTest.kt new file mode 100644 index 000000000..207e71644 --- /dev/null +++ b/app/src/test/java/me/ash/reader/infrastructure/rss/FeedlyStreamIdTest.kt @@ -0,0 +1,148 @@ +package me.ash.reader.infrastructure.rss + +import me.ash.reader.ui.ext.dollarLast +import me.ash.reader.ui.ext.spacerDollar +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URLEncoder + +/** + * Verifies stream ID construction and the DB ID helpers used by [FeedlyRssService]. + * + * These are pure logic tests — no Android framework, no network. + */ +class FeedlyStreamIdTest { + + // ------------------------------------------------------------------------- + // dollarLast() — extracts the remote ID stored after the last '$' separator + // ------------------------------------------------------------------------- + + @Test + fun `dollarLast extracts entry ID from db ID`() { + val dbId = "5\$tag:google.com,2013:googlealerts/feed:12345" + assertEquals("tag:google.com,2013:googlealerts/feed:12345", dbId.dollarLast()) + } + + @Test + fun `dollarLast extracts feed ID from db ID`() { + val dbId = "5\$feed/http://feeds.example.com/rss" + assertEquals("feed/http://feeds.example.com/rss", dbId.dollarLast()) + } + + @Test + fun `dollarLast extracts category ID from db ID`() { + val dbId = "5\$user/c805fcbf/category/tech" + assertEquals("user/c805fcbf/category/tech", dbId.dollarLast()) + } + + // ------------------------------------------------------------------------- + // spacerDollar() — creates the composite DB ID + // ------------------------------------------------------------------------- + + @Test + fun `spacerDollar constructs db ID from accountId and remote ID`() { + val accountId = 3 + val remoteId = "feed/http://feeds.example.com/rss" + assertEquals("3\$feed/http://feeds.example.com/rss", accountId.spacerDollar(remoteId)) + } + + @Test + fun `spacerDollar and dollarLast are inverse operations`() { + val accountId = 5 + val remoteId = "tag:google.com,2013:googlealerts/feed:99999" + val dbId = accountId.spacerDollar(remoteId) + assertEquals(remoteId, dbId.dollarLast()) + } + + // ------------------------------------------------------------------------- + // Global stream ID construction (used in sync) + // ------------------------------------------------------------------------- + + @Test + fun `global all stream ID is constructed correctly`() { + val userId = "c805fcbf-3acf-4302-a97e-d82f9d7c897f" + val streamId = "user/$userId/category/global.all" + assertEquals("user/c805fcbf-3acf-4302-a97e-d82f9d7c897f/category/global.all", streamId) + } + + @Test + fun `global saved tag ID is constructed correctly`() { + val userId = "c805fcbf-3acf-4302-a97e-d82f9d7c897f" + val tagId = "user/$userId/tag/global.saved" + assertTrue(tagId.contains("global.saved")) + } + + @Test + fun `category stream ID is constructed correctly`() { + val userId = "c805fcbf" + val categoryLabel = "tech" + val categoryStreamId = "user/$userId/category/$categoryLabel" + assertEquals("user/c805fcbf/category/tech", categoryStreamId) + } + + // ------------------------------------------------------------------------- + // URL encoding of stream IDs (used in FeedlyAPI.getStreamContents) + // ------------------------------------------------------------------------- + + @Test + fun `stream ID with slashes is URL encoded for HTTP request`() { + val streamId = "user/c805fcbf/category/global.all" + val encoded = URLEncoder.encode(streamId, "UTF-8") + + // Slashes and dots should be percent-encoded + assertTrue(encoded.contains("%2F") || encoded.contains("%2f")) + assertTrue(!encoded.contains("/")) + } + + @Test + fun `feed ID with URL is URL encoded for HTTP request`() { + val feedId = "feed/http://feeds.example.com/rss" + val encoded = URLEncoder.encode(feedId, "UTF-8") + + assertTrue(!encoded.contains("/")) + assertTrue(!encoded.contains(":")) + } + + @Test + fun `encoded stream ID can be decoded back to original`() { + val streamId = "user/c805fcbf-3acf-4302-a97e-d82f9d7c897f/category/global.all" + val encoded = URLEncoder.encode(streamId, "UTF-8") + val decoded = java.net.URLDecoder.decode(encoded, "UTF-8") + assertEquals(streamId, decoded) + } + + // ------------------------------------------------------------------------- + // Feed URL extraction from Feedly feed ID + // ------------------------------------------------------------------------- + + @Test + fun `feed URL is obtained by removing feed prefix`() { + assertEquals( + "http://feeds.example.com/rss", + "feed/http://feeds.example.com/rss".removePrefix("feed/"), + ) + } + + @Test + fun `https feed URL is obtained by removing feed prefix`() { + assertEquals( + "https://feeds.example.com/atom.xml", + "feed/https://feeds.example.com/atom.xml".removePrefix("feed/"), + ) + } + + // ------------------------------------------------------------------------- + // Subscription category membership + // ------------------------------------------------------------------------- + + @Test + fun `category DB ID uses full category stream ID as remote ID`() { + val accountId = 5 + val categoryStreamId = "user/c805fcbf/category/tech" + val dbId = accountId.spacerDollar(categoryStreamId) + + assertEquals("5\$user/c805fcbf/category/tech", dbId) + assertEquals(categoryStreamId, dbId.dollarLast()) + } +}