fix(rss): fetch best icon (#817)

This commit is contained in:
Ash 2024-08-12 16:42:39 +08:00 committed by GitHub
parent b30ff86503
commit 1ae5958b19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 162 additions and 33 deletions

View File

@ -0,0 +1,111 @@
package me.ash.reader.infrastructure.rss
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import java.net.URL
class BestIconFinder(private val client: OkHttpClient) {
private val defaultFormats = listOf("apple-touch-icon", "svg", "png", "ico", "gif", "jpg")
suspend fun findBestIcon(siteUrl: String): String? {
val url = normalizeUrl(siteUrl)
val icons = fetchIcons(url)
return selectBestIcon(icons)
}
private fun normalizeUrl(url: String): String {
return if (!url.startsWith("http://") && !url.startsWith("https://")) {
"http://$url"
} else {
url
}
}
private suspend fun fetchIcons(url: String): List<Icon> {
val links = try {
val html = fetchHtml(url)
findIconLinks(url, html)
} catch (e: Exception) {
Log.w("RLog", "fetchIcons: $e")
// Fallback to default icon paths if HTML fetch fails
defaultIconUrls(url)
}
return links.mapNotNull { fetchIconDetails(it) }
}
private suspend fun fetchHtml(url: String): String {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
return response.body.string()
}
private fun findIconLinks(baseUrl: String, html: String): List<String> {
val doc = Jsoup.parse(html, baseUrl)
val links = mutableListOf<String>()
// Find apple-touch-icon
links.addAll(doc.select("link[rel~=apple-touch-icon]").map { it.attr("abs:href") })
// Find link rel="icon" and rel="shortcut icon"
links.addAll(doc.select("link[rel~=icon]").map { it.attr("abs:href") })
// Find meta property="og:image"
doc.select("meta[property=og:image]").firstOrNull()?.attr("content")?.let {
links.add(it)
}
// Add default favicon.ico if not already present
val faviconUrl = URL(URL(baseUrl), "/favicon.ico").toString()
if (faviconUrl !in links) {
links.add(faviconUrl)
}
return links.distinct()
}
private fun defaultIconUrls(siteUrl: String): List<String> {
val baseUrl = URL(siteUrl)
return listOf(
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
"/favicon.ico"
).map { URL(baseUrl, it).toString() }
}
private fun fetchIconDetails(url: String): Icon? {
try {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
val body = response.body.bytes()
.takeIf { it.isNotEmpty() } ?: return null
val contentType = response.header("Content-Type") ?: ""
val format = when {
url.contains("apple-touch-icon") -> "apple-touch-icon"
contentType.contains("svg") -> "svg"
contentType.contains("png") -> "png"
contentType.contains("ico") -> "ico"
contentType.contains("gif") -> "gif"
contentType.contains("jpeg") || contentType.contains("jpg") -> "jpg"
else -> return null
}
return Icon(url, format, body.size)
} catch (e: Exception) {
return null
}
}
private fun selectBestIcon(icons: List<Icon>): String? {
return icons.sortedWith(compareBy(
{ defaultFormats.indexOf(it.format) },
{ -it.size }
)).firstOrNull()?.url
}
data class Icon(val url: String, val format: String, val size: Int)
}

View File

@ -2,7 +2,6 @@ package me.ash.reader.infrastructure.rss
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.rometools.rome.feed.synd.SyndEntry
import com.rometools.rome.feed.synd.SyndFeed
import com.rometools.rome.feed.synd.SyndImageImpl
@ -18,6 +17,7 @@ import me.ash.reader.infrastructure.di.IODispatcher
import me.ash.reader.infrastructure.html.Readability
import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.decodeHTML
import me.ash.reader.ui.ext.extractDomain
import me.ash.reader.ui.ext.isFuture
import me.ash.reader.ui.ext.spacerDollar
import okhttp3.OkHttpClient
@ -149,15 +149,12 @@ class RssHelper @Inject constructor(
return imgRegex.find(text)?.groupValues?.get(2)?.takeIf { !it.startsWith("data:") }
}
suspend fun queryRssIconLink(feedLink: String): String? {
return try {
val request = response(okHttpClient, "https://besticon-demo.herokuapp.com/allicons.json?url=${feedLink}")
val content = request.body.string()
val favicon = Gson().fromJson(content, Favicon::class.java)
favicon?.icons?.first { it.width != null && it.width >= 20 }?.url
} catch (e: Exception) {
Log.i("RLog", "queryRssIcon is failed: ${e.message}")
null
suspend fun queryRssIconLink(feedLink: String?): String? {
if (feedLink.isNullOrEmpty()) return null
val iconFinder = BestIconFinder(okHttpClient)
val domain = feedLink.extractDomain()
return iconFinder.findBestIcon(domain ?: feedLink).also {
Log.i("RLog", "queryRssIconByLink: get $it from $domain")
}
}

View File

@ -24,33 +24,34 @@ import me.ash.reader.ui.component.base.RYAsyncImage
@Composable
fun FeedIcon(
feedName: String,
modifier: Modifier = Modifier,
feedName: String? = "",
iconUrl: String?,
size: Dp = 20.dp,
placeholderIcon: ImageVector? = null,
) {
if (iconUrl.isNullOrEmpty()) {
if (placeholderIcon == null) {
FontIcon(size, feedName)
FontIcon(modifier, size, feedName ?: "")
} else {
ImageIcon(placeholderIcon, feedName)
ImageIcon(modifier, placeholderIcon, feedName ?: "")
}
}
// e.g. image/gif;base64,R0lGODlh...
else if ("^image/.*;base64,.*".toRegex().matches(iconUrl)) {
Base64Image(
modifier = Modifier
modifier = modifier
.size(size)
.clip(CircleShape),
base64Uri = iconUrl,
onEmpty = { FontIcon(size, feedName) },
onEmpty = { FontIcon(modifier, size, feedName ?: "") },
)
} else {
RYAsyncImage(
modifier = Modifier
modifier = modifier
.size(size)
.clip(CircleShape),
contentDescription = feedName,
contentDescription = feedName ?: "",
data = iconUrl,
placeholder = null,
)
@ -58,17 +59,18 @@ fun FeedIcon(
}
@Composable
private fun ImageIcon(placeholderIcon: ImageVector, feedName: String) {
private fun ImageIcon(modifier: Modifier, placeholderIcon: ImageVector, feedName: String) {
Icon(
modifier = modifier,
imageVector = placeholderIcon,
contentDescription = feedName,
)
}
@Composable
private fun FontIcon(size: Dp, feedName: String) {
private fun FontIcon(modifier: Modifier, size: Dp, feedName: String) {
Box(
modifier = Modifier
modifier = modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
@ -88,5 +90,5 @@ private fun FontIcon(size: Dp, feedName: String) {
@Preview
@Composable
fun FeedIconPrev() {
FeedIcon(stringResource(R.string.preview_feed_name), null)
FeedIcon(feedName = stringResource(R.string.preview_feed_name), iconUrl = null)
}

View File

@ -71,7 +71,7 @@ fun FeedItem(
verticalAlignment = Alignment.CenterVertically,
) {
Row(modifier = Modifier.weight(1f)) {
FeedIcon(feed.name, feed.icon)
FeedIcon(feedName = feed.name, iconUrl = feed.icon)
Text(
modifier = Modifier.padding(start = 12.dp, end = 6.dp),
text = feed.name,

View File

@ -2,7 +2,13 @@ package me.ash.reader.ui.page.home.feeds.drawer.feed
import android.view.HapticFeedbackConstants
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CreateNewFolder
@ -63,13 +69,9 @@ fun FeedOptionDrawer(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
FeedIcon(feedName = feed?.name ?: "", iconUrl = feed?.icon, size = 24.dp)
// Icon(
// modifier = Modifier.roundClick { },
// imageVector = Icons.Rounded.RssFeed,
// contentDescription = feed?.name ?: stringResource(R.string.unknown),
// tint = MaterialTheme.colorScheme.secondary,
// )
FeedIcon(modifier = Modifier.clickable {
feedOptionViewModel.reloadIcon()
}, feedName = feed?.name, iconUrl = feed?.icon, size = 24.dp)
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier.roundClick {

View File

@ -16,10 +16,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.ash.reader.domain.model.feed.Feed
import me.ash.reader.domain.model.group.Group
import me.ash.reader.domain.repository.FeedDao
import me.ash.reader.domain.service.RssService
import me.ash.reader.infrastructure.di.ApplicationScope
import me.ash.reader.infrastructure.di.IODispatcher
import me.ash.reader.infrastructure.di.MainDispatcher
import me.ash.reader.infrastructure.rss.RssHelper
import javax.inject.Inject
@OptIn(ExperimentalMaterialApi::class)
@ -32,6 +34,8 @@ class FeedOptionViewModel @Inject constructor(
private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope
private val applicationScope: CoroutineScope,
private val rssHelper: RssHelper,
private val feedDao: FeedDao,
) : ViewModel() {
private val _feedOptionUiState = MutableStateFlow(FeedOptionUiState())
@ -228,6 +232,16 @@ class FeedOptionViewModel @Inject constructor(
}
}
}
fun reloadIcon() {
_feedOptionUiState.value.feed?.let { feed ->
viewModelScope.launch(ioDispatcher) {
val icon = rssHelper.queryRssIconLink(feed.url) ?: return@launch
feedDao.update(feed.copy(icon = icon))
fetchFeed(feed.id)
}
}
}
}
@OptIn(ExperimentalMaterialApi::class)

View File

@ -171,7 +171,10 @@ fun ArticleItem(
Text(
modifier = Modifier
.weight(1f)
.padding(start = if (articleListFeedIcon.value) 30.dp else 0.dp),
.padding(
start = if (articleListFeedIcon.value) 30.dp else 0.dp,
end = 10.dp,
),
text = feedName,
color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.labelMedium,
@ -219,7 +222,7 @@ fun ArticleItem(
) {
// Feed icon
if (articleListFeedIcon.value) {
FeedIcon(feedName, iconUrl = feedIconUrl)
FeedIcon(feedName = feedName, iconUrl = feedIconUrl)
Spacer(modifier = Modifier.width(10.dp))
}
@ -658,4 +661,4 @@ fun MenuContentPreview() {
}
}
}
}
}