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

View File

@ -24,33 +24,34 @@ import me.ash.reader.ui.component.base.RYAsyncImage
@Composable @Composable
fun FeedIcon( fun FeedIcon(
feedName: String, modifier: Modifier = Modifier,
feedName: String? = "",
iconUrl: String?, iconUrl: String?,
size: Dp = 20.dp, size: Dp = 20.dp,
placeholderIcon: ImageVector? = null, placeholderIcon: ImageVector? = null,
) { ) {
if (iconUrl.isNullOrEmpty()) { if (iconUrl.isNullOrEmpty()) {
if (placeholderIcon == null) { if (placeholderIcon == null) {
FontIcon(size, feedName) FontIcon(modifier, size, feedName ?: "")
} else { } else {
ImageIcon(placeholderIcon, feedName) ImageIcon(modifier, placeholderIcon, feedName ?: "")
} }
} }
// e.g. image/gif;base64,R0lGODlh... // e.g. image/gif;base64,R0lGODlh...
else if ("^image/.*;base64,.*".toRegex().matches(iconUrl)) { else if ("^image/.*;base64,.*".toRegex().matches(iconUrl)) {
Base64Image( Base64Image(
modifier = Modifier modifier = modifier
.size(size) .size(size)
.clip(CircleShape), .clip(CircleShape),
base64Uri = iconUrl, base64Uri = iconUrl,
onEmpty = { FontIcon(size, feedName) }, onEmpty = { FontIcon(modifier, size, feedName ?: "") },
) )
} else { } else {
RYAsyncImage( RYAsyncImage(
modifier = Modifier modifier = modifier
.size(size) .size(size)
.clip(CircleShape), .clip(CircleShape),
contentDescription = feedName, contentDescription = feedName ?: "",
data = iconUrl, data = iconUrl,
placeholder = null, placeholder = null,
) )
@ -58,17 +59,18 @@ fun FeedIcon(
} }
@Composable @Composable
private fun ImageIcon(placeholderIcon: ImageVector, feedName: String) { private fun ImageIcon(modifier: Modifier, placeholderIcon: ImageVector, feedName: String) {
Icon( Icon(
modifier = modifier,
imageVector = placeholderIcon, imageVector = placeholderIcon,
contentDescription = feedName, contentDescription = feedName,
) )
} }
@Composable @Composable
private fun FontIcon(size: Dp, feedName: String) { private fun FontIcon(modifier: Modifier, size: Dp, feedName: String) {
Box( Box(
modifier = Modifier modifier = modifier
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.primary), .background(MaterialTheme.colorScheme.primary),
@ -88,5 +90,5 @@ private fun FontIcon(size: Dp, feedName: String) {
@Preview @Preview
@Composable @Composable
fun FeedIconPrev() { 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(modifier = Modifier.weight(1f)) { Row(modifier = Modifier.weight(1f)) {
FeedIcon(feed.name, feed.icon) FeedIcon(feedName = feed.name, iconUrl = feed.icon)
Text( Text(
modifier = Modifier.padding(start = 12.dp, end = 6.dp), modifier = Modifier.padding(start = 12.dp, end = 6.dp),
text = feed.name, text = feed.name,

View File

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

View File

@ -16,10 +16,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.feed.Feed
import me.ash.reader.domain.model.group.Group 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.domain.service.RssService
import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.ApplicationScope
import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.di.IODispatcher
import me.ash.reader.infrastructure.di.MainDispatcher import me.ash.reader.infrastructure.di.MainDispatcher
import me.ash.reader.infrastructure.rss.RssHelper
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@ -32,6 +34,8 @@ class FeedOptionViewModel @Inject constructor(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope @ApplicationScope
private val applicationScope: CoroutineScope, private val applicationScope: CoroutineScope,
private val rssHelper: RssHelper,
private val feedDao: FeedDao,
) : ViewModel() { ) : ViewModel() {
private val _feedOptionUiState = MutableStateFlow(FeedOptionUiState()) 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) @OptIn(ExperimentalMaterialApi::class)

View File

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