Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
}
434 changes: 434 additions & 0 deletions app/src/main/java/me/ash/reader/domain/service/FeedlyRssService.kt

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion app/src/main/java/me/ash/reader/domain/service/RssService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ constructor(
private val localRssService: LocalRssService,
private val feverRssService: FeverRssService,
private val googleReaderRssService: GoogleReaderRssService,
private val feedlyRssService: FeedlyRssService,
) {

private val currentServiceFlow =
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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 <reified T> getRequest(
path: String,
params: List<Pair<String, String>>? = 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 <reified T> 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<FeedlyDTO.Subscription> =
getRequest<Array<FeedlyDTO.Subscription>>("subscriptions").toList()

suspend fun getCollections(): List<FeedlyDTO.Collection> =
getRequest<Array<FeedlyDTO.Collection>>("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<String>) {
if (entryIds.isEmpty()) return
withRetries(retryConfig) {
postRequest<String>(
"markers",
FeedlyDTO.MarkersRequest(
action = "markAsRead",
type = "entries",
entryIds = entryIds,
),
)
}.getOrThrow()
}

suspend fun markEntriesAsUnread(entryIds: List<String>) {
if (entryIds.isEmpty()) return
withRetries(retryConfig) {
postRequest<String>(
"markers",
FeedlyDTO.MarkersRequest(
action = "keepUnread",
type = "entries",
entryIds = entryIds,
),
)
}.getOrThrow()
}

suspend fun markFeedAsRead(feedId: String, asOf: Long? = null) {
withRetries(retryConfig) {
postRequest<String>(
"markers",
FeedlyDTO.MarkersRequest(
action = "markAsRead",
type = "feeds",
feedIds = listOf(feedId),
asOf = asOf,
),
)
}.getOrThrow()
}

suspend fun markCategoryAsRead(categoryId: String, asOf: Long? = null) {
withRetries(retryConfig) {
postRequest<String>(
"markers",
FeedlyDTO.MarkersRequest(
action = "markAsRead",
type = "categories",
categoryIds = listOf(categoryId),
asOf = asOf,
),
)
}.getOrThrow()
}

suspend fun markEntriesAsSaved(entryIds: List<String>) {
if (entryIds.isEmpty()) return
withRetries(retryConfig) {
postRequest<String>(
"markers",
FeedlyDTO.MarkersRequest(
action = "markAsSaved",
type = "entries",
entryIds = entryIds,
),
)
}.getOrThrow()
}

suspend fun markEntriesAsUnsaved(entryIds: List<String>) {
if (entryIds.isEmpty()) return
withRetries(retryConfig) {
postRequest<String>(
"markers",
FeedlyDTO.MarkersRequest(
action = "markAsUnsaved",
type = "entries",
entryIds = entryIds,
),
)
}.getOrThrow()
}

suspend fun addSubscription(
feedId: String,
title: String?,
categoryId: String?,
): FeedlyDTO.Subscription {
val body =
mutableMapOf<String, Any>("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<String, FeedlyAPI> = ConcurrentHashMap()

fun getInstance(context: Context, accessToken: String): FeedlyAPI =
instances.getOrPut(accessToken) { FeedlyAPI(context, accessToken) }

fun clearInstance() {
instances.clear()
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Category>?,
val iconUrl: String?,
val website: String?,
val updated: Long?,
)

data class Collection(
val id: String?,
val label: String?,
val subscriptions: List<Subscription>?,
)

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<Link>?,
val alternate: List<Link>?,
val origin: Origin?,
val tags: List<Tag>?,
val enclosure: List<Link>?,
val visual: Visual?,
val thumbnail: List<Visual>?,
)

data class StreamContents(
val id: String?,
val items: List<StreamItem>?,
val continuation: String?,
val updated: Long?,
)

data class MarkersRequest(
val action: String,
val type: String,
val entryIds: List<String>? = null,
val feedIds: List<String>? = null,
val categoryIds: List<String>? = null,
val asOf: Long? = null,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading