From 161ecdf353f6051d9b7c242873024a107dd76642 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Thu, 7 Nov 2024 20:47:42 -0600 Subject: [PATCH 1/4] Reorder account delegate methods --- .../java/com/jocmp/capy/AccountDelegate.kt | 20 +-- .../feedbin/FeedbinAccountDelegate.kt | 154 +++++++++--------- 2 files changed, 87 insertions(+), 87 deletions(-) diff --git a/capy/src/main/java/com/jocmp/capy/AccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/AccountDelegate.kt index d8994755..29201605 100644 --- a/capy/src/main/java/com/jocmp/capy/AccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/AccountDelegate.kt @@ -4,22 +4,22 @@ import com.jocmp.capy.accounts.AddFeedResult import java.time.ZonedDateTime interface AccountDelegate { - suspend fun addFeed( - url: String, - title: String?, - folderTitles: List? - ): AddFeedResult - - suspend fun addStar(articleIDs: List): Result - suspend fun refresh(cutoffDate: ZonedDateTime? = null): Result - suspend fun removeStar(articleIDs: List): Result - suspend fun markRead(articleIDs: List): Result suspend fun markUnread(articleIDs: List): Result + suspend fun addStar(articleIDs: List): Result + + suspend fun removeStar(articleIDs: List): Result + + suspend fun addFeed( + url: String, + title: String?, + folderTitles: List? + ): AddFeedResult + suspend fun updateFeed( feed: Feed, title: String, diff --git a/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt index 4970039a..f8de0a53 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegate.kt @@ -42,6 +42,22 @@ internal class FeedbinAccountDelegate( private val feedRecords = FeedRecords(database) private val taggingRecords = TaggingRecords(database) + override suspend fun refresh(cutoffDate: ZonedDateTime?): Result { + return try { + val since = articleRecords.maxUpdatedAt().toString() + + refreshFeeds() + refreshTaggings() + refreshArticles(since = since) + + Result.success(Unit) + } catch (exception: IOException) { + Result.failure(exception) + } catch (e: UnauthorizedError) { + Result.failure(e) + } + } + override suspend fun markRead(articleIDs: List): Result { val entryIDs = articleIDs.map { it.toLong() } @@ -62,74 +78,6 @@ internal class FeedbinAccountDelegate( } } - override suspend fun updateFeed( - feed: Feed, - title: String, - folderTitles: List, - ): Result = withErrorHandling { - if (title != feed.title) { - feedbin.updateSubscription( - subscriptionID = feed.subscriptionID, - body = UpdateSubscriptionRequest(title = title) - ) - - feedRecords.update( - feedID = feed.id, - title = title, - ) - } - - val taggingIDsToDelete = taggingRecords.findFeedTaggingsToDelete( - feed = feed, - excludedTaggingNames = folderTitles - ) - - folderTitles.forEach { folderTitle -> - val request = CreateTaggingRequest(feed_id = feed.id, name = folderTitle) - - withResult(feedbin.createTagging(request)) { tagging -> - taggingRecords.upsert( - id = tagging.id.toString(), - feedID = tagging.feed_id.toString(), - name = tagging.name - ) - } - } - - taggingIDsToDelete.forEach { taggingID -> - val result = feedbin.deleteTagging(taggingID = taggingID) - - if (result.isSuccessful) { - taggingRecords.deleteTagging(taggingID = taggingID) - } - } - - feedRecords.findBy(feed.id) - } - - override suspend fun removeFeed(feed: Feed): Result = withErrorHandling { - feedbin.deleteSubscription(subscriptionID = feed.subscriptionID) - - Unit - } - - override suspend fun fetchFullContent(article: Article): Result { - return try { - val url = article.extractedContentURL!! - - val result = feedbin.fetchExtractedContent(url = url.toString()) - val responseBody = result.body() - - if (result.isSuccessful && responseBody != null) { - return Result.success(responseBody.content) - } else { - return Result.failure(Throwable("Error extracting article")) - } - } catch (e: Exception) { - Result.failure(e) - } - } - override suspend fun addStar(articleIDs: List): Result { val entryIDs = articleIDs.map { it.toLong() } @@ -191,18 +139,70 @@ internal class FeedbinAccountDelegate( } } - override suspend fun refresh(cutoffDate: ZonedDateTime?): Result { + override suspend fun updateFeed( + feed: Feed, + title: String, + folderTitles: List, + ): Result = withErrorHandling { + if (title != feed.title) { + feedbin.updateSubscription( + subscriptionID = feed.subscriptionID, + body = UpdateSubscriptionRequest(title = title) + ) + + feedRecords.update( + feedID = feed.id, + title = title, + ) + } + + val taggingIDsToDelete = taggingRecords.findFeedTaggingsToDelete( + feed = feed, + excludedTaggingNames = folderTitles + ) + + folderTitles.forEach { folderTitle -> + val request = CreateTaggingRequest(feed_id = feed.id, name = folderTitle) + + withResult(feedbin.createTagging(request)) { tagging -> + taggingRecords.upsert( + id = tagging.id.toString(), + feedID = tagging.feed_id.toString(), + name = tagging.name + ) + } + } + + taggingIDsToDelete.forEach { taggingID -> + val result = feedbin.deleteTagging(taggingID = taggingID) + + if (result.isSuccessful) { + taggingRecords.deleteTagging(taggingID = taggingID) + } + } + + feedRecords.findBy(feed.id) + } + + override suspend fun removeFeed(feed: Feed): Result = withErrorHandling { + feedbin.deleteSubscription(subscriptionID = feed.subscriptionID) + + Unit + } + + override suspend fun fetchFullContent(article: Article): Result { return try { - val since = articleRecords.maxUpdatedAt().toString() + val url = article.extractedContentURL!! - refreshFeeds() - refreshTaggings() - refreshArticles(since = since) + val result = feedbin.fetchExtractedContent(url = url.toString()) + val responseBody = result.body() - Result.success(Unit) - } catch (exception: IOException) { - Result.failure(exception) - } catch (e: UnauthorizedError) { + if (result.isSuccessful && responseBody != null) { + return Result.success(responseBody.content) + } else { + return Result.failure(Throwable("Error extracting article")) + } + } catch (e: Exception) { Result.failure(e) } } From de188ee0447a823697f0548e76c15f78864984ee Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Thu, 7 Nov 2024 20:48:05 -0600 Subject: [PATCH 2/4] Add edit-tag method --- .../main/java/com/jocmp/readerclient/GoogleReader.kt | 10 ++++++++++ .../src/main/java/com/jocmp/readerclient/Stream.kt | 1 - 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt index 63514da3..468189e8 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt @@ -39,6 +39,16 @@ interface GoogleReader { @Query("output") output: String = "json", ): Response + @FormUrlEncoded + @POST("reader/api/0/edit-tag") + suspend fun editTag( + @Field("i") ids: List, + @Field("T") postToken: String?, + @Field("a") addTag: String? = null, + @Field("r") removeTag: String? = null, + @Query("output") output: String = "json", + ): Response + @POST("accounts/ClientLogin") suspend fun clientLogin( @Query("Email") email: String, diff --git a/readerclient/src/main/java/com/jocmp/readerclient/Stream.kt b/readerclient/src/main/java/com/jocmp/readerclient/Stream.kt index f1b0b497..f530656f 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/Stream.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/Stream.kt @@ -1,7 +1,6 @@ package com.jocmp.readerclient enum class Stream(val id: String) { - READING_LIST("user/-/state/com.google/reading-list"), STARRED("user/-/state/com.google/starred"), From 96e22b1115b9a60019e785ce549a7bc02c5ced7e Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Thu, 7 Nov 2024 20:48:23 -0600 Subject: [PATCH 3/4] Add 'read' and 'starred' methods --- .../accounts/reader/ReaderAccountDelegate.kt | 83 ++++++++++++++----- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt index 6c3460f7..5ec2809c 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegate.kt @@ -4,6 +4,8 @@ import com.jocmp.capy.AccountDelegate import com.jocmp.capy.Article import com.jocmp.capy.Feed import com.jocmp.capy.accounts.AddFeedResult +import com.jocmp.capy.accounts.feedbin.FeedbinAccountDelegate.Companion.MAX_CREATE_UNREAD_LIMIT +import com.jocmp.capy.accounts.withErrorHandling import com.jocmp.capy.articles.ArticleContent import com.jocmp.capy.common.TimeHelpers import com.jocmp.capy.common.UnauthorizedError @@ -36,18 +38,6 @@ internal class ReaderAccountDelegate( private val articleContent = ArticleContent(httpClient) private val articleRecords = ArticleRecords(database) - override suspend fun addFeed( - url: String, - title: String?, - folderTitles: List? - ): AddFeedResult { - return AddFeedResult.Failure(error = AddFeedResult.AddFeedError.NetworkError()) - } - - override suspend fun addStar(articleIDs: List): Result { - return Result.failure(Throwable("")) - } - override suspend fun refresh(cutoffDate: ZonedDateTime?): Result { return try { val since = articleRecords.maxUpdatedAt().toEpochSecond() @@ -63,16 +53,33 @@ internal class ReaderAccountDelegate( } } - override suspend fun removeStar(articleIDs: List): Result { - return Result.failure(Throwable("")) - } - override suspend fun markRead(articleIDs: List): Result { - return Result.failure(Throwable("")) + return withErrorHandling { + articleIDs.chunked(MAX_CREATE_UNREAD_LIMIT).map { batchIDs -> + editTag(ids = batchIDs, addTag = Stream.READ) + } + Unit + } } override suspend fun markUnread(articleIDs: List): Result { - return Result.failure(Throwable("")) + return editTag(ids = articleIDs, removeTag = Stream.READ) + } + + override suspend fun addStar(articleIDs: List): Result { + return editTag(ids = articleIDs, addTag = Stream.STARRED) + } + + override suspend fun removeStar(articleIDs: List): Result { + return editTag(ids = articleIDs, removeTag = Stream.STARRED) + } + + override suspend fun addFeed( + url: String, + title: String?, + folderTitles: List? + ): AddFeedResult { + return AddFeedResult.Failure(error = AddFeedResult.AddFeedError.NetworkError()) } override suspend fun updateFeed( @@ -206,7 +213,11 @@ internal class ReaderAccountDelegate( count = MAX_PAGINATED_ITEM_LIMIT, ) - val result = response.body() ?: return + val result = response.body() + + if (result == null || result.itemRefs.isEmpty()) { + return + } coroutineScope { launch { @@ -263,7 +274,30 @@ internal class ReaderAccountDelegate( } } + private suspend fun editTag( + ids: List, + addTag: Stream? = null, + removeTag: Stream? = null, + ): Result { + return withErrorHandling { + withPostToken { + googleReader.editTag( + ids, + postToken = postToken.get(), + addTag = addTag?.id, + removeTag = removeTag?.id + ) + } + + Unit + } + } + private suspend fun withPostToken(handler: suspend () -> Response): Response { + if (postToken.get() == null) { + fetchToken() + } + val response = handler() val isBadToken = response @@ -276,12 +310,16 @@ internal class ReaderAccountDelegate( return response } + fetchToken() + + return handler() + } + + private suspend fun fetchToken() { try { postToken.set(googleReader.token().body()) - - return handler() } catch (exception: IOException) { - return response + // continue } } @@ -289,7 +327,6 @@ internal class ReaderAccountDelegate( return "${subscription.id}:${category.id}" } - companion object { const val MAX_PAGINATED_ITEM_LIMIT = 100 } From 10397590d5bebff8442638ca9304511369dedb67 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Thu, 7 Nov 2024 21:03:49 -0600 Subject: [PATCH 4/4] Add tests --- .../reader/ReaderAccountDelegateTest.kt | 113 +++++++++++++++++- .../com/jocmp/readerclient/GoogleReader.kt | 2 +- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt b/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt index 50b62177..b1604d99 100644 --- a/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt +++ b/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt @@ -20,6 +20,7 @@ import com.jocmp.readerclient.StreamItemsContentsResult import com.jocmp.readerclient.Subscription import com.jocmp.readerclient.SubscriptionListResult import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest import okhttp3.Headers @@ -42,7 +43,7 @@ class ReaderAccountDelegateTest { private lateinit var feedFixture: FeedFixture private lateinit var delegate: AccountDelegate - val subscriptions = listOf( + private val subscriptions = listOf( Subscription( id = "feed/2", title = "Ars Technica - All content", @@ -203,6 +204,106 @@ class ReaderAccountDelegateTest { assertEquals(result, Result.failure(networkError)) } + @Test + fun markRead() = runTest { + val id = "0006265cd4de43c6" + + coEvery { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + addTag = Stream.READ.id, + ) + } returns Response.success("OK") + + stubPostToken() + + delegate.markRead(listOf(id)) + + coVerify { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + addTag = Stream.READ.id, + ) + } + } + + @Test + fun markUnread() = runTest { + val id = "0006265cd4de43c6" + + coEvery { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + removeTag = Stream.READ.id, + ) + } returns Response.success("OK") + + stubPostToken() + + delegate.markUnread(listOf(id)) + + coVerify { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + removeTag = Stream.READ.id, + ) + } + } + + @Test + fun addStar() = runTest { + val id = "0006265cd4de43c6" + + coEvery { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + addTag = Stream.STARRED.id, + ) + } returns Response.success("OK") + + stubPostToken() + + delegate.addStar(listOf(id)) + + coVerify { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + addTag = Stream.STARRED.id, + ) + } + } + + @Test + fun removeStar() = runTest { + val id = "0006265cd4de43c6" + + coEvery { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + removeTag = Stream.STARRED.id, + ) + } returns Response.success("OK") + + stubPostToken() + + delegate.removeStar(listOf(id)) + + coVerify { + googleReader.editTag( + ids = listOf(id), + postToken = postToken, + removeTag = Stream.STARRED.id, + ) + } + } + private fun stubSubscriptions(subscriptions: List = this.subscriptions) { coEvery { googleReader.subscriptionList() }.returns( Response.success( @@ -251,15 +352,19 @@ class ReaderAccountDelegateTest { googleReader.streamItemsContents(items.map { it.hexID }, postToken = null) }.returns(Response.error("".toResponseBody(), errorResponse)) - coEvery { - googleReader.token() - }.returns(Response.success(postToken)) + stubPostToken() coEvery { googleReader.streamItemsContents(items.map { it.hexID }, postToken = postToken) }.returns(Response.success(StreamItemsContentsResult(items))) } + private fun stubPostToken() { + coEvery { + googleReader.token() + }.returns(Response.success(postToken)) + } + private fun stubUnread(itemRefs: List) { coEvery { googleReader.streamItemsIDs( diff --git a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt index 468189e8..3528c273 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt @@ -47,7 +47,7 @@ interface GoogleReader { @Field("a") addTag: String? = null, @Field("r") removeTag: String? = null, @Query("output") output: String = "json", - ): Response + ): Response @POST("accounts/ClientLogin") suspend fun clientLogin(