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() sinceId = unreadIds.first().toLong()
}, },
async { starredIds = service.getStarredItemsIds(body) }, async { starredIds = service.getStarredItemsIds(body) },
async { favicons = listOf() } async { favicons = service.getFavicons(body) }
) )
.awaitAll() .awaitAll()
} }
@ -67,7 +67,7 @@ class FeverDataSource(private val service: FeverService) {
async { feverFeeds = service.getFeeds(body) }, async { feverFeeds = service.getFeeds(body) },
async { unreadIds = service.getUnreadItemsIds(body) }, async { unreadIds = service.getUnreadItemsIds(body) },
async { starredIds = service.getStarredItemsIds(body) }, async { starredIds = service.getStarredItemsIds(body) },
async { favicons = listOf() }, async { favicons = service.getFavicons(body) },
async { async {
items = buildList { items = buildList {
var localSinceId = lastSinceId 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.FromJson
import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
data class Favicon( data class Favicon(
val id: Int, val id: String,
val data: ByteArray val data: ByteArray
) )
@ -17,6 +19,7 @@ class FeverFaviconsAdapter {
@ToJson @ToJson
fun toJson(favicons: List<Favicon>) = "" fun toJson(favicons: List<Favicon>) = ""
@OptIn(ExperimentalEncodingApi::class)
@SuppressLint("CheckResult") @SuppressLint("CheckResult")
@FromJson @FromJson
fun fromJson(reader: JsonReader): List<Favicon> = with(reader) { fun fromJson(reader: JsonReader): List<Favicon> = with(reader) {
@ -41,14 +44,14 @@ class FeverFaviconsAdapter {
while (hasNext()) { while (hasNext()) {
when (selectName(NAMES)) { when (selectName(NAMES)) {
0 -> id = nextInt() 0 -> id = nextInt()
1 -> data = nextString().toByteArray() 1 -> data = Base64.decode(nextString().substringAfter("base64,"))
else -> skipValue() else -> skipValue()
} }
} }
if (id > 0 && data != null) { if (id > 0 && data != null) {
favicons += Favicon( favicons += Favicon(
id = id, id = id.toString(),
data = data, data = data,
) )
} }

View File

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

View File

@ -55,7 +55,10 @@ class FeverRepository(
SyncResult( SyncResult(
items = newItems, 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 package com.readrops.app.repositories
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder import com.readrops.db.entities.Folder
@ -11,7 +12,8 @@ typealias ErrorResult = HashMap<Feed, Exception>
data class SyncResult( data class SyncResult(
val items: List<Item> = listOf(), 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 { interface Repository {

View File

@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.Action import androidx.core.app.NotificationCompat.Action
@ -22,7 +23,10 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import coil.annotation.ExperimentalCoilApi
import coil.imageLoader
import com.readrops.api.services.Credentials import com.readrops.api.services.Credentials
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.api.utils.AuthInterceptor import com.readrops.api.utils.AuthInterceptor
import com.readrops.app.MainActivity import com.readrops.app.MainActivity
import com.readrops.app.R 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.FeedColors
import com.readrops.app.util.putSerializable import com.readrops.app.util.putSerializable
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.Account
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -147,9 +152,13 @@ class SyncWorker(
syncResults[account] = result.first syncResults[account] = result.first
} else { } else {
get<AuthInterceptor>().credentials = Credentials.toCredentials(account) get<AuthInterceptor>().credentials = Credentials.toCredentials(account)
val syncResult = repository.synchronize() val syncResult = repository.synchronize()
fetchFeedColors(syncResult, notificationBuilder)
if (syncResult.favicons.isNotEmpty()) {
loadFeverFavicons(syncResult.favicons, notificationBuilder)
} else {
fetchFeedColors(syncResult, notificationBuilder)
}
syncResults[account] = syncResult 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>) { private suspend fun displaySyncResults(syncResults: Map<Account, SyncResult>) {
val notificationContent = SyncAnalyzer(applicationContext, database) val notificationContent = SyncAnalyzer(applicationContext, database)
.getNotificationContent(syncResults) .getNotificationContent(syncResults)

View File

@ -1,5 +1,6 @@
package com.readrops.app.util package com.readrops.app.util
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.palette.graphics.Palette import androidx.palette.graphics.Palette
@ -19,6 +20,10 @@ object FeedColors : KoinComponent {
).execute() ).execute()
val bitmap = BitmapFactory.decodeStream(response.body?.byteStream()) ?: return 0 val bitmap = BitmapFactory.decodeStream(response.body?.byteStream()) ?: return 0
return getFeedColor(bitmap)
}
fun getFeedColor(bitmap: Bitmap): Int {
val palette = Palette.from(bitmap).generate() val palette = Palette.from(bitmap).generate()
val dominantSwatch = palette.dominantSwatch 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 androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.readrops.app.R import com.readrops.app.R
import com.readrops.app.util.FeedKey
@Composable @Composable
fun FeedIcon( fun FeedIcon(
@ -16,7 +17,7 @@ fun FeedIcon(
size: Dp = 24.dp size: Dp = 24.dp
) { ) {
AsyncImage( AsyncImage(
model = iconUrl, model = FeedKey(iconUrl),
error = painterResource(id = R.drawable.ic_rss_feed_grey), error = painterResource(id = R.drawable.ic_rss_feed_grey),
placeholder = painterResource(R.drawable.ic_rss_feed_grey), placeholder = painterResource(R.drawable.ic_rss_feed_grey),
fallback = painterResource(id = 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)") @Query("Select * From Feed Where id in (:ids)")
abstract suspend fun selectFromIds(ids: List<Int>): List<Feed> 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 * Insert, update and delete feeds by account
* *