mirror of
https://github.com/Ashinch/ReadYou.git
synced 2025-02-07 15:48:50 +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.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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
@ -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() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user