From 37835a4964223fc476299125aca9125cc02a2a86 Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Wed, 6 Mar 2024 18:54:08 +0800 Subject: [PATCH 1/4] feat(ui): save image as file (#627) --- app/src/main/AndroidManifest.xml | 3 +- .../infrastructure/android/AndroidApp.kt | 4 + .../storage/AndroidImageDownloader.kt | 114 ++++++++++++++++++ .../ui/page/home/reading/ReaderImagePage.kt | 96 ++++++++++++--- .../ui/page/home/reading/ReadingPage.kt | 23 +++- .../ui/page/home/reading/ReadingViewModel.kt | 13 ++ app/src/main/res/values/strings.xml | 3 + 7 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/infrastructure/storage/AndroidImageDownloader.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee54fd859..d44540ecb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ - + diff --git a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt index 703262bb3..1e97446a0 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt @@ -17,6 +17,7 @@ import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.net.NetworkDataSource import me.ash.reader.infrastructure.rss.OPMLDataSource import me.ash.reader.infrastructure.rss.RssHelper +import me.ash.reader.infrastructure.storage.AndroidImageDownloader import me.ash.reader.ui.ext.del import me.ash.reader.ui.ext.getLatestApk import me.ash.reader.ui.ext.isGitHub @@ -92,6 +93,9 @@ class AndroidApp : Application(), Configuration.Provider { @Inject lateinit var imageLoader: ImageLoader + @Inject + lateinit var imageDownloader: AndroidImageDownloader + /** * When the application startup. * diff --git a/app/src/main/java/me/ash/reader/infrastructure/storage/AndroidImageDownloader.kt b/app/src/main/java/me/ash/reader/infrastructure/storage/AndroidImageDownloader.kt new file mode 100644 index 000000000..02342c588 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/storage/AndroidImageDownloader.kt @@ -0,0 +1,114 @@ +package me.ash.reader.infrastructure.storage + +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.webkit.URLUtil +import androidx.annotation.CheckResult +import androidx.annotation.DeprecatedSinceApi +import androidx.core.content.contentValuesOf +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import me.ash.reader.R +import me.ash.reader.infrastructure.di.IODispatcher +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.FileOutputStream +import java.io.IOException +import javax.inject.Inject +import kotlin.io.path.Path +import kotlin.io.path.createFile +import kotlin.io.path.createParentDirectories + +private const val TAG = "AndroidImageDownloader" + +class AndroidImageDownloader @Inject constructor( + @ApplicationContext private val context: Context, + @IODispatcher private val ioDispatcher: CoroutineDispatcher, + private val okHttpClient: OkHttpClient, +) { + @CheckResult + suspend fun downloadImage(imageUrl: String): Result { + return withContext(ioDispatcher) { + Request.Builder().url(imageUrl).build().runCatching { + okHttpClient.newCall(this).execute().run { + + val fileName = URLUtil.guessFileName( + imageUrl, header("Content-Disposition"), body.contentType()?.toString() + ) + + val relativePath = + Environment.DIRECTORY_PICTURES + "/" + context.getString(R.string.read_you) + + val resolver = context.contentResolver + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val imageCollection = + MediaStore.Images.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + + + val imageDetails = contentValuesOf( + MediaStore.Images.Media.DISPLAY_NAME to fileName, + MediaStore.Images.Media.RELATIVE_PATH to relativePath, + MediaStore.Images.Media.IS_PENDING to 1 + ) + + val imageUri = resolver.insert(imageCollection, imageDetails) + ?: return@withContext Result.failure(IOException("Cannot create image")) + + resolver.openFileDescriptor(imageUri, "w", null).use { pfd -> + body.byteStream().use { + it.copyTo( + FileOutputStream( + pfd?.fileDescriptor ?: return@withContext Result.failure( + IOException("Null fd") + ) + ) + ) + } + } + imageDetails.run { + clear() + put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(imageUri, this, null, null) + } + imageUri + } else { + saveImageForAndroidP( + fileName, + Environment.getExternalStoragePublicDirectory(relativePath).path + ) + } + } + } + } + + } + + @DeprecatedSinceApi(29) + private fun Response.saveImageForAndroidP( + fileName: String, + imageDirectory: String, + ): Uri { + val file = Path(imageDirectory, fileName).createParentDirectories().createFile().toFile() + + body.byteStream().use { + it.copyTo(file.outputStream()) + } + + var contentUri: Uri = Uri.fromFile(file) + + MediaScannerConnection.scanFile(context, arrayOf(file.path), null) { _, uri -> + contentUri = uri + } + + return contentUri + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReaderImagePage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReaderImagePage.kt index 5ce00edb4..49843736c 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReaderImagePage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReaderImagePage.kt @@ -1,5 +1,10 @@ package me.ash.reader.ui.page.home.reading +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets @@ -9,24 +14,36 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.content.ContextCompat +import androidx.core.view.HapticFeedbackConstantsCompat import coil.compose.rememberAsyncImagePainter + import me.ash.reader.R -import me.ash.reader.ui.component.base.RYAsyncImage +import me.ash.reader.ui.ext.showToast import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomableContentLocation import me.saket.telephoto.zoomable.rememberZoomableState @@ -35,7 +52,9 @@ import me.saket.telephoto.zoomable.zoomable data class ImageData(val imageUrl: String = "", val altText: String = "") @Composable -fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) { +fun ReaderImageViewer( + imageData: ImageData, onDownloadImage: (String) -> Unit, onDismissRequest: () -> Unit = {} +) { Dialog( onDismissRequest = onDismissRequest, properties = DialogProperties(usePlatformDefaultWidth = false) @@ -43,14 +62,15 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) { Box( modifier = Modifier .fillMaxSize() -// .background(Color.Black) .windowInsetsPadding(WindowInsets.systemBars) ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + val view = LocalView.current + val context = LocalContext.current + + val dialogWindowProvider = view.parent as? DialogWindowProvider dialogWindowProvider?.window?.setDimAmount(1f) - val zoomableState = - rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 4f)) + val zoomableState = rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 4f)) val painter = rememberAsyncImagePainter(model = imageData.imageUrl) @@ -60,8 +80,6 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) { ) } - - Image( painter = painter, contentDescription = imageData.altText, @@ -73,18 +91,66 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) { ) IconButton( - onClick = onDismissRequest, - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Gray.copy(alpha = 0.5f), - contentColor = Color.White - ), - modifier = Modifier.padding(12.dp) + onClick = { + view.performHapticFeedback(HapticFeedbackConstantsCompat.KEYBOARD_TAP) + onDismissRequest() + }, colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), contentColor = Color.White + ), modifier = Modifier + .padding(4.dp) + .align(Alignment.TopStart) ) { Icon( imageVector = Icons.Outlined.Close, - contentDescription = stringResource(id = R.string.close) + contentDescription = stringResource(id = R.string.close), + ) + } + var expanded by remember { mutableStateOf(false) } + IconButton( + onClick = { expanded = true }, colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), contentColor = Color.White + ), modifier = Modifier + .padding(4.dp) + .align(Alignment.TopEnd) + ) { + Icon( + imageVector = Icons.Outlined.MoreHoriz, + contentDescription = stringResource(id = R.string.more), ) } + + val launcher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), + onResult = { result -> + if (result) { + onDownloadImage(imageData.imageUrl) + } else { + context.showToast(context.getString(R.string.permission_denied)) + } + }) + + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(12.dp) + ) { + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(text = { Text(text = stringResource(id = R.string.save)) }, + onClick = { + val isStoragePermissionGranted = ContextCompat.checkSelfPermission( + context, Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + + if (Build.VERSION.SDK_INT > 28 || isStoragePermissionGranted) { + onDownloadImage(imageData.imageUrl) + } else { + launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + expanded = false + }) + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index 32e20cd69..e0f534741 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -28,10 +28,12 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.paging.compose.collectAsLazyPagingItems +import me.ash.reader.R import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation import me.ash.reader.ui.ext.collectAsStateValue +import me.ash.reader.ui.ext.showToast import me.ash.reader.ui.motion.materialSharedAxisY import me.ash.reader.ui.page.home.HomeViewModel import kotlin.math.abs @@ -40,7 +42,9 @@ import kotlin.math.abs private const val UPWARD = 1 private const val DOWNWARD = -1 -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) +@OptIn( + ExperimentalFoundationApi::class, ExperimentalMaterialApi::class +) @Composable fun ReadingPage( navController: NavHostController, @@ -48,6 +52,7 @@ fun ReadingPage( readingViewModel: ReadingViewModel = hiltViewModel(), ) { val tonalElevation = LocalReadingPageTonalElevation.current + val context = LocalContext.current val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value val readingUiState = readingViewModel.readingUiState.collectAsStateValue() val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue() @@ -229,6 +234,20 @@ fun ReadingPage( } ) if (showFullScreenImageViewer) { - ReaderImageViewer(imageData = currentImageData) { showFullScreenImageViewer = false } + + ReaderImageViewer( + imageData = currentImageData, + onDownloadImage = { + readingViewModel.downloadImage( + it, + onSuccess = { context.showToast(context.getString(R.string.image_saved)) }, + onFailure = { + // FIXME: crash the app for error report + th -> throw th + } + ) + }, + onDismissRequest = { showFullScreenImageViewer = false } + ) } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt index a641e8518..a84362529 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt @@ -1,5 +1,6 @@ package me.ash.reader.ui.page.home.reading +import android.net.Uri import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -20,6 +21,7 @@ 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.rss.RssHelper +import me.ash.reader.infrastructure.storage.AndroidImageDownloader import java.util.Date import javax.inject.Inject @@ -31,6 +33,7 @@ class ReadingViewModel @Inject constructor( private val ioDispatcher: CoroutineDispatcher, @ApplicationScope private val applicationScope: CoroutineScope, + private val imageDownloader: AndroidImageDownloader ) : ViewModel() { private val _readingUiState = MutableStateFlow(ReadingUiState()) @@ -195,6 +198,16 @@ class ReadingViewModel @Inject constructor( } ?: return false return true } + + fun downloadImage( + url: String, + onSuccess: (Uri) -> Unit = {}, + onFailure: (Throwable) -> Unit = {} + ) { + viewModelScope.launch { + imageDownloader.downloadImage(url).onSuccess(onSuccess).onFailure(onFailure) + } + } } data class ReadingUiState( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7534643b..7be9732b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -421,4 +421,7 @@ Toggle starred Export Pull to switch article + Save + Image saved + Permission denied From f06d8ce05e1028e906374523541b4a6c36d2577c Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:15:43 +0800 Subject: [PATCH 2/4] fix(ui): load new items from paging data --- .../main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt index 871922bb1..b4599738e 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/ArticleList.kt @@ -24,7 +24,7 @@ fun LazyListScope.ArticleList( onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null, ) { for (index in 0 until pagingItems.itemCount) { - when (val item = pagingItems.peek(index)) { + when (val item = pagingItems[index]) { is ArticleFlowItem.Article -> { item(key = item.articleWithFeed.article.id) { // if (item.articleWithFeed.article.isUnread) { From d40743d5ff25d18b909ecefd25ad3ed30752053c Mon Sep 17 00:00:00 2001 From: Ash Date: Fri, 8 Mar 2024 12:40:29 +0800 Subject: [PATCH 3/4] fix(sync): replace publish date of an article with the current time if it is a future date (#638) --- .../ash/reader/domain/service/AbstractRssRepository.kt | 7 ++++--- .../java/me/ash/reader/domain/service/FeverRssService.kt | 9 +++++++-- .../ash/reader/domain/service/GoogleReaderRssService.kt | 6 +++++- .../java/me/ash/reader/infrastructure/rss/RssHelper.kt | 9 ++++++--- app/src/main/java/me/ash/reader/ui/ext/DateExt.kt | 2 ++ 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt index 0a2a76bf8..566f2511e 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt @@ -98,11 +98,12 @@ abstract class AbstractRssRepository( supervisorScope { coroutineWorker.setProgress(SyncWorker.setIsSyncing(true)) val preTime = System.currentTimeMillis() + val preDate = Date(preTime) val accountId = context.currentAccountId feedDao.queryAll(accountId) .chunked(16) .forEach { - it.map { feed -> async { syncFeed(feed) } } + it.map { feed -> async { syncFeed(feed, preDate) } } .awaitAll() .forEach { if (it.feed.isNotification) { @@ -165,9 +166,9 @@ abstract class AbstractRssRepository( articleDao.markAsStarredByArticleId(accountId, articleId, isStarred) } - private suspend fun syncFeed(feed: Feed): FeedWithArticle { + private suspend fun syncFeed(feed: Feed, preDate: Date = Date()): FeedWithArticle { val latest = articleDao.queryLatestByFeedId(context.currentAccountId, feed.id) - val articles = rssHelper.queryRssXml(feed, latest?.link) + val articles = rssHelper.queryRssXml(feed, latest?.link, preDate) if (feed.icon == null) { val iconLink = rssHelper.queryRssIconLink(feed.url) if (iconLink != null) { diff --git a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt index ec7aa99e9..a2972dc5a 100644 --- a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt @@ -32,6 +32,7 @@ import me.ash.reader.infrastructure.rss.provider.fever.FeverDTO import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.decodeHTML import me.ash.reader.ui.ext.dollarLast +import me.ash.reader.ui.ext.isFuture import me.ash.reader.ui.ext.showToast import me.ash.reader.ui.ext.spacerDollar import java.util.Date @@ -137,6 +138,7 @@ class FeverRssService @Inject constructor( try { val preTime = System.currentTimeMillis() + val preDate = Date(preTime) val accountId = context.currentAccountId val account = accountDao.queryById(accountId)!! val feverAPI = getFeverAPI() @@ -192,7 +194,10 @@ class FeverRssService @Inject constructor( *itemsBody.items?.map { Article( id = accountId.spacerDollar(it.id!!), - date = it.created_on_time?.run { Date(this * 1000) } ?: Date(), + date = it.created_on_time + ?.run { Date(this * 1000) } + ?.takeIf { !it.isFuture(preDate) } + ?: preDate, title = it.title.decodeHTML() ?: context.getString(R.string.empty), author = it.author, rawDescription = it.html ?: "", @@ -204,7 +209,7 @@ class FeverRssService @Inject constructor( accountId = accountId, isUnread = (it.is_read ?: 0) <= 0, isStarred = (it.is_saved ?: 0) > 0, - updateAt = Date(), + updateAt = preDate, ).also { sinceId = it.id.dollarLast() } diff --git a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt index 7510ee2c9..cf3e474bd 100644 --- a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt @@ -35,6 +35,7 @@ import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderDTO import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.decodeHTML import me.ash.reader.ui.ext.dollarLast +import me.ash.reader.ui.ext.isFuture import me.ash.reader.ui.ext.showToast import me.ash.reader.ui.ext.spacerDollar import java.util.Calendar @@ -405,7 +406,10 @@ class GoogleReaderRssService @Inject constructor( val articleId = it.id!!.ofItemStreamIdToId() Article( id = accountId.spacerDollar(articleId), - date = it.published?.run { Date(this * 1000) } ?: preDate, + date = it.published + ?.run { Date(this * 1000) } + ?.takeIf { !it.isFuture(preDate) } + ?: preDate, title = it.title.decodeHTML() ?: context.getString(R.string.empty), author = it.author, rawDescription = it.summary?.content ?: "", 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 a2db73f73..e35dcf646 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 @@ -18,6 +18,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.isFuture import me.ash.reader.ui.ext.spacerDollar import okhttp3.OkHttpClient import okhttp3.Request @@ -67,6 +68,7 @@ class RssHelper @Inject constructor( suspend fun queryRssXml( feed: Feed, latestLink: String?, + preDate: Date = Date(), ): List
= try { val accountId = context.currentAccountId @@ -76,7 +78,7 @@ class RssHelper @Inject constructor( .entries .asSequence() .takeWhile { latestLink == null || latestLink != it.link } - .map { buildArticleFromSyndEntry(feed, accountId, it) } + .map { buildArticleFromSyndEntry(feed, accountId, it, preDate) } .toList() } } catch (e: Exception) { @@ -89,6 +91,7 @@ class RssHelper @Inject constructor( feed: Feed, accountId: Int, syndEntry: SyndEntry, + preDate: Date = Date(), ): Article { val desc = syndEntry.description?.value val content = syndEntry.contents @@ -108,7 +111,7 @@ class RssHelper @Inject constructor( id = accountId.spacerDollar(UUID.randomUUID().toString()), accountId = accountId, feedId = feed.id, - date = syndEntry.publishedDate ?: syndEntry.updatedDate ?: Date(), + date = (syndEntry.publishedDate ?: syndEntry.updatedDate).takeIf { !it.isFuture(preDate) } ?: preDate, title = syndEntry.title.decodeHTML() ?: feed.name, author = syndEntry.author, rawDescription = (content ?: desc) ?: "", @@ -116,7 +119,7 @@ class RssHelper @Inject constructor( fullContent = content, img = findImg((content ?: desc) ?: ""), link = syndEntry.link ?: "", - updateAt = Date(), + updateAt = preDate, ) } diff --git a/app/src/main/java/me/ash/reader/ui/ext/DateExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DateExt.kt index 0717a280e..00126b363 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DateExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DateExt.kt @@ -86,3 +86,5 @@ private fun String.parseToDate( } return null } + +fun Date.isFuture(staticDate: Date = Date()): Boolean = this.time > staticDate.time From 6b29a810ba4bfffc03f7debde97815023b64d76b Mon Sep 17 00:00:00 2001 From: junkfood <69683722+JunkFood02@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:26:30 +0800 Subject: [PATCH 4/4] style: reformat code --- .../reader/domain/service/FeverRssService.kt | 231 +++++++++--------- 1 file changed, 119 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt index a2972dc5a..fc8fd8b58 100644 --- a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt @@ -133,142 +133,149 @@ class FeverRssService @Inject constructor( * 3. Fetch the Fever articles * 4. Synchronize read/unread and starred/un-starred items */ - override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result = supervisorScope { - coroutineWorker.setProgress(SyncWorker.setIsSyncing(true)) + override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result = + supervisorScope { + coroutineWorker.setProgress(SyncWorker.setIsSyncing(true)) - try { - val preTime = System.currentTimeMillis() - val preDate = Date(preTime) - val accountId = context.currentAccountId - val account = accountDao.queryById(accountId)!! - val feverAPI = getFeverAPI() + try { + val preTime = System.currentTimeMillis() + val preDate = Date(preTime) + val accountId = context.currentAccountId + val account = accountDao.queryById(accountId)!! + val feverAPI = getFeverAPI() - // 1. Fetch the Fever groups - val groups = feverAPI.getGroups().groups?.map { - Group( - id = accountId.spacerDollar(it.id!!), - name = it.title ?: context.getString(R.string.empty), - accountId = accountId, - ) - } ?: emptyList() - groupDao.insertOrUpdate(groups) - - // 2. Fetch the Fever feeds - val feedsBody = feverAPI.getFeeds() - val feedsGroupsMap = mutableMapOf() - feedsBody.feeds_groups?.forEach { feedsGroups -> - feedsGroups.group_id?.toString()?.let { groupId -> - feedsGroups.feed_ids?.split(",")?.forEach { feedId -> - feedsGroupsMap[feedId] = groupId - } - } - } - - // Fetch the Fever favicons - val faviconsById = feverAPI.getFavicons().favicons?.associateBy { it.id } ?: emptyMap() - feedDao.insertOrUpdate( - feedsBody.feeds?.map { - Feed( + // 1. Fetch the Fever groups + val groups = feverAPI.getGroups().groups?.map { + Group( id = accountId.spacerDollar(it.id!!), - name = it.title.decodeHTML() ?: context.getString(R.string.empty), - url = it.url!!, - groupId = accountId.spacerDollar(feedsGroupsMap[it.id.toString()]!!), + name = it.title ?: context.getString(R.string.empty), accountId = accountId, - icon = faviconsById[it.favicon_id]?.data ) } ?: emptyList() - ) + groupDao.insertOrUpdate(groups) - // Handle empty icon for feeds - val noIconFeeds = feedDao.queryNoIcon(accountId) - noIconFeeds.forEach { - it.icon = rssHelper.queryRssIconLink(it.url) - } - feedDao.update(*noIconFeeds.toTypedArray()) + // 2. Fetch the Fever feeds + val feedsBody = feverAPI.getFeeds() + val feedsGroupsMap = mutableMapOf() + feedsBody.feeds_groups?.forEach { feedsGroups -> + feedsGroups.group_id?.toString()?.let { groupId -> + feedsGroups.feed_ids?.split(",")?.forEach { feedId -> + feedsGroupsMap[feedId] = groupId + } + } + } - // 3. Fetch the Fever articles (up to unlimited counts) - var sinceId = account.lastArticleId?.dollarLast() ?: "" - var itemsBody = feverAPI.getItemsSince(sinceId) - while (itemsBody.items?.isNotEmpty() == true) { - articleDao.insert( - *itemsBody.items?.map { - Article( + // Fetch the Fever favicons + val faviconsById = + feverAPI.getFavicons().favicons?.associateBy { it.id } ?: emptyMap() + feedDao.insertOrUpdate( + feedsBody.feeds?.map { + Feed( id = accountId.spacerDollar(it.id!!), - date = it.created_on_time - ?.run { Date(this * 1000) } - ?.takeIf { !it.isFuture(preDate) } - ?: preDate, - title = it.title.decodeHTML() ?: context.getString(R.string.empty), - author = it.author, - rawDescription = it.html ?: "", - shortDescription = Readability.parseToText(it.html, it.url).take(110), - fullContent = it.html, - img = rssHelper.findImg(it.html ?: ""), - link = it.url ?: "", - feedId = accountId.spacerDollar(it.feed_id!!), + name = it.title.decodeHTML() ?: context.getString(R.string.empty), + url = it.url!!, + groupId = accountId.spacerDollar(feedsGroupsMap[it.id.toString()]!!), accountId = accountId, - isUnread = (it.is_read ?: 0) <= 0, - isStarred = (it.is_saved ?: 0) > 0, - updateAt = preDate, - ).also { - sinceId = it.id.dollarLast() - } - }?.toTypedArray() ?: emptyArray() + icon = faviconsById[it.favicon_id]?.data + ) + } ?: emptyList() ) - if (itemsBody.items?.size!! >= 50) { - itemsBody = feverAPI.getItemsSince(sinceId) - } else { - break + + // Handle empty icon for feeds + val noIconFeeds = feedDao.queryNoIcon(accountId) + noIconFeeds.forEach { + it.icon = rssHelper.queryRssIconLink(it.url) } - } + feedDao.update(*noIconFeeds.toTypedArray()) - // 4. Synchronize read/unread and starred/un-starred - val unreadArticleIds = feverAPI.getUnreadItems().unread_item_ids?.split(",") - val starredArticleIds = feverAPI.getSavedItems().saved_item_ids?.split(",") - val articleMeta = articleDao.queryMetadataAll(accountId) - for (meta: ArticleMeta in articleMeta) { - val articleId = meta.id.dollarLast() - val shouldBeUnread = unreadArticleIds?.contains(articleId) - val shouldBeStarred = starredArticleIds?.contains(articleId) - if (meta.isUnread != shouldBeUnread) { - articleDao.markAsReadByArticleId(accountId, meta.id, shouldBeUnread ?: true) + // 3. Fetch the Fever articles (up to unlimited counts) + var sinceId = account.lastArticleId?.dollarLast() ?: "" + var itemsBody = feverAPI.getItemsSince(sinceId) + while (itemsBody.items?.isNotEmpty() == true) { + articleDao.insert( + *itemsBody.items?.map { + Article( + id = accountId.spacerDollar(it.id!!), + date = it.created_on_time + ?.run { Date(this * 1000) } + ?.takeIf { !it.isFuture(preDate) } + ?: preDate, + title = it.title.decodeHTML() ?: context.getString(R.string.empty), + author = it.author, + rawDescription = it.html ?: "", + shortDescription = Readability.parseToText(it.html, it.url) + .take(110), + fullContent = it.html, + img = rssHelper.findImg(it.html ?: ""), + link = it.url ?: "", + feedId = accountId.spacerDollar(it.feed_id!!), + accountId = accountId, + isUnread = (it.is_read ?: 0) <= 0, + isStarred = (it.is_saved ?: 0) > 0, + updateAt = preDate, + ).also { + sinceId = it.id.dollarLast() + } + }?.toTypedArray() ?: emptyArray() + ) + if (itemsBody.items?.size!! >= 50) { + itemsBody = feverAPI.getItemsSince(sinceId) + } else { + break + } } - if (meta.isStarred != shouldBeStarred) { - articleDao.markAsStarredByArticleId(accountId, meta.id, shouldBeStarred ?: false) + + // 4. Synchronize read/unread and starred/un-starred + val unreadArticleIds = feverAPI.getUnreadItems().unread_item_ids?.split(",") + val starredArticleIds = feverAPI.getSavedItems().saved_item_ids?.split(",") + val articleMeta = articleDao.queryMetadataAll(accountId) + for (meta: ArticleMeta in articleMeta) { + val articleId = meta.id.dollarLast() + val shouldBeUnread = unreadArticleIds?.contains(articleId) + val shouldBeStarred = starredArticleIds?.contains(articleId) + if (meta.isUnread != shouldBeUnread) { + articleDao.markAsReadByArticleId(accountId, meta.id, shouldBeUnread ?: true) + } + if (meta.isStarred != shouldBeStarred) { + articleDao.markAsStarredByArticleId( + accountId, + meta.id, + shouldBeStarred ?: false + ) + } } - } - // Remove orphaned groups and feeds, after synchronizing the starred/un-starred - val groupIds = groups.map { it.id } - groupDao.queryAll(accountId).forEach { - if (!groupIds.contains(it.id)) { - super.deleteGroup(it, true) + // Remove orphaned groups and feeds, after synchronizing the starred/un-starred + val groupIds = groups.map { it.id } + groupDao.queryAll(accountId).forEach { + if (!groupIds.contains(it.id)) { + super.deleteGroup(it, true) + } } - } - feedDao.queryAll(accountId).forEach { - if (!feedsGroupsMap.contains(it.id.dollarLast())) { - super.deleteFeed(it, true) + feedDao.queryAll(accountId).forEach { + if (!feedsGroupsMap.contains(it.id.dollarLast())) { + super.deleteFeed(it, true) + } } - } - Log.i("RLog", "onCompletion: ${System.currentTimeMillis() - preTime}") - accountDao.update(account.apply { - updateAt = Date() - if (sinceId.isNotEmpty()) { - lastArticleId = accountId.spacerDollar(sinceId) + Log.i("RLog", "onCompletion: ${System.currentTimeMillis() - preTime}") + accountDao.update(account.apply { + updateAt = Date() + if (sinceId.isNotEmpty()) { + lastArticleId = accountId.spacerDollar(sinceId) + } + }) + ListenableWorker.Result.success(SyncWorker.setIsSyncing(false)) + } catch (e: Exception) { + Log.e("RLog", "On sync exception: ${e.message}", e) + withContext(mainDispatcher) { + context.showToast(e.message) } - }) - ListenableWorker.Result.success(SyncWorker.setIsSyncing(false)) - } catch (e: Exception) { - Log.e("RLog", "On sync exception: ${e.message}", e) - withContext(mainDispatcher) { - context.showToast(e.message) + ListenableWorker.Result.failure(SyncWorker.setIsSyncing(false)) } - ListenableWorker.Result.failure(SyncWorker.setIsSyncing(false)) } - } override suspend fun markAsRead( groupId: String?,