Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(rss): fetch best icon #817

Merged
merged 1 commit into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 7 additions & 10 deletions app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
}

Expand Down
24 changes: 13 additions & 11 deletions app/src/main/java/me/ash/reader/ui/component/FeedIcon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,51 +24,53 @@ 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,
)
}
}

@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),
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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())
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -658,4 +661,4 @@ fun MenuContentPreview() {
}
}
}
}
}
Loading