This commit is contained in:
2026-03-30 11:26:33 -04:00
parent 533dc1ba14
commit 1fe72401f0
1655 changed files with 531 additions and 45407 deletions

View File

@@ -0,0 +1,174 @@
package com.rssuper.services
import com.rssuper.parsing.FeedParser
import com.rssuper.parsing.ParseResult
import okhttp3.Call
import okhttp3.EventListener
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
class FeedFetcher(
private val timeoutMs: Long = 15000,
private val maxRetries: Int = 3,
private val baseRetryDelayMs: Long = 1000
) {
private val client: OkHttpClient
init {
val builder = OkHttpClient.Builder()
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(timeoutMs, TimeUnit.MILLISECONDS)
builder.eventListenerFactory { call -> TimeoutEventListener(call) }
client = builder.build()
}
fun fetch(
url: String,
httpAuth: HTTPAuthCredentials? = null,
ifNoneMatch: String? = null,
ifModifiedSince: String? = null
): NetworkResult<FetchResult> {
var lastError: Throwable? = null
for (attempt in 1..maxRetries) {
val result = fetchSingleAttempt(url, httpAuth, ifNoneMatch, ifModifiedSince)
when (result) {
is NetworkResult.Success -> return result
is NetworkResult.Failure -> {
lastError = result.error
if (attempt < maxRetries) {
val delay = calculateBackoffDelay(attempt)
Thread.sleep(delay)
}
}
}
}
return NetworkResult.Failure(lastError ?: NetworkError.Unknown())
}
fun fetchAndParse(url: String, httpAuth: HTTPAuthCredentials? = null): NetworkResult<ParseResult> {
val fetchResult = fetch(url, httpAuth)
return fetchResult.flatMap { result ->
try {
val parseResult = FeedParser.parse(result.feedXml, url)
NetworkResult.Success(parseResult)
} catch (e: Exception) {
NetworkResult.Failure(NetworkError.Unknown(e))
}
}
}
private fun fetchSingleAttempt(
url: String,
httpAuth: HTTPAuthCredentials? = null,
ifNoneMatch: String? = null,
ifModifiedSince: String? = null
): NetworkResult<FetchResult> {
val requestBuilder = Request.Builder()
.url(url)
.addHeader("User-Agent", "RSSuper/1.0")
ifNoneMatch?.let { requestBuilder.addHeader("If-None-Match", it) }
ifModifiedSince?.let { requestBuilder.addHeader("If-Modified-Since", it) }
httpAuth?.let {
requestBuilder.addHeader("Authorization", it.toCredentials())
}
val request = requestBuilder.build()
return try {
val response = client.newCall(request).execute()
handleResponse(response, url)
} catch (e: IOException) {
NetworkResult.Failure(NetworkError.Unknown(e))
} catch (e: Exception) {
NetworkResult.Failure(NetworkError.Unknown(e))
}
}
private fun handleResponse(response: Response, url: String): NetworkResult<FetchResult> {
try {
val body = response.body
return when (response.code) {
200 -> {
if (body != null) {
NetworkResult.Success(FetchResult.fromResponse(response, url, response.cacheResponse != null))
} else {
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
}
}
304 -> {
if (body != null) {
NetworkResult.Success(FetchResult.fromResponse(response, url, true))
} else {
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
}
}
in 400..499 -> {
NetworkResult.Failure(NetworkError.Http(response.code, "Client error: ${response.message}"))
}
in 500..599 -> {
NetworkResult.Failure(NetworkError.Http(response.code, "Server error: ${response.message}"))
}
else -> {
NetworkResult.Failure(NetworkError.Http(response.code, "Unexpected status code: ${response.code}"))
}
}
} finally {
response.close()
}
}
private fun calculateBackoffDelay(attempt: Int): Long {
var delay = baseRetryDelayMs
for (i in 1 until attempt) {
delay *= 2
}
return delay
}
private class TimeoutEventListener(private val call: Call) : EventListener() {
override fun callStart(call: Call) {
}
override fun callEnd(call: Call) {
}
override fun callFailed(call: Call, ioe: IOException) {
}
}
sealed class NetworkResult<out T> {
data class Success<T>(val value: T) : NetworkResult<T>()
data class Failure<T>(val error: Throwable) : NetworkResult<T>()
fun isSuccess(): Boolean = this is Success
fun isFailure(): Boolean = this is Failure
fun getOrNull(): T? = when (this) {
is Success -> value
is Failure -> null
}
fun <R> map(transform: (T) -> R): NetworkResult<R> = when (this) {
is Success -> Success(transform(value))
is Failure -> Failure(error)
}
fun <R> flatMap(transform: (T) -> NetworkResult<R>): NetworkResult<R> = when (this) {
is Success -> transform(value)
is Failure -> Failure(error)
}
}
}

View File

@@ -0,0 +1,31 @@
package com.rssuper.services
import okhttp3.CacheControl
import okhttp3.Response
data class FetchResult(
val feedXml: String,
val url: String,
val cacheControl: CacheControl?,
val isCached: Boolean,
val etag: String? = null,
val lastModified: String? = null
) {
companion object {
fun fromResponse(response: Response, url: String, isCached: Boolean = false): FetchResult {
val body = response.body?.string() ?: ""
val cacheControl = response.cacheControl
val etag = response.header("ETag")
val lastModified = response.header("Last-Modified")
return FetchResult(
feedXml = body,
url = url,
cacheControl = cacheControl,
isCached = isCached,
etag = etag,
lastModified = lastModified
)
}
}
}

View File

@@ -0,0 +1,12 @@
package com.rssuper.services
import okhttp3.Credentials
data class HTTPAuthCredentials(
val username: String,
val password: String
) {
fun toCredentials(): String {
return Credentials.basic(username, password)
}
}

View File

@@ -0,0 +1,7 @@
package com.rssuper.services
sealed class NetworkError(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
data class Http(val statusCode: Int, override val message: String) : NetworkError(message)
data class Timeout(val durationMs: Long) : NetworkError("Timeout")
data class Unknown(override val cause: Throwable? = null) : NetworkError(cause = cause)
}