mirror of
https://github.com/Ashinch/ReadYou.git
synced 2025-01-31 11:35:10 +01:00
fix(rss): fetch best icon (#817)
This commit is contained in:
parent
b30ff86503
commit
1ae5958b19
@ -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)
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user