Add support for Fever favicons

This commit is contained in:
Shinokuni 2024-08-12 14:18:31 +02:00
parent 3c99bafe43
commit f67f817b82
10 changed files with 193 additions and 12 deletions

View File

@ -56,7 +56,7 @@ class FeverDataSource(private val service: FeverService) {
sinceId = unreadIds.first().toLong()
},
async { starredIds = service.getStarredItemsIds(body) },
async { favicons = listOf() }
async { favicons = service.getFavicons(body) }
)
.awaitAll()
}
@ -67,7 +67,7 @@ class FeverDataSource(private val service: FeverService) {
async { feverFeeds = service.getFeeds(body) },
async { unreadIds = service.getUnreadItemsIds(body) },
async { starredIds = service.getStarredItemsIds(body) },
async { favicons = listOf() },
async { favicons = service.getFavicons(body) },
async {
items = buildList {
var localSinceId = lastSinceId

View File

@ -6,9 +6,11 @@ import com.readrops.api.utils.extensions.skipField
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
data class Favicon(
val id: Int,
val id: String,
val data: ByteArray
)
@ -17,6 +19,7 @@ class FeverFaviconsAdapter {
@ToJson
fun toJson(favicons: List<Favicon>) = ""
@OptIn(ExperimentalEncodingApi::class)
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Favicon> = with(reader) {
@ -41,14 +44,14 @@ class FeverFaviconsAdapter {
while (hasNext()) {
when (selectName(NAMES)) {
0 -> id = nextInt()
1 -> data = nextString().toByteArray()
1 -> data = Base64.decode(nextString().substringAfter("base64,"))
else -> skipValue()
}
}
if (id > 0 && data != null) {
favicons += Favicon(
id = id,
id = id.toString(),
data = data,
)
}

View File

@ -11,11 +11,12 @@ import coil.ImageLoaderFactory
import coil.disk.DiskCache
import com.readrops.api.apiModule
import com.readrops.app.util.CrashActivity
import com.readrops.app.util.FeverFaviconFetcher
import com.readrops.db.dbModule
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.context.startKoin
import org.koin.core.logger.Level
import kotlin.system.exitProcess
@ -49,10 +50,11 @@ open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.okHttpClient { get() }
.components { add(FeverFaviconFetcher.Factory(get())) }
.diskCache {
DiskCache.Builder()
.directory(this.cacheDir.resolve("image_cache"))
.maxSizePercent(0.05)
.maximumMaxSizeBytes(1024 * 1024 * 100)
.build()
}
.crossfade(true)

View File

@ -55,7 +55,10 @@ class FeverRepository(
SyncResult(
items = newItems,
feeds = newFeeds
feeds = newFeeds,
favicons = favicons.associateBy { favicon ->
feverFeeds.feeds.find { it.remoteId == favicon.id }!!
}
)
}
}

View File

@ -1,5 +1,6 @@
package com.readrops.app.repositories
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
@ -11,7 +12,8 @@ typealias ErrorResult = HashMap<Feed, Exception>
data class SyncResult(
val items: List<Item> = listOf(),
val feeds: List<Feed> = listOf()
val feeds: List<Feed> = listOf(),
val favicons: Map<Feed, Favicon> = emptyMap() // only for Fever
)
interface Repository {

View File

@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.Action
@ -22,7 +23,10 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import coil.annotation.ExperimentalCoilApi
import coil.imageLoader
import com.readrops.api.services.Credentials
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.api.utils.AuthInterceptor
import com.readrops.app.MainActivity
import com.readrops.app.R
@ -33,6 +37,7 @@ import com.readrops.app.repositories.SyncResult
import com.readrops.app.util.FeedColors
import com.readrops.app.util.putSerializable
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.account.Account
import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent
@ -147,9 +152,13 @@ class SyncWorker(
syncResults[account] = result.first
} else {
get<AuthInterceptor>().credentials = Credentials.toCredentials(account)
val syncResult = repository.synchronize()
fetchFeedColors(syncResult, notificationBuilder)
if (syncResult.favicons.isNotEmpty()) {
loadFeverFavicons(syncResult.favicons, notificationBuilder)
} else {
fetchFeedColors(syncResult, notificationBuilder)
}
syncResults[account] = syncResult
}
@ -236,6 +245,50 @@ class SyncWorker(
}
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun loadFeverFavicons(
favicons: Map<Feed, Favicon>,
notificationBuilder: Builder
) {
if (notificationManager.areNotificationsEnabled()) {
// can't make detailed progress as the favicon might already exist in cache
notificationBuilder.setContentTitle("Loading icons and colors")
.setProgress(0, 0, true)
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
}
val diskCache = applicationContext.imageLoader.diskCache!!
for ((feed, favicon) in favicons) {
val key = "account_${feed.accountId}_feed_${feed.id}"
val snapshot = diskCache.openSnapshot(key)
if (snapshot == null) {
try {
diskCache.openEditor(key)!!.apply {
diskCache.fileSystem.write(data) {
write(favicon.data)
}
commit()
}
database.feedDao().updateFeedIconUrl(feed.id, key)
val bitmap =
BitmapFactory.decodeByteArray(favicon.data, 0, favicon.data.size)
if (bitmap != null) {
val color = FeedColors.getFeedColor(bitmap)
database.feedDao().updateFeedColor(feed.id, color)
}
} catch (e: Exception) {
Log.e(TAG, "${feed.name}: ${e.message}")
}
}
snapshot?.close()
}
}
private suspend fun displaySyncResults(syncResults: Map<Account, SyncResult>) {
val notificationContent = SyncAnalyzer(applicationContext, database)
.getNotificationContent(syncResults)

View File

@ -1,5 +1,6 @@
package com.readrops.app.util
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.annotation.ColorInt
import androidx.palette.graphics.Palette
@ -19,6 +20,10 @@ object FeedColors : KoinComponent {
).execute()
val bitmap = BitmapFactory.decodeStream(response.body?.byteStream()) ?: return 0
return getFeedColor(bitmap)
}
fun getFeedColor(bitmap: Bitmap): Int {
val palette = Palette.from(bitmap).generate()
val dominantSwatch = palette.dominantSwatch

View File

@ -0,0 +1,109 @@
package com.readrops.app.util
import android.util.Patterns
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import okhttp3.OkHttpClient
import okhttp3.Request
data class FeedKey(val faviconUrl: String?)
/**
* Custom Coil Fetcher to load Feed favicons from either an http source or a file source
*/
@OptIn(ExperimentalCoilApi::class)
class FeverFaviconFetcher(
private val data: FeedKey,
private val diskCache: DiskCache,
private val okHttpClient: OkHttpClient
) : Fetcher {
override suspend fun fetch(): FetchResult? {
return when {
data.faviconUrl == null -> null
Patterns.WEB_URL.matcher(data.faviconUrl).matches() -> httpLoader()
else -> fileLoader()
}
}
private fun fileLoader(): FetchResult? {
val diskCacheKey = data.faviconUrl!!
val snapshot = diskCache.openSnapshot(diskCacheKey)
return if (snapshot != null) {
SourceResult(
source = snapshot.toImageSource(),
mimeType = MIME_TYPE,
dataSource = DataSource.DISK
)
} else {
null
}
}
private fun httpLoader(): FetchResult? {
val diskCacheKey = data.faviconUrl!!
val snapshot = diskCache.openSnapshot(diskCacheKey)
return if (snapshot != null) {
SourceResult(
source = snapshot.toImageSource(),
mimeType = MIME_TYPE,
dataSource = DataSource.NETWORK
)
} else {
val request = Request.Builder()
.url(diskCacheKey)
.build()
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful || response.code == 304 || response.body == null) {
return null
}
val httpSnapshot = diskCache.openEditor(diskCacheKey)!!.run {
diskCache.fileSystem.write(data) {
write(response.body!!.bytes())
}
commitAndOpenSnapshot()
}
return if (httpSnapshot != null) {
SourceResult(
source = httpSnapshot.toImageSource(),
mimeType = MIME_TYPE,
dataSource = DataSource.NETWORK
)
} else {
null
}
}
}
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
return ImageSource(
file = data,
diskCacheKey = this@FeverFaviconFetcher.data.faviconUrl,
closeable = this,
)
}
class Factory(private val okHttpClient: OkHttpClient) : Fetcher.Factory<FeedKey> {
override fun create(data: FeedKey, options: Options, imageLoader: ImageLoader): Fetcher {
return FeverFaviconFetcher(data, imageLoader.diskCache!!, okHttpClient)
}
}
companion object {
private const val MIME_TYPE = "image/*"
}
}

View File

@ -8,6 +8,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.readrops.app.R
import com.readrops.app.util.FeedKey
@Composable
fun FeedIcon(
@ -16,7 +17,7 @@ fun FeedIcon(
size: Dp = 24.dp
) {
AsyncImage(
model = iconUrl,
model = FeedKey(iconUrl),
error = painterResource(id = R.drawable.ic_rss_feed_grey),
placeholder = painterResource(R.drawable.ic_rss_feed_grey),
fallback = painterResource(id = R.drawable.ic_rss_feed_grey),

View File

@ -70,6 +70,9 @@ abstract class FeedDao : BaseDao<Feed> {
@Query("Select * From Feed Where id in (:ids)")
abstract suspend fun selectFromIds(ids: List<Int>): List<Feed>
@Query("Update Feed set icon_url = :iconUrl Where id = :feedId")
abstract suspend fun updateFeedIconUrl(feedId: Int, iconUrl: String)
/**
* Insert, update and delete feeds by account
*