diff --git a/capy/src/main/java/com/jocmp/capy/Account.kt b/capy/src/main/java/com/jocmp/capy/Account.kt index faa7c15b..6f2650bd 100644 --- a/capy/src/main/java/com/jocmp/capy/Account.kt +++ b/capy/src/main/java/com/jocmp/capy/Account.kt @@ -6,10 +6,9 @@ import com.jocmp.capy.accounts.LocalAccountDelegate import com.jocmp.capy.accounts.LocalOkHttpClient import com.jocmp.capy.accounts.Source import com.jocmp.capy.accounts.asOPML +import com.jocmp.capy.accounts.reader.buildFreshRSSDelegate import com.jocmp.capy.accounts.feedbin.FeedbinAccountDelegate import com.jocmp.capy.accounts.feedbin.FeedbinOkHttpClient -import com.jocmp.capy.accounts.reader.ReaderAccountDelegate -import com.jocmp.capy.accounts.reader.ReaderOkHttpClient import com.jocmp.capy.articles.UnreadSortOrder import com.jocmp.capy.common.TimeHelpers.nowUTC import com.jocmp.capy.common.sortedByTitle @@ -20,7 +19,6 @@ import com.jocmp.capy.opml.OPMLImporter import com.jocmp.capy.persistence.ArticleRecords import com.jocmp.capy.persistence.FeedRecords import com.jocmp.feedbinclient.Feedbin -import com.jocmp.readerclient.GoogleReader import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -258,23 +256,6 @@ data class Account( private fun Feedbin.Companion.forAccount(path: URI, preferences: AccountPreferences) = create(client = FeedbinOkHttpClient.forAccount(path, preferences)) -private fun buildFreshRSSDelegate( - database: Database, - path: URI, - preferences: AccountPreferences -): AccountDelegate { - val httpClient = ReaderOkHttpClient.forAccount(path, preferences) - - return ReaderAccountDelegate( - database = database, - httpClient = httpClient, - googleReader = GoogleReader.create( - client = httpClient, - baseURL = preferences.url.get() - ) - ) -} - private fun AutoDelete.cutoffDate(): ZonedDateTime? { val now = nowUTC() diff --git a/feedbinclient/src/main/java/com/jocmp/feedbinclient/BasicAuthInterceptor.kt b/capy/src/main/java/com/jocmp/capy/accounts/BasicAuthInterceptor.kt similarity index 82% rename from feedbinclient/src/main/java/com/jocmp/feedbinclient/BasicAuthInterceptor.kt rename to capy/src/main/java/com/jocmp/capy/accounts/BasicAuthInterceptor.kt index 5e6c06f0..c3424ef9 100644 --- a/feedbinclient/src/main/java/com/jocmp/feedbinclient/BasicAuthInterceptor.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/BasicAuthInterceptor.kt @@ -1,4 +1,4 @@ -package com.jocmp.feedbinclient +package com.jocmp.capy.accounts import okhttp3.Interceptor @@ -6,7 +6,7 @@ class BasicAuthInterceptor(private val credentials: () -> String) : Interceptor override fun intercept(chain: Interceptor.Chain): okhttp3.Response { val request = chain.request() - if (request.headers("Authorization").isNullOrEmpty()) { + if (request.headers("Authorization").isEmpty()) { val authenticatedRequest = request.newBuilder().header("Authorization", credentials()).build() return chain.proceed(authenticatedRequest) 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 02cf4529..4970039a 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 @@ -283,7 +283,7 @@ internal class FeedbinAccountDelegate( coroutineScope { ids.chunked(MAX_ENTRY_LIMIT).map { chunkedIDs -> launch { - fetchPaginatedEntries(ids = chunkedIDs) + fetchPaginatedEntries(ids = chunkedIDs.map { it.toLong() }) } } } diff --git a/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinOkHttpClient.kt b/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinOkHttpClient.kt index 440af09d..68aa3e36 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinOkHttpClient.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/feedbin/FeedbinOkHttpClient.kt @@ -2,7 +2,7 @@ package com.jocmp.capy.accounts.feedbin import com.jocmp.capy.AccountPreferences import com.jocmp.capy.accounts.httpClientBuilder -import com.jocmp.feedbinclient.BasicAuthInterceptor +import com.jocmp.capy.accounts.BasicAuthInterceptor import okhttp3.Credentials import okhttp3.OkHttpClient import java.net.URI diff --git a/capy/src/main/java/com/jocmp/capy/accounts/reader/BuildFreshRSSDelegate.kt b/capy/src/main/java/com/jocmp/capy/accounts/reader/BuildFreshRSSDelegate.kt new file mode 100644 index 00000000..8a7e9dff --- /dev/null +++ b/capy/src/main/java/com/jocmp/capy/accounts/reader/BuildFreshRSSDelegate.kt @@ -0,0 +1,25 @@ +package com.jocmp.capy.accounts.reader + +import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.AccountPreferences +import com.jocmp.capy.accounts.httpClientBuilder +import com.jocmp.capy.db.Database +import com.jocmp.readerclient.GoogleReader +import java.net.URI + +internal fun buildFreshRSSDelegate( + database: Database, + path: URI, + preferences: AccountPreferences +): AccountDelegate { + val httpClient = ReaderOkHttpClient.forAccount(path, preferences) + + return ReaderAccountDelegate( + database = database, + httpClient = httpClientBuilder(cachePath = path).build(), + googleReader = GoogleReader.create( + client = httpClient, + baseURL = preferences.url.get() + ) + ) +} 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 8f54360b..6c3460f7 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 @@ -13,23 +13,26 @@ import com.jocmp.capy.db.Database import com.jocmp.capy.persistence.ArticleRecords import com.jocmp.readerclient.Category import com.jocmp.readerclient.GoogleReader +import com.jocmp.readerclient.GoogleReader.Companion.BAD_TOKEN_HEADER_KEY import com.jocmp.readerclient.Item +import com.jocmp.readerclient.ItemRef import com.jocmp.readerclient.Stream import com.jocmp.readerclient.Subscription +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jsoup.Jsoup +import retrofit2.Response import java.io.IOException import java.time.ZonedDateTime +import java.util.concurrent.atomic.AtomicReference -/** - * Save Auth Token for later use - * self.credentials = Credentials(type: .readerAPIKey, username: credentials.username, secret: authString) - */ internal class ReaderAccountDelegate( private val database: Database, - private val httpClient: OkHttpClient, private val googleReader: GoogleReader, + httpClient: OkHttpClient = OkHttpClient(), ) : AccountDelegate { + private var postToken = AtomicReference<String?>(null) private val articleContent = ArticleContent(httpClient) private val articleRecords = ArticleRecords(database) @@ -120,7 +123,7 @@ internal class ReaderAccountDelegate( title = subscription.title, feed_url = subscription.url, site_url = subscription.htmlUrl, - favicon_url = subscription.iconUrl + favicon_url = subscription.iconUrl.ifBlank { null } ) } @@ -139,8 +142,8 @@ internal class ReaderAccountDelegate( private suspend fun refreshArticles( since: Long = articleRecords.maxUpdatedAt().toEpochSecond() ) { - refreshUnreadItems() refreshStarredItems() + refreshUnreadItems() refreshAllArticles(since = since) fetchMissingArticles() } @@ -162,8 +165,25 @@ internal class ReaderAccountDelegate( } } + private suspend fun fetchMissingArticles() { + val ids = articleRecords.findMissingArticles() + + coroutineScope { + ids.chunked(MAX_PAGINATED_ITEM_LIMIT).map { chunkedIDs -> + launch { + val response = withPostToken { + googleReader.streamItemsContents( + postToken = postToken.get(), + ids = chunkedIDs + ) + } + + val result = response.body() ?: return@launch - private fun fetchMissingArticles() { + saveItems(result.items) + } + } + } } private suspend fun refreshAllArticles(since: Long) { @@ -178,15 +198,21 @@ internal class ReaderAccountDelegate( stream: Stream, continuation: String? = null, ) { - val response = googleReader.streamContents( + val response = googleReader.streamItemsIDs( streamID = stream.id, since = since, continuation = continuation, + excludedStreamID = Stream.READ.id, + count = MAX_PAGINATED_ITEM_LIMIT, ) val result = response.body() ?: return - saveItems(result.items) + coroutineScope { + launch { + fetchItemContents(result.itemRefs) + } + } val nextContinuation = result.continuation ?: return @@ -197,6 +223,19 @@ internal class ReaderAccountDelegate( ) } + private suspend fun fetchItemContents(items: List<ItemRef>) { + val response = withPostToken { + googleReader.streamItemsContents( + postToken = postToken.get(), + ids = items.map { it.hexID } + ) + } + + val result = response.body() ?: return + + saveItems(result.items) + } + private fun saveItems(items: List<Item>) { database.transactionWithErrorHandling { items.forEach { item -> @@ -207,7 +246,7 @@ internal class ReaderAccountDelegate( feed_id = item.origin.streamId, title = item.title, author = item.author, - content_html = item.summary.content, + content_html = item.content?.content ?: item.summary.content, extracted_content_url = null, summary = Jsoup.parse(item.summary.content).text(), url = item.canonical.firstOrNull()?.href, @@ -224,7 +263,34 @@ internal class ReaderAccountDelegate( } } + private suspend fun <T> withPostToken(handler: suspend () -> Response<T>): Response<T> { + val response = handler() + + val isBadToken = response + .headers() + .get(BAD_TOKEN_HEADER_KEY) + .orEmpty() + .toBoolean() + + if (!isBadToken) { + return response + } + + try { + postToken.set(googleReader.token().body()) + + return handler() + } catch (exception: IOException) { + return response + } + } + private fun taggingID(subscription: Subscription, category: Category): String { return "${subscription.id}:${category.id}" } + + + companion object { + const val MAX_PAGINATED_ITEM_LIMIT = 100 + } } diff --git a/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderOkHttpClient.kt b/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderOkHttpClient.kt index a2cbcbf7..30e55f55 100644 --- a/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderOkHttpClient.kt +++ b/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderOkHttpClient.kt @@ -1,8 +1,8 @@ package com.jocmp.capy.accounts.reader import com.jocmp.capy.AccountPreferences +import com.jocmp.capy.accounts.BasicAuthInterceptor import com.jocmp.capy.accounts.httpClientBuilder -import com.jocmp.feedbinclient.BasicAuthInterceptor import okhttp3.OkHttpClient import java.net.URI diff --git a/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt index d2f84b4d..a44f9e3e 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/ArticleRecords.kt @@ -33,12 +33,11 @@ internal class ArticleRecords internal constructor( ).executeAsOneOrNull() } - fun findMissingArticles(): List<Long> { + fun findMissingArticles(): List<String> { return database .articlesQueries .findMissingArticles() .executeAsList() - .map { it.toLong() } } internal suspend fun notifications(since: ZonedDateTime): List<ArticleNotification> { diff --git a/capy/src/test/java/com/jocmp/capy/accounts/FeedbinAccountDelegateTest.kt b/capy/src/test/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegateTest.kt similarity index 90% rename from capy/src/test/java/com/jocmp/capy/accounts/FeedbinAccountDelegateTest.kt rename to capy/src/test/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegateTest.kt index f635c57c..f6be149a 100644 --- a/capy/src/test/java/com/jocmp/capy/accounts/FeedbinAccountDelegateTest.kt +++ b/capy/src/test/java/com/jocmp/capy/accounts/feedbin/FeedbinAccountDelegateTest.kt @@ -1,16 +1,16 @@ -package com.jocmp.capy.accounts +package com.jocmp.capy.accounts.feedbin +import com.jocmp.capy.AccountDelegate import com.jocmp.capy.ArticleStatus import com.jocmp.capy.InMemoryDatabaseProvider -import com.jocmp.capy.accounts.feedbin.FeedbinAccountDelegate +import com.jocmp.capy.accounts.AddFeedResult +import com.jocmp.capy.accounts.SubscriptionChoice import com.jocmp.capy.articles.UnreadSortOrder import com.jocmp.capy.db.Database import com.jocmp.capy.fixtures.FeedFixture import com.jocmp.capy.persistence.ArticleRecords import com.jocmp.feedbinclient.CreateSubscriptionRequest import com.jocmp.feedbinclient.Entry -import com.jocmp.feedbinclient.Entry.Images -import com.jocmp.feedbinclient.Entry.Images.SizeOne import com.jocmp.feedbinclient.Feedbin import com.jocmp.feedbinclient.StarredEntriesRequest import com.jocmp.feedbinclient.Subscription @@ -27,10 +27,10 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Protocol import okhttp3.Request import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Before import org.junit.Test import retrofit2.Response import java.net.SocketTimeoutException +import kotlin.test.BeforeTest import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -40,6 +40,7 @@ class FeedbinAccountDelegateTest { private lateinit var database: Database private lateinit var feedbin: Feedbin private lateinit var feedFixture: FeedFixture + private lateinit var delegate: AccountDelegate private val subscriptions = listOf( Subscription( @@ -80,26 +81,27 @@ class FeedbinAccountDelegateTest { created_at = "2024-02-23T17:47:45.708056Z", extracted_content_url = "https://extract.feedbin.com/parser/feedbin/fa2d8d34c403421a766dbec46c58738c36ff359e?base64_url=aHR0cHM6Ly9hcnN0ZWNobmljYS5jb20vP3A9MjAwNTUyNg==", author = "Scharon Harding", - images = Images( + images = Entry.Images( original_url = "https://cdn.arstechnica.net/wp-content/uploads/2024/02/GettyImages-2023785321-800x534.jpg", - size_1 = SizeOne( + size_1 = Entry.Images.SizeOne( cdn_url = "https://cdn.arstechnica.net/wp-content/uploads/2024/02/GettyImages-2023785321-800x534.jpg" ), ), ) ) - @Before + @BeforeTest fun setup() { database = InMemoryDatabaseProvider.build(accountID) feedFixture = FeedFixture(database) - feedbin = mockk<Feedbin>() + feedbin = mockk() + delegate = FeedbinAccountDelegate(database, feedbin) coEvery { feedbin.icons() }.returns(Response.success(listOf())) } @Test - fun refreshAll_updatesEntries() = runTest { + fun refresh_updatesEntries() = runTest { coEvery { feedbin.subscriptions() }.returns(Response.success(subscriptions)) coEvery { feedbin.unreadEntries() }.returns(Response.success(entries.map { it.id })) coEvery { feedbin.starredEntries() }.returns(Response.success(emptyList())) @@ -113,8 +115,6 @@ class FeedbinAccountDelegateTest { ) }.returns(Response.success(entries)) - val delegate = FeedbinAccountDelegate(database, feedbin) - delegate.refresh() val articles = database @@ -141,19 +141,17 @@ class FeedbinAccountDelegateTest { } @Test - fun refreshAll_IOException() = runTest { + fun refresh_IOException() = runTest { val networkError = SocketTimeoutException("Sorry networked charlie") coEvery { feedbin.subscriptions() }.throws(networkError) - val delegate = FeedbinAccountDelegate(database, feedbin) - val result = delegate.refresh() assertEquals(result, Result.failure(networkError)) } @Test - fun refreshAll_findsMissingArticles() = runTest { + fun refresh_findsMissingArticles() = runTest { val unreadEntry = Entry( id = 1, feed_id = 2, @@ -165,9 +163,9 @@ class FeedbinAccountDelegateTest { created_at = "2024-02-23T17:47:45.708056Z", extracted_content_url = "https://extract.feedbin.com/parser/feedbin/fa2d8d34c403421a766dbec46c58738c36ff359e?base64_url=aHR0cHM6Ly9hcnN0ZWNobmljYS5jb20vP3A9MjAwNTUyNg==", author = "Scharon Harding", - images = Images( + images = Entry.Images( original_url = "https://cdn.arstechnica.net/wp-content/uploads/2024/02/GettyImages-2023785321-800x534.jpg", - size_1 = SizeOne( + size_1 = Entry.Images.SizeOne( cdn_url = "https://cdn.arstechnica.net/wp-content/uploads/2024/02/GettyImages-2023785321-800x534.jpg" ), ), @@ -184,9 +182,9 @@ class FeedbinAccountDelegateTest { created_at = "2024-08-243T17:47:45.708056Z", extracted_content_url = "https://extract.feedbin.com/parser/feedbin/fa2d8d34c403421a766dbec46c58738c36ff359e?base64_url=aHR0cHM6Ly9hcnN0ZWNobmljYS5jb20vP3A9MjAwNTUyNg==", author = "Jay Peters", - images = Images( + images = Entry.Images( original_url = "https://cdn.arstechnica.net/wp-content/uploads/2024/02/GettyImages-2023785321-800x534.jpg", - size_1 = SizeOne( + size_1 = Entry.Images.SizeOne( cdn_url = "https://cdn.arstechnica.net/wp-content/uploads/2024/02/GettyImages-2023785321-800x534.jpg" ), ), @@ -215,8 +213,6 @@ class FeedbinAccountDelegateTest { ) }.returns(Response.success(emptyList())) - val delegate = FeedbinAccountDelegate(database, feedbin) - delegate.refresh() val starredArticles = ArticleRecords(database) @@ -244,8 +240,6 @@ class FeedbinAccountDelegateTest { null ) - val delegate = FeedbinAccountDelegate(database, feedbin) - delegate.markRead(listOf(id.toString())) coVerify { feedbin.deleteUnreadEntries(body = UnreadEntriesRequest(listOf(id))) } @@ -259,8 +253,6 @@ class FeedbinAccountDelegateTest { listOf(id) ) - val delegate = FeedbinAccountDelegate(database, feedbin) - delegate.markUnread(listOf(id.toString())) coVerify { feedbin.createUnreadEntries(body = UnreadEntriesRequest(listOf(id))) } @@ -274,8 +266,6 @@ class FeedbinAccountDelegateTest { listOf(id) ) - val delegate = FeedbinAccountDelegate(database, feedbin) - delegate.addStar(listOf(id.toString())) coVerify { feedbin.createStarredEntries(body = StarredEntriesRequest(listOf(id))) } @@ -289,8 +279,6 @@ class FeedbinAccountDelegateTest { null ) - val delegate = FeedbinAccountDelegate(database, feedbin) - delegate.removeStar(listOf(id.toString())) coVerify { feedbin.deleteStarredEntries(body = StarredEntriesRequest(listOf(id))) } @@ -298,7 +286,6 @@ class FeedbinAccountDelegateTest { @Test fun addFeed() = runTest { - val delegate = FeedbinAccountDelegate(database, feedbin) val url = "wheresyoured.at" val successResponse = Response.success<Subscription>( Subscription( @@ -326,7 +313,7 @@ class FeedbinAccountDelegateTest { ) }.returns(Response.success(emptyList())) - val result = delegate.addFeed(url = url) as AddFeedResult.Success + val result = delegate.addFeed(url = url, folderTitles = emptyList(), title = "") as AddFeedResult.Success val feed = result.feed assertEquals( @@ -337,7 +324,6 @@ class FeedbinAccountDelegateTest { @Test fun addFeed_multipleChoice() = runTest { - val delegate = FeedbinAccountDelegate(database, feedbin) val url = "9to5google.com" val choices = listOf( SubscriptionChoice( @@ -373,7 +359,7 @@ class FeedbinAccountDelegateTest { feedbin.createSubscription(body = CreateSubscriptionRequest(feed_url = url)) } returns multipleChoiceResponse - val result = delegate.addFeed(url = url) + val result = delegate.addFeed(url = url, folderTitles = emptyList(), title = "") val actualTitles = (result as AddFeedResult.MultipleChoices).choices.map { it.title } @@ -383,7 +369,6 @@ class FeedbinAccountDelegateTest { @Test fun addFeed_Failure() = runTest { - val delegate = FeedbinAccountDelegate(database, feedbin) val url = "example.com" val responseBody = """ @@ -398,7 +383,7 @@ class FeedbinAccountDelegateTest { feedbin.createSubscription(body = CreateSubscriptionRequest(feed_url = url)) } returns Response.error(404, responseBody) - val result = delegate.addFeed(url = url) + val result = delegate.addFeed(url = url, folderTitles = emptyList(), title = "") assertTrue(result is AddFeedResult.Failure) } @@ -435,6 +420,3 @@ class FeedbinAccountDelegateTest { assertEquals(expected = feedTitle, actual = updated.title) } } - -private suspend fun FeedbinAccountDelegate.addFeed(url: String) = - addFeed(url = url, null, null) 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 new file mode 100644 index 00000000..50b62177 --- /dev/null +++ b/capy/src/test/java/com/jocmp/capy/accounts/reader/ReaderAccountDelegateTest.kt @@ -0,0 +1,272 @@ +package com.jocmp.capy.accounts.reader + +import com.jocmp.capy.AccountDelegate +import com.jocmp.capy.ArticleStatus +import com.jocmp.capy.InMemoryDatabaseProvider +import com.jocmp.capy.articles.UnreadSortOrder +import com.jocmp.capy.db.Database +import com.jocmp.capy.fixtures.FeedFixture +import com.jocmp.capy.persistence.ArticleRecords +import com.jocmp.readerclient.Category +import com.jocmp.readerclient.GoogleReader +import com.jocmp.readerclient.Item +import com.jocmp.readerclient.Item.Link +import com.jocmp.readerclient.Item.Origin +import com.jocmp.readerclient.Item.Summary +import com.jocmp.readerclient.ItemRef +import com.jocmp.readerclient.Stream +import com.jocmp.readerclient.StreamItemIDsResult +import com.jocmp.readerclient.StreamItemsContentsResult +import com.jocmp.readerclient.Subscription +import com.jocmp.readerclient.SubscriptionListResult +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.Headers +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.Response +import java.net.SocketTimeoutException +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReaderAccountDelegateTest { + private val accountID = "777" + private val postToken = "alice/foobar123" + private lateinit var database: Database + private lateinit var googleReader: GoogleReader + private lateinit var feedFixture: FeedFixture + private lateinit var delegate: AccountDelegate + + val subscriptions = listOf( + Subscription( + id = "feed/2", + title = "Ars Technica - All content", + categories = listOf( + Category( + id = "user/1/label/Gadgets", + label = "Gadgets" + ) + ), + url = "https://feeds.arstechnica.com/arstechnica/index", + htmlUrl = "https://arstechnica.com", + iconUrl = "", + ), + Subscription( + id = "feed/3", + title = "The Verge", + categories = listOf( + Category( + id = "user/1/label/All", + label = "All" + ) + ), + url = "https://www.theverge.com/rss/index.xml", + htmlUrl = "https://theverge.com", + iconUrl = "", + ), + ) + + private val items = listOf( + Item( + id = "tag:google.com,2005:reader/item/0000000000000010", + published = 1723806013, + title = "Rocket Report: ULA is losing engineers; SpaceX is launching every two days", + canonical = listOf(Link("https://arstechnica.com/?p=2043638")), + origin = Origin( + streamId = "feed/2", + title = "Ars Technica - All content", + htmlUrl = "https://arstechnica.com", + ), + summary = Summary("Summary - Welcome to Edition 7.07 of the Rocket Report! SpaceX has not missed a beat since the Federal Aviation Administration gave the company a green light to resume Falcon 9 launches after a failure last month."), + content = Item.Content("Content - Welcome to Edition 7.07 of the Rocket Report! SpaceX has not missed a beat since the Federal Aviation Administration gave the company a green light to resume Falcon 9 launches after a failure last month."), + ) + ) + + @BeforeTest + fun setup() { + database = InMemoryDatabaseProvider.build(accountID) + feedFixture = FeedFixture(database) + googleReader = mockk() + + delegate = ReaderAccountDelegate(database, googleReader) + } + + @Test + fun refresh_updatesEntries() = runTest { + val itemRefs = listOf(ItemRef("16")) + + stubSubscriptions() + stubUnread(itemRefs) + stubStarred() + stubReadingList(itemRefs) + + delegate.refresh() + + val articles = database + .articlesQueries + .countAll(read = false, starred = false) + .executeAsList() + + val taggedNames = database + .feedsQueries + .tagged() + .executeAsList() + .map { it.name } + + val feeds = database + .feedsQueries + .all() + .executeAsList() + + assertEquals(expected = 2, actual = feeds.size) + + assertEquals(expected = listOf("All", "Gadgets"), actual = taggedNames) + + assertEquals(expected = 1, actual = articles.size) + } + + @Test + fun refresh_findsMissingArticles() = runTest { + val itemRefs = listOf("1", "16").map { ItemRef(it) } + + stubSubscriptions() + stubUnread(itemRefs) + stubStarred(listOf("1", "2").map { ItemRef(it) }) + + val unreadItem = Item( + id = "tag:google.com,2005:reader/item/0000000000000001", + published = 1708710158, + title = "Reddit admits more moderator protests could hurt its business", + canonical = listOf(Link("https://arstechnica.com/?p=2005526")), + author = "Scharon Harding", + origin = Origin( + streamId = "feed/2", + title = "Ars Technica - All content", + htmlUrl = "https://arstechnica.com", + ), + summary = Summary("Enlarge (credit: Jakub Porzycki/NurPhoto via Getty Images) Reddit filed to go public on Thursday (PDF), revealing various details of the social media company's inner workings. Among the revelations, Reddit acknowledged the threat of future user protests"), + ) + + val readItem = Item( + id = "tag:google.com,2005:reader/item/0000000000000002", + title = "Apple’s iPhone 16 launch event is set for September", + summary = Summary("Apple’s tagline: 'It’s Glowtime.'"), + canonical = listOf(Link("https://www.theverge.com/2024/8/26/24223957/apple-iphone-16-launch-event-date-glowtime")), + published = 1724521358, + author = "Jay Peters", + origin = Origin( + streamId = "feed/3", + title = "The Verge", + htmlUrl = "https://theverge.com", + ), + ) + + stubReadingList(itemRefs, listOf(unreadItem, items.first())) + + val starredItems = listOf(unreadItem, readItem) + + coEvery { + googleReader.streamItemsContents(starredItems.map { it.hexID }, postToken = postToken) + }.returns(Response.success(StreamItemsContentsResult(starredItems))) + + delegate.refresh() + + val starredArticles = ArticleRecords(database) + .byStatus + .all( + ArticleStatus.STARRED, + limit = 2, + offset = 0, + unreadSort = UnreadSortOrder.NEWEST_FIRST, + ) + .executeAsList() + + val unreadArticle = starredArticles.find { it.id == unreadItem.hexID }!! + val readArticle = starredArticles.find { it.id == readItem.hexID }!! + + assertFalse(unreadArticle.read) + assertTrue(readArticle.read) + } + + @Test + fun refresh_IOException() = runTest { + val networkError = SocketTimeoutException("Sorry networked charlie") + coEvery { googleReader.subscriptionList() }.throws(networkError) + + val result = delegate.refresh() + + assertEquals(result, Result.failure(networkError)) + } + + private fun stubSubscriptions(subscriptions: List<Subscription> = this.subscriptions) { + coEvery { googleReader.subscriptionList() }.returns( + Response.success( + SubscriptionListResult( + subscriptions + ) + ) + ) + } + + private fun stubStarred(itemRefs: List<ItemRef> = emptyList()) { + coEvery { + googleReader.streamItemsIDs( + streamID = Stream.STARRED.id, + ) + }.returns( + Response.success( + StreamItemIDsResult( + itemRefs = itemRefs, + continuation = null + ) + ) + ) + } + + private fun stubReadingList(itemRefs: List<ItemRef>, items: List<Item> = this.items) { + coEvery { + googleReader.streamItemsIDs( + streamID = Stream.READING_LIST.id, + since = any(), + count = 100, + excludedStreamID = Stream.READ.id + ) + }.returns(Response.success(StreamItemIDsResult(itemRefs = itemRefs, continuation = null))) + + val errorResponse = okhttp3.Response.Builder() + .code(401) + .protocol(Protocol.HTTP_1_1) + .headers(Headers.headersOf(GoogleReader.BAD_TOKEN_HEADER_KEY, "true")) + .message("Unauthorized") + .request( + Request.Builder().url("http://localhost/").build() + ).build() + + coEvery { + googleReader.streamItemsContents(items.map { it.hexID }, postToken = null) + }.returns(Response.error("".toResponseBody(), errorResponse)) + + coEvery { + googleReader.token() + }.returns(Response.success(postToken)) + + coEvery { + googleReader.streamItemsContents(items.map { it.hexID }, postToken = postToken) + }.returns(Response.success(StreamItemsContentsResult(items))) + } + + private fun stubUnread(itemRefs: List<ItemRef>) { + coEvery { + googleReader.streamItemsIDs( + streamID = Stream.READING_LIST.id, + count = 10_000, + excludedStreamID = Stream.READ.id, + ) + }.returns(Response.success(StreamItemIDsResult(itemRefs = itemRefs, continuation = null))) + } +} diff --git a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt index 18319b72..63514da3 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt @@ -7,9 +7,10 @@ import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory import retrofit2.create +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.POST -import retrofit2.http.Path import retrofit2.http.Query interface GoogleReader { @@ -20,29 +21,23 @@ interface GoogleReader { @GET("reader/api/0/stream/items/ids") suspend fun streamItemsIDs( - @Query("s") streamID: String, - @Query("n") count: Int = 10_000, - @Query("xt") excludedStreamID: String? = null, - @Query("output") output: String = "json", + @Query("s") streamID: String, + /** Epoch timestamp. Items older than this timestamp are filtered out. */ + @Query("ot") since: Long? = null, + @Query("c") continuation: String? = null, + @Query("n") count: Int = 10_000, + /** A stream ID to exclude from the list. */ + @Query("xt") excludedStreamID: String? = null, + @Query("output") output: String = "json", ): Response<StreamItemIDsResult> - // use to fetch missing articles + @FormUrlEncoded @POST("reader/api/0/stream/items/contents") suspend fun streamItemsContents( -// @FormUrlEncoded - ) - - @GET("reader/api/0/stream/contents/{streamID}") - suspend fun streamContents( - @Path("streamID") streamID: String, - @Query("n") count: Int = 100, - /** Epoch timestamp. Items older than this timestamp are filtered out. */ - @Query("ot") since: Long? = null, - /** A stream ID to exclude from the list. */ - @Query("xt") excludedStreamID: String = Stream.READ.id, - @Query("c") continuation: String? = null, + @Field("i") ids: List<String>, + @Field("T") postToken: String?, @Query("output") output: String = "json", - ): Response<StreamContentsResult> + ): Response<StreamItemsContentsResult> @POST("accounts/ClientLogin") suspend fun clientLogin( @@ -50,7 +45,12 @@ interface GoogleReader { @Query("Passwd") password: String ): Response<String> + @GET("reader/api/0/token") + suspend fun token(): Response<String> + companion object { + const val BAD_TOKEN_HEADER_KEY = "X-Reader-Google-Bad-Token" + fun create( client: OkHttpClient, baseURL: String diff --git a/readerclient/src/main/java/com/jocmp/readerclient/Item.kt b/readerclient/src/main/java/com/jocmp/readerclient/Item.kt index fe4f5ab5..1e8f6462 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/Item.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/Item.kt @@ -10,6 +10,7 @@ data class Item( val canonical: List<Link>, val summary: Summary, val origin: Origin, + val content: Content? = null, val author: String? = null, val enclosure: List<Enclosure>? = null, ) { @@ -27,6 +28,11 @@ data class Item( val content: String, ) + @JsonClass(generateAdapter = true) + data class Content( + val content: String, + ) + @JsonClass(generateAdapter = true) data class Enclosure( val href: String, diff --git a/readerclient/src/main/java/com/jocmp/readerclient/ItemRef.kt b/readerclient/src/main/java/com/jocmp/readerclient/ItemRef.kt new file mode 100644 index 00000000..728d91d9 --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/ItemRef.kt @@ -0,0 +1,10 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ItemRef( + val id: String, +) { + val hexID = String.format("%016x", id.toLong()) +} diff --git a/readerclient/src/main/java/com/jocmp/readerclient/Stream.kt b/readerclient/src/main/java/com/jocmp/readerclient/Stream.kt index 3daaef04..f1b0b497 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) { - KEPT_UNREAD("user/-/state/com.google/kept-unread"), READING_LIST("user/-/state/com.google/reading-list"), diff --git a/readerclient/src/main/java/com/jocmp/readerclient/StreamItemIDsResult.kt b/readerclient/src/main/java/com/jocmp/readerclient/StreamItemIDsResult.kt index 65f848df..22ff95c5 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/StreamItemIDsResult.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/StreamItemIDsResult.kt @@ -4,12 +4,6 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class StreamItemIDsResult( - val itemRefs: List<ItemRef> -) { - @JsonClass(generateAdapter = true) - data class ItemRef( - val id: Long, - ) { - val hexID = String.format("%016x", id) - } -} + val itemRefs: List<ItemRef>, + val continuation: String?, +) diff --git a/readerclient/src/main/java/com/jocmp/readerclient/StreamItemsContentsResult.kt b/readerclient/src/main/java/com/jocmp/readerclient/StreamItemsContentsResult.kt new file mode 100644 index 00000000..2deaf229 --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/StreamItemsContentsResult.kt @@ -0,0 +1,8 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class StreamItemsContentsResult( + val items: List<Item>, +) diff --git a/readerclient/src/main/java/com/jocmp/readerclient/Subscription.kt b/readerclient/src/main/java/com/jocmp/readerclient/Subscription.kt index 868bc0b2..9b4a319d 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/Subscription.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/Subscription.kt @@ -4,7 +4,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class Subscription( - val id: String, + val id: String, val title: String, val categories: List<Category>, val url: String,