diff --git a/capy/src/main/java/com/jocmp/capy/Account.kt b/capy/src/main/java/com/jocmp/capy/Account.kt index c4125e3f..6f2650bd 100644 --- a/capy/src/main/java/com/jocmp/capy/Account.kt +++ b/capy/src/main/java/com/jocmp/capy/Account.kt @@ -6,9 +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.articles.UnreadSortOrder import com.jocmp.capy.common.TimeHelpers.nowUTC import com.jocmp.capy.common.sortedByTitle @@ -47,7 +47,11 @@ data class Account( ) ) - Source.FRESHRSS -> ReaderAccountDelegate() + Source.FRESHRSS -> buildFreshRSSDelegate( + database = database, + path = cacheDirectory, + preferences = preferences + ) } ) { internal val articleRecords: ArticleRecords = ArticleRecords(database) 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/WithErrorHandling.kt b/capy/src/main/java/com/jocmp/capy/accounts/WithErrorHandling.kt new file mode 100644 index 00000000..7b13f723 --- /dev/null +++ b/capy/src/main/java/com/jocmp/capy/accounts/WithErrorHandling.kt @@ -0,0 +1,20 @@ +package com.jocmp.capy.accounts + +import com.jocmp.capy.common.UnauthorizedError +import java.net.UnknownHostException + +internal suspend fun withErrorHandling(func: suspend () -> T?): Result { + return try { + val result = func() + + if (result != null) { + Result.success(result) + } else { + Result.failure(Throwable("Unexpected error")) + } + } catch (e: UnknownHostException) { + return Result.failure(e) + } catch (e: UnauthorizedError) { + return Result.failure(e) + } +} 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 9b46b536..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 @@ -6,6 +6,7 @@ import com.jocmp.capy.Feed import com.jocmp.capy.accounts.AddFeedResult import com.jocmp.capy.accounts.FeedOption import com.jocmp.capy.accounts.SubscriptionChoice +import com.jocmp.capy.accounts.withErrorHandling import com.jocmp.capy.common.TimeHelpers import com.jocmp.capy.common.UnauthorizedError import com.jocmp.capy.common.host @@ -31,7 +32,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import okio.IOException import org.jsoup.Jsoup -import java.net.UnknownHostException import java.time.ZonedDateTime internal class FeedbinAccountDelegate( @@ -193,7 +193,7 @@ internal class FeedbinAccountDelegate( override suspend fun refresh(cutoffDate: ZonedDateTime?): Result { return try { - val since = articleRecords.maxUpdatedAt() + val since = articleRecords.maxUpdatedAt().toString() refreshFeeds() refreshTaggings() @@ -207,7 +207,7 @@ internal class FeedbinAccountDelegate( } } - private suspend fun refreshArticles(since: String = articleRecords.maxUpdatedAt()) { + private suspend fun refreshArticles(since: String = articleRecords.maxUpdatedAt().toString()) { refreshStarredEntries() refreshUnreadEntries() refreshAllArticles(since = since) @@ -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() }) } } } @@ -314,10 +314,10 @@ internal class FeedbinAccountDelegate( ) } - private fun saveEntries(entries: List, updatedAt: ZonedDateTime = TimeHelpers.nowUTC()) { + private fun saveEntries(entries: List) { database.transactionWithErrorHandling { entries.forEach { entry -> - val updated = updatedAt.toEpochSecond() + val updated = TimeHelpers.nowUTC().toEpochSecond() database.articlesQueries.create( id = entry.id.toString(), @@ -356,20 +356,4 @@ internal class FeedbinAccountDelegate( const val MAX_ENTRY_LIMIT = 100 const val MAX_CREATE_UNREAD_LIMIT = 1_000 } - - private suspend fun withErrorHandling(func: suspend () -> T?): Result { - return try { - val result = func() - - if (result != null) { - Result.success(result) - } else { - Result.failure(Throwable("Unexpected error")) - } - } catch (e: UnknownHostException) { - return Result.failure(e) - } catch (e: UnauthorizedError) { - return Result.failure(e) - } - } } 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 9824c795..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 @@ -4,13 +4,38 @@ 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.articles.ArticleContent +import com.jocmp.capy.common.TimeHelpers +import com.jocmp.capy.common.UnauthorizedError +import com.jocmp.capy.common.transactionWithErrorHandling +import com.jocmp.capy.common.withResult +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 + +internal class ReaderAccountDelegate( + private val database: Database, + private val googleReader: GoogleReader, + httpClient: OkHttpClient = OkHttpClient(), +) : AccountDelegate { + private var postToken = AtomicReference(null) + private val articleContent = ArticleContent(httpClient) + private val articleRecords = ArticleRecords(database) -/** - * Save Auth Token for later use - * self.credentials = Credentials(type: .readerAPIKey, username: credentials.username, secret: authString) - */ -internal class ReaderAccountDelegate: AccountDelegate { override suspend fun addFeed( url: String, title: String?, @@ -24,7 +49,18 @@ internal class ReaderAccountDelegate: AccountDelegate { } override suspend fun refresh(cutoffDate: ZonedDateTime?): Result { - return Result.failure(Throwable("")) + return try { + val since = articleRecords.maxUpdatedAt().toEpochSecond() + + refreshFeeds() + refreshArticles(since = since) + + Result.success(Unit) + } catch (exception: IOException) { + Result.failure(exception) + } catch (e: UnauthorizedError) { + Result.failure(e) + } } override suspend fun removeStar(articleIDs: List): Result { @@ -52,6 +88,209 @@ internal class ReaderAccountDelegate: AccountDelegate { } override suspend fun fetchFullContent(article: Article): Result { - return Result.failure(Throwable("")) + article.url ?: return Result.failure(Error("No article url found")) + + return articleContent.fetch(article.url) + } + + private suspend fun refreshFeeds() { + withResult(googleReader.subscriptionList()) { result -> + database.transactionWithErrorHandling { + result.subscriptions.forEach { subscription -> + upsertFeed(subscription) + upsertTaggings(subscription) + } + + cleanUpTaggings(result.subscriptions) + } + } + } + + private fun upsertTaggings(subscription: Subscription) { + subscription.categories.forEach { category -> + database.taggingsQueries.upsert( + id = taggingID(subscription, category), + feed_id = subscription.id, + name = category.label.orEmpty(), + ) + } + } + + private fun upsertFeed(subscription: Subscription) { + database.feedsQueries.upsert( + id = subscription.id, + subscription_id = subscription.id, + title = subscription.title, + feed_url = subscription.url, + site_url = subscription.htmlUrl, + favicon_url = subscription.iconUrl.ifBlank { null } + ) + } + + private fun cleanUpTaggings(subscriptions: List) { + val excludedIDs = subscriptions.flatMap { + it.categories.map { category -> + taggingID(it, category) + } + } + + database.taggingsQueries.deleteOrphanedTags( + excludedIDs = excludedIDs + ) + } + + private suspend fun refreshArticles( + since: Long = articleRecords.maxUpdatedAt().toEpochSecond() + ) { + refreshStarredItems() + refreshUnreadItems() + refreshAllArticles(since = since) + fetchMissingArticles() + } + + private suspend fun refreshUnreadItems() { + withResult( + googleReader.streamItemsIDs( + streamID = Stream.READING_LIST.id, + excludedStreamID = Stream.READ.id + ) + ) { result -> + articleRecords.markAllUnread(articleIDs = result.itemRefs.map { it.hexID }) + } + } + + private suspend fun refreshStarredItems() { + withResult(googleReader.streamItemsIDs(streamID = Stream.STARRED.id)) { result -> + articleRecords.markAllStarred(articleIDs = result.itemRefs.map { it.hexID }) + } + } + + 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 + + saveItems(result.items) + } + } + } + } + + private suspend fun refreshAllArticles(since: Long) { + fetchPaginatedItems( + since = since, + stream = Stream.READING_LIST + ) + } + + private suspend fun fetchPaginatedItems( + since: Long? = null, + stream: Stream, + continuation: String? = null, + ) { + 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 + + coroutineScope { + launch { + fetchItemContents(result.itemRefs) + } + } + + val nextContinuation = result.continuation ?: return + + fetchPaginatedItems( + since = since, + stream = stream, + continuation = nextContinuation + ) + } + + private suspend fun fetchItemContents(items: List) { + 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) { + database.transactionWithErrorHandling { + items.forEach { item -> + val updated = TimeHelpers.nowUTC().toEpochSecond() + + database.articlesQueries.create( + id = item.hexID, + feed_id = item.origin.streamId, + title = item.title, + author = item.author, + content_html = item.content?.content ?: item.summary.content, + extracted_content_url = null, + summary = Jsoup.parse(item.summary.content).text(), + url = item.canonical.firstOrNull()?.href, + image_url = item.image?.href, + published_at = item.published + ) + + database.articlesQueries.updateStatus( + article_id = item.hexID, + updated_at = updated, + read = true + ) + } + } + } + + private suspend fun withPostToken(handler: suspend () -> Response): Response { + 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 new file mode 100644 index 00000000..30e55f55 --- /dev/null +++ b/capy/src/main/java/com/jocmp/capy/accounts/reader/ReaderOkHttpClient.kt @@ -0,0 +1,21 @@ +package com.jocmp.capy.accounts.reader + +import com.jocmp.capy.AccountPreferences +import com.jocmp.capy.accounts.BasicAuthInterceptor +import com.jocmp.capy.accounts.httpClientBuilder +import okhttp3.OkHttpClient +import java.net.URI + +internal object ReaderOkHttpClient { + fun forAccount(path: URI, preferences: AccountPreferences): OkHttpClient { + return httpClientBuilder(cachePath = path) + .addInterceptor( + BasicAuthInterceptor { + val secret = preferences.password.get() + + "GoogleLogin auth=${secret}" + } + ) + .build() + } +} 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 080355b7..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 { + fun findMissingArticles(): List { return database .articlesQueries .findMissingArticles() .executeAsList() - .map { it.toLong() } } internal suspend fun notifications(since: ZonedDateTime): List { @@ -150,12 +149,12 @@ internal class ArticleRecords internal constructor( } /** Date in UTC */ - fun maxUpdatedAt(): String { + fun maxUpdatedAt(): ZonedDateTime { val max = database.articlesQueries.lastUpdatedAt().executeAsOne().MAX - max ?: return cutoffDate().toString() + max ?: return cutoffDate() - return max.toDateTimeFromSeconds.toString() + return max.toDateTimeFromSeconds } fun unreadArticleIDs(filter: ArticleFilter, range: MarkRead): List { 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 = 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( @@ -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 = this.subscriptions) { + coEvery { googleReader.subscriptionList() }.returns( + Response.success( + SubscriptionListResult( + subscriptions + ) + ) + ) + } + + private fun stubStarred(itemRefs: List = emptyList()) { + coEvery { + googleReader.streamItemsIDs( + streamID = Stream.STARRED.id, + ) + }.returns( + Response.success( + StreamItemIDsResult( + itemRefs = itemRefs, + continuation = null + ) + ) + ) + } + + private fun stubReadingList(itemRefs: List, items: List = 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) { + 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/Category.kt b/readerclient/src/main/java/com/jocmp/readerclient/Category.kt new file mode 100644 index 00000000..d50287e0 --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/Category.kt @@ -0,0 +1,9 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Category( + val id: String, + val label: String?, +) diff --git a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt index 9584264a..63514da3 100644 --- a/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt +++ b/readerclient/src/main/java/com/jocmp/readerclient/GoogleReader.kt @@ -7,17 +7,50 @@ 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.Query interface GoogleReader { + @GET("reader/api/0/subscription/list") + suspend fun subscriptionList( + @Query("output") output: String = "json" + ): Response + + @GET("reader/api/0/stream/items/ids") + suspend fun streamItemsIDs( + @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 + + @FormUrlEncoded + @POST("reader/api/0/stream/items/contents") + suspend fun streamItemsContents( + @Field("i") ids: List, + @Field("T") postToken: String?, + @Query("output") output: String = "json", + ): Response + @POST("accounts/ClientLogin") suspend fun clientLogin( @Query("Email") email: String, @Query("Passwd") password: String ): Response + @GET("reader/api/0/token") + suspend fun token(): Response + 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 new file mode 100644 index 00000000..1e8f6462 --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/Item.kt @@ -0,0 +1,49 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Item( + val id: String, + val published: Long, + val title: String, + val canonical: List, + val summary: Summary, + val origin: Origin, + val content: Content? = null, + val author: String? = null, + val enclosure: List? = null, +) { + val hexID = id.split("/").last() + + @JsonClass(generateAdapter = true) + data class Origin( + val streamId: String, + val htmlUrl: String, + val title: String, + ) + + @JsonClass(generateAdapter = true) + data class Summary( + val content: String, + ) + + @JsonClass(generateAdapter = true) + data class Content( + val content: String, + ) + + @JsonClass(generateAdapter = true) + data class Enclosure( + val href: String, + val type: String, + ) + + @JsonClass(generateAdapter = true) + data class Link( + val href: String + ) + + val image: Enclosure? + get() = enclosure?.find { it.type.startsWith("image") } +} 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 new file mode 100644 index 00000000..f1b0b497 --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/Stream.kt @@ -0,0 +1,10 @@ +package com.jocmp.readerclient + +enum class Stream(val id: String) { + + READING_LIST("user/-/state/com.google/reading-list"), + + STARRED("user/-/state/com.google/starred"), + + READ("user/-/state/com.google/read") +} diff --git a/readerclient/src/main/java/com/jocmp/readerclient/StreamContentsResult.kt b/readerclient/src/main/java/com/jocmp/readerclient/StreamContentsResult.kt new file mode 100644 index 00000000..086ee4ee --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/StreamContentsResult.kt @@ -0,0 +1,9 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class StreamContentsResult( + val items: List, + val continuation: String? = null, +) diff --git a/readerclient/src/main/java/com/jocmp/readerclient/StreamItemIDsResult.kt b/readerclient/src/main/java/com/jocmp/readerclient/StreamItemIDsResult.kt new file mode 100644 index 00000000..22ff95c5 --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/StreamItemIDsResult.kt @@ -0,0 +1,9 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class StreamItemIDsResult( + val itemRefs: List, + 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, +) diff --git a/readerclient/src/main/java/com/jocmp/readerclient/Subscription.kt b/readerclient/src/main/java/com/jocmp/readerclient/Subscription.kt new file mode 100644 index 00000000..9b4a319d --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/Subscription.kt @@ -0,0 +1,13 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Subscription( + val id: String, + val title: String, + val categories: List, + val url: String, + val htmlUrl: String, + val iconUrl: String, +) diff --git a/readerclient/src/main/java/com/jocmp/readerclient/SubscriptionListResult.kt b/readerclient/src/main/java/com/jocmp/readerclient/SubscriptionListResult.kt new file mode 100644 index 00000000..c376854a --- /dev/null +++ b/readerclient/src/main/java/com/jocmp/readerclient/SubscriptionListResult.kt @@ -0,0 +1,8 @@ +package com.jocmp.readerclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SubscriptionListResult( + val subscriptions: List +)