Add feed's favicon from UI Avatars (#70)

* Add feed's favicon from UI Avatars

* Fix backwards writes
This commit is contained in:
Ashinch 2022-05-15 21:16:34 +08:00 committed by GitHub
parent c79649bb77
commit f76abd98be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 209 additions and 146 deletions

View File

@ -108,6 +108,7 @@ dependencies {
implementation "com.rometools:rome:$rome"
// https://coil-kt.github.io/coil/changelog/
implementation("io.coil-kt:coil-base:$coil")
implementation("io.coil-kt:coil-compose:$coil")
implementation("io.coil-kt:coil-svg:$coil")
implementation("io.coil-kt:coil-gif:$coil")

View File

@ -1,24 +1,11 @@
package me.ash.reader
import android.app.Application
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkManager
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.*
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -29,12 +16,16 @@ import me.ash.reader.data.source.AppNetworkDataSource
import me.ash.reader.data.source.OpmlLocalDataSource
import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.ui.ext.*
import okhttp3.Cache
import okhttp3.OkHttpClient
import org.conscrypt.Conscrypt
import java.io.File
import java.security.Security
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidApp
class App : Application(), Configuration.Provider, ImageLoader {
class App : Application(), Configuration.Provider {
init {
// From: https://gitlab.com/spacecowboy/Feeder
// Install Conscrypt to handle TLSv1.3 pre Android10
@ -88,6 +79,9 @@ class App : Application(), Configuration.Provider, ImageLoader {
@DispatcherDefault
lateinit var dispatcherDefault: CoroutineDispatcher
@Inject
lateinit var imageLoader: ImageLoader
override fun onCreate() {
super.onCreate()
CrashHandler(this)
@ -130,58 +124,29 @@ class App : Application(), Configuration.Provider, ImageLoader {
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(android.util.Log.DEBUG)
.build()
}
override val components: ComponentRegistry
get() = ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
.add(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoderDecoder.Factory()
} else {
GifDecoder.Factory()
}
)
.build()
override val defaults: DefaultRequestOptions
get() = DefaultRequestOptions()
override val diskCache: DiskCache
get() = DiskCache.Builder()
.directory(cacheDir.resolve("images"))
.maxSizePercent(0.02)
.build()
override val memoryCache: MemoryCache
get() = MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
fun cachingHttpClient(
cacheDirectory: File? = null,
cacheSize: Long = 10L * 1024L * 1024L,
trustAllCerts: Boolean = true,
connectTimeoutSecs: Long = 30L,
readTimeoutSecs: Long = 30L
): OkHttpClient {
val builder: OkHttpClient.Builder = OkHttpClient.Builder()
override fun enqueue(request: ImageRequest): Disposable {
// Always call onStart before onSuccess.
request.target?.onStart(request.placeholder)
val result = ColorDrawable(Color.BLACK)
request.target?.onSuccess(result)
return object : Disposable {
override val job = CompletableDeferred(newResult(request, result))
override val isDisposed get() = true
override fun dispose() {}
}
if (cacheDirectory != null) {
builder.cache(Cache(cacheDirectory, cacheSize))
}
override suspend fun execute(request: ImageRequest): ImageResult {
return newResult(request, ColorDrawable(Color.BLACK))
}
builder
.connectTimeout(connectTimeoutSecs, TimeUnit.SECONDS)
.readTimeout(readTimeoutSecs, TimeUnit.SECONDS)
.followRedirects(true)
override fun newBuilder(): ImageLoader.Builder {
throw UnsupportedOperationException()
}
// if (trustAllCerts) {
// builder.trustAllCerts()
// }
override fun shutdown() {
}
private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult {
return SuccessResult(
drawable = drawable,
request = request,
dataSource = DataSource.MEMORY_CACHE
)
}
return builder.build()
}

View File

@ -4,16 +4,22 @@ import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.CompositionLocalProvider
import androidx.core.view.WindowCompat
import androidx.profileinstaller.ProfileInstallerInitializer
import coil.ImageLoader
import coil.compose.LocalImageLoader
import dagger.hilt.android.AndroidEntryPoint
import me.ash.reader.data.preference.LanguagesPreference
import me.ash.reader.data.preference.SettingsProvider
import me.ash.reader.ui.ext.languages
import me.ash.reader.ui.page.common.HomeEntry
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var imageLoader: ImageLoader
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -27,8 +33,12 @@ class MainActivity : ComponentActivity() {
}
setContent {
SettingsProvider {
HomeEntry()
CompositionLocalProvider(
LocalImageLoader provides imageLoader,
) {
SettingsProvider {
HomeEntry()
}
}
}
}

View File

@ -32,7 +32,7 @@ data class Article(
@ColumnInfo
var fullContent: String? = null,
@ColumnInfo
var img: String? = null,
val img: String? = null,
@ColumnInfo
val link: String,
@ColumnInfo(index = true)

View File

@ -0,0 +1,62 @@
package me.ash.reader.data.module
import android.content.Context
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import me.ash.reader.cachingHttpClient
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ImageLoaderModule {
@Provides
@Singleton
fun provideImageLoader(
@ApplicationContext context: Context
): ImageLoader {
return ImageLoader.Builder(context)
.okHttpClient(
okHttpClient = cachingHttpClient(
cacheDirectory = context.cacheDir.resolve("http")
).newBuilder()
//.addNetworkInterceptor(UserAgentInterceptor)
.build()
)
.dispatcher(Dispatchers.Default) // This slightly improves scrolling performance
.components{
add(SvgDecoder.Factory())
add(
if (SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoderDecoder.Factory()
} else {
GifDecoder.Factory()
}
)
}
.diskCache(
DiskCache.Builder()
.directory(context.cacheDir.resolve("images"))
.maxSizePercent(0.02)
.build()
)
.memoryCache(
MemoryCache.Builder(context)
.maxSizePercent(0.25)
.build()
)
.build()
}
}

View File

@ -115,10 +115,9 @@ class RssHelper @Inject constructor(
.take(100)
.trim(),
fullContent = content,
img = findImg((desc ?: content) ?: ""),
link = it.link ?: "",
).apply {
img = findImg(rawDescription)
}
)
)
}
a
@ -130,7 +129,8 @@ class RssHelper @Inject constructor(
// Using negative lookahead to skip data: urls, being inline base64
// And capturing original quote to use as ending quote
val regex = """img.*?src=(["'])((?!data).*?)\1""".toRegex(RegexOption.DOT_MATCHES_ALL)
return regex.find(rawDescription)?.groupValues?.get(2)
// Base64 encoded images can be quite large - and crash database cursors
return regex.find(rawDescription)?.groupValues?.get(2)?.takeIf { !it.startsWith("data:") }
}
@Throws(Exception::class)

View File

@ -3,9 +3,7 @@ package me.ash.reader.ui.component
import androidx.annotation.DrawableRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
@ -14,7 +12,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.imageLoader
import coil.compose.LocalImageLoader
import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale
@ -33,37 +31,10 @@ fun AsyncImage(
@DrawableRes placeholder: Int? = R.drawable.ic_hourglass_empty_black_24dp,
@DrawableRes error: Int? = R.drawable.ic_broken_image_black_24dp,
) {
val context = LocalContext.current
val color = MaterialTheme.colorScheme.onSurfaceVariant
val placeholderPainterResource = placeholder?.run { painterResource(this) }
val errorPainterResource = error?.run { painterResource(this) }
val placeholderPainter by remember {
mutableStateOf(
placeholderPainterResource?.run {
forwardingPainter(
painter = this,
colorFilter = ColorFilter.tint(color),
alpha = 0.1f,
)
}
)
}
val errorPainter by remember {
mutableStateOf(
errorPainterResource?.run {
forwardingPainter(
painter = this,
colorFilter = ColorFilter.tint(color),
alpha = 0.1f,
)
}
)
}
coil.compose.AsyncImage(
modifier = modifier,
model = ImageRequest
.Builder(context)
.Builder(LocalContext.current)
.data(data)
.crossfade(true)
.scale(scale)
@ -72,9 +43,21 @@ fun AsyncImage(
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
imageLoader = context.imageLoader,
placeholder = placeholderPainter,
error = errorPainter,
imageLoader = LocalImageLoader.current,
placeholder = placeholder?.run {
forwardingPainter(
painter = painterResource(this),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
alpha = 0.1f,
)
},
error = error?.run {
forwardingPainter(
painter = painterResource(this),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
alpha = 0.1f,
)
},
)
}
@ -90,12 +73,14 @@ fun forwardingPainter(
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
): Painter = ForwardingPainter(painter, alpha, colorFilter, onDraw)
@Immutable
data class ForwardingDrawInfo(
val painter: Painter,
val alpha: Float,
val colorFilter: ColorFilter?,
)
@Immutable
private class ForwardingPainter(
private val painter: Painter,
private var alpha: Float,

View File

@ -0,0 +1,45 @@
package me.ash.reader.ui.page.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun FeedIcon(
feedName: String,
size: Dp = 20.dp
) {
Row(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
) {}
// val url by remember {
// mutableStateOf(
// "https://ui-avatars.com/api/?length=1&background=random&name=${
// URLEncoder.encode(
// feedName,
// Charsets.UTF_8.toString()
// )
// }"
// )
// }
//
// AsyncImage(
// modifier = Modifier
// .size(size)
// .clip(CircleShape),
// contentDescription = feedName,
// data = url,
// placeholder = null,
// )
}

View File

@ -60,7 +60,7 @@ class HomeViewModel @Inject constructor(
private fun fetchArticles() {
_viewState.update {
it.copy(
pagingData = Pager(PagingConfig(pageSize = 15)) {
pagingData = Pager(PagingConfig(pageSize = 50)) {
if (_viewState.value.searchContent.isNotBlank()) {
rssRepository.get().searchArticles(
content = _viewState.value.searchContent.trim(),

View File

@ -1,16 +1,16 @@
package me.ash.reader.ui.page.home.feeds
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -20,6 +20,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.data.entity.Feed
import me.ash.reader.ui.page.home.FeedIcon
import me.ash.reader.ui.page.home.feeds.option.feed.FeedOptionViewAction
import me.ash.reader.ui.page.home.feeds.option.feed.FeedOptionViewModel
import kotlin.math.ln
@ -30,7 +31,6 @@ import kotlin.math.ln
)
@Composable
fun FeedItem(
modifier: Modifier = Modifier,
feed: Feed,
feedOptionViewModel: FeedOptionViewModel = hiltViewModel(),
tonalElevation: Dp,
@ -38,6 +38,11 @@ fun FeedItem(
) {
val view = LocalView.current
val scope = rememberCoroutineScope()
val tonalElevationAlpha by remember {
derivedStateOf {
(ln(tonalElevation.value + 1.4f) + 2f) / 100f
}
}
Row(
modifier = Modifier
@ -63,12 +68,7 @@ fun FeedItem(
verticalAlignment = Alignment.CenterVertically,
) {
Row(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
) {}
FeedIcon(feed.name)
Text(
modifier = Modifier.padding(start = 12.dp, end = 6.dp),
text = feed.name,
@ -78,10 +78,10 @@ fun FeedItem(
overflow = TextOverflow.Ellipsis,
)
}
if (feed.important ?: 0 != 0) {
if ((feed.important ?: 0) != 0) {
Badge(
containerColor = MaterialTheme.colorScheme.surfaceTint.copy(
alpha = (ln(tonalElevation.value + 1.4f) + 2f) / 100f
alpha = tonalElevationAlpha
),
contentColor = MaterialTheme.colorScheme.outline,
content = {

View File

@ -8,7 +8,6 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
@ -32,7 +31,7 @@ import me.ash.reader.ui.ext.alphaLN
import me.ash.reader.ui.page.home.feeds.option.group.GroupOptionViewAction
import me.ash.reader.ui.page.home.feeds.option.group.GroupOptionViewModel
@OptIn(ExperimentalMaterialApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun GroupItem(
modifier: Modifier = Modifier,
@ -114,7 +113,6 @@ fun GroupItem(
Column {
feeds.forEach { feed ->
FeedItem(
modifier = Modifier.padding(horizontal = 20.dp),
feed = feed,
tonalElevation = tonalElevation,
) {

View File

@ -6,8 +6,6 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CreateNewFolder
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.rounded.RssFeed
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -26,6 +24,7 @@ import me.ash.reader.ui.component.TextFieldDialog
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.roundClick
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.page.home.FeedIcon
import me.ash.reader.ui.page.home.feeds.subscribe.ResultView
@OptIn(ExperimentalMaterialApi::class)
@ -56,12 +55,13 @@ fun FeedOptionDrawer(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
modifier = Modifier.roundClick { },
imageVector = Icons.Rounded.RssFeed,
contentDescription = feed?.name ?: stringResource(R.string.unknown),
tint = MaterialTheme.colorScheme.secondary,
)
FeedIcon(feedName = feed?.name ?: "", size = 24.dp)
// Icon(
// modifier = Modifier.roundClick { },
// imageVector = Icons.Rounded.RssFeed,
// contentDescription = feed?.name ?: stringResource(R.string.unknown),
// tint = MaterialTheme.colorScheme.secondary,
// )
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier.roundClick {

View File

@ -1,9 +1,7 @@
package me.ash.reader.ui.page.home.flow
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Star
@ -26,10 +24,10 @@ import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.data.preference.*
import me.ash.reader.ui.component.AsyncImage
import me.ash.reader.ui.ext.formatAsString
import me.ash.reader.ui.page.home.FeedIcon
@Composable
fun ArticleItem(
modifier: Modifier = Modifier,
articleWithFeed: ArticleWithFeed,
onClick: (ArticleWithFeed) -> Unit = {},
) {
@ -107,12 +105,7 @@ fun ArticleItem(
) {
// Feed icon
if (articleListFeedIcon.value) {
Row(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
) {}
FeedIcon(articleWithFeed.feed.name)
Spacer(modifier = Modifier.width(10.dp))
}

View File

@ -30,15 +30,14 @@ fun LazyListScope.ArticleList(
}
}
is FlowItemView.Date -> {
val separator = pagingItems[index] as FlowItemView.Date
if (separator.showSpacer) item { Spacer(modifier = Modifier.height(40.dp)) }
if (item.showSpacer) item { Spacer(modifier = Modifier.height(40.dp)) }
if (articleListDateStickyHeader) {
stickyHeader(key = separator.date) {
StickyHeader(separator.date, articleListFeedIcon, articleListTonalElevation)
stickyHeader(key = item.date) {
StickyHeader(item.date, articleListFeedIcon, articleListTonalElevation)
}
} else {
item(key = separator.date) {
StickyHeader(separator.date, articleListFeedIcon, articleListTonalElevation)
item(key = item.date) {
StickyHeader(item.date, articleListFeedIcon, articleListTonalElevation)
}
}
}

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.data.repository.RssRepository
import java.util.*
import javax.annotation.concurrent.Immutable
import javax.inject.Inject
@HiltViewModel
@ -116,7 +117,10 @@ enum class MarkAsReadBefore {
All,
}
@Immutable
sealed class FlowItemView {
@Immutable
class Article(val articleWithFeed: ArticleWithFeed) : FlowItemView()
@Immutable
class Date(val date: String, val showSpacer: Boolean) : FlowItemView()
}

View File

@ -1,6 +1,7 @@
package me.ash.reader.ui.page.home.read
import android.content.Intent
import android.util.Log
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -35,7 +36,6 @@ fun ReadPage(
) {
val viewState = readViewModel.viewState.collectAsStateValue()
val isScrollDown = viewState.listState.isScrollDown()
// val isScrollDown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
navController.currentBackStackEntryFlow.collect {
@ -46,6 +46,7 @@ fun ReadPage(
}
LaunchedEffect(viewState.articleWithFeed?.article?.id) {
Log.i("RLog", "ReadPage: ${viewState.articleWithFeed}")
viewState.articleWithFeed?.let {
if (it.article.isUnread) {
readViewModel.dispatch(ReadViewAction.MarkUnread(false))