diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt new file mode 100644 index 000000000..87da0ac7d --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt @@ -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 { + 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 { + val doc = Jsoup.parse(html, baseUrl) + val links = mutableListOf() + + // 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 { + 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): 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) +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt index f8cf1a302..698f7ffb5 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt @@ -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") } } diff --git a/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt b/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt index 5e5c13bc1..879f25bda 100644 --- a/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt +++ b/app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt @@ -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) } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt index 6cdeee52a..da0601e5d 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt @@ -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, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt index da0082111..ab9bee9a3 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt @@ -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 { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt index 8952c605b..5eda96ee2 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt @@ -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) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt index 318e20ee5..f7d97d383 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleItem.kt @@ -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() { } } } -} \ No newline at end of file +}