Add support for Fever favicons
This commit is contained in:
parent
3c99bafe43
commit
f67f817b82
@ -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
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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 }!!
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
109
app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt
Normal file
109
app/src/main/java/com/readrops/app/util/FeverFaviconFetcher.kt
Normal 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/*"
|
||||||
|
}
|
||||||
|
}
|
@ -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),
|
||||||
|
@ -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
|
||||||
*
|
*
|
||||||
|
Loading…
x
Reference in New Issue
Block a user