From f67f817b82da87d4fc1ef44e9b3026dfa7a979c7 Mon Sep 17 00:00:00 2001 From: Shinokuni Date: Mon, 12 Aug 2024 14:18:31 +0200 Subject: [PATCH] Add support for Fever favicons --- .../api/services/fever/FeverDataSource.kt | 4 +- .../fever/adapters/FeverFaviconsAdapter.kt | 9 +- .../main/java/com/readrops/app/ReadropsApp.kt | 6 +- .../app/repositories/FeverRepository.kt | 5 +- .../readrops/app/repositories/Repository.kt | 4 +- .../java/com/readrops/app/sync/SyncWorker.kt | 57 ++++++++- .../java/com/readrops/app/util/FeedColors.kt | 5 + .../readrops/app/util/FeverFaviconFetcher.kt | 109 ++++++++++++++++++ .../readrops/app/util/components/FeedIcon.kt | 3 +- .../main/java/com/readrops/db/dao/FeedDao.kt | 3 + 10 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt diff --git a/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt b/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt index 8ebe0678..40ac87d5 100644 --- a/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt +++ b/api/src/main/java/com/readrops/api/services/fever/FeverDataSource.kt @@ -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 diff --git a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt index a12aebe5..36bf3de0 100644 --- a/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt +++ b/api/src/main/java/com/readrops/api/services/fever/adapters/FeverFaviconsAdapter.kt @@ -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) = "" + @OptIn(ExperimentalEncodingApi::class) @SuppressLint("CheckResult") @FromJson fun fromJson(reader: JsonReader): List = 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, ) } diff --git a/app/src/main/java/com/readrops/app/ReadropsApp.kt b/app/src/main/java/com/readrops/app/ReadropsApp.kt index 4c269fc6..a8003743 100644 --- a/app/src/main/java/com/readrops/app/ReadropsApp.kt +++ b/app/src/main/java/com/readrops/app/ReadropsApp.kt @@ -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) diff --git a/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt b/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt index c81844f7..f51b0c9d 100644 --- a/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt +++ b/app/src/main/java/com/readrops/app/repositories/FeverRepository.kt @@ -55,7 +55,10 @@ class FeverRepository( SyncResult( items = newItems, - feeds = newFeeds + feeds = newFeeds, + favicons = favicons.associateBy { favicon -> + feverFeeds.feeds.find { it.remoteId == favicon.id }!! + } ) } } diff --git a/app/src/main/java/com/readrops/app/repositories/Repository.kt b/app/src/main/java/com/readrops/app/repositories/Repository.kt index b23cdc19..ae34508b 100644 --- a/app/src/main/java/com/readrops/app/repositories/Repository.kt +++ b/app/src/main/java/com/readrops/app/repositories/Repository.kt @@ -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 data class SyncResult( val items: List = listOf(), - val feeds: List = listOf() + val feeds: List = listOf(), + val favicons: Map = emptyMap() // only for Fever ) interface Repository { diff --git a/app/src/main/java/com/readrops/app/sync/SyncWorker.kt b/app/src/main/java/com/readrops/app/sync/SyncWorker.kt index 56697625..08c8199d 100644 --- a/app/src/main/java/com/readrops/app/sync/SyncWorker.kt +++ b/app/src/main/java/com/readrops/app/sync/SyncWorker.kt @@ -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().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, + 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) { val notificationContent = SyncAnalyzer(applicationContext, database) .getNotificationContent(syncResults) diff --git a/app/src/main/java/com/readrops/app/util/FeedColors.kt b/app/src/main/java/com/readrops/app/util/FeedColors.kt index fce3d24a..c47a8967 100644 --- a/app/src/main/java/com/readrops/app/util/FeedColors.kt +++ b/app/src/main/java/com/readrops/app/util/FeedColors.kt @@ -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 diff --git a/app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt b/app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt new file mode 100644 index 00000000..d2034c55 --- /dev/null +++ b/app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt @@ -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 { + + override fun create(data: FeedKey, options: Options, imageLoader: ImageLoader): Fetcher { + return FeverFaviconFetcher(data, imageLoader.diskCache!!, okHttpClient) + } + } + + companion object { + private const val MIME_TYPE = "image/*" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt b/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt index bf8f92b1..53fee57c 100644 --- a/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt +++ b/app/src/main/java/com/readrops/app/util/components/FeedIcon.kt @@ -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), diff --git a/db/src/main/java/com/readrops/db/dao/FeedDao.kt b/db/src/main/java/com/readrops/db/dao/FeedDao.kt index 8f713736..3542e925 100644 --- a/db/src/main/java/com/readrops/db/dao/FeedDao.kt +++ b/db/src/main/java/com/readrops/db/dao/FeedDao.kt @@ -70,6 +70,9 @@ abstract class FeedDao : BaseDao { @Query("Select * From Feed Where id in (:ids)") abstract suspend fun selectFromIds(ids: List): List + @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 *