Display notifications after background synchronization

This commit is contained in:
Shinokuni 2024-07-28 15:22:34 +02:00
parent 76d7f98227
commit cafb46c727
13 changed files with 299 additions and 79 deletions

View File

@ -24,9 +24,12 @@
android:resource="@xml/file_paths" />
</provider>
<receiver android:name=".sync.SyncBroadcastReceiver" android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -1,5 +1,6 @@
package com.readrops.app
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
@ -10,19 +11,26 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.toArgb
import androidx.lifecycle.lifecycleScope
import cafe.adriel.voyager.navigator.CurrentScreen
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
import com.readrops.app.account.selection.AccountSelectionScreen
import com.readrops.app.account.selection.AccountSelectionScreenModel
import com.readrops.app.home.HomeScreen
import com.readrops.app.sync.SyncWorker
import com.readrops.app.timelime.TimelineTab
import com.readrops.app.util.Preferences
import com.readrops.app.util.theme.ReadropsTheme
import com.readrops.db.Database
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.androidx.compose.KoinAndroidContext
import org.koin.core.annotation.KoinExperimentalAPI
@ -67,12 +75,16 @@ class MainActivity : ComponentActivity(), KoinComponent {
)
Navigator(
screen = if (accountExists) HomeScreen() else AccountSelectionScreen(),
screen = if (accountExists) HomeScreen else AccountSelectionScreen(),
disposeBehavior = NavigatorDisposeBehavior(
// prevent screenModels being recreated when opening a screen from a tab
disposeNestedNavigators = false
)
) {
LaunchedEffect(Unit) {
handleIntent(intent)
}
CurrentScreen()
}
}
@ -80,6 +92,29 @@ class MainActivity : ComponentActivity(), KoinComponent {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
lifecycleScope.launch(Dispatchers.IO) {
handleIntent(intent)
}
}
private suspend fun handleIntent(intent: Intent) {
if (intent.hasExtra(SyncWorker.ACCOUNT_ID_KEY)) {
val accountId = intent.getIntExtra(SyncWorker.ACCOUNT_ID_KEY, -1)
get<Database>().accountDao()
.updateCurrentAccount(accountId)
HomeScreen.openTab(TimelineTab)
if (intent.hasExtra(SyncWorker.ITEM_ID_KEY)) {
val itemId = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1)
HomeScreen.openItemScreen(itemId)
}
}
}
private fun useDarkTheme(mode: String, darkFlag: Int): Boolean {
return when (mode) {
"light" -> false

View File

@ -71,7 +71,7 @@ class AccountCredentialsScreen(
if (state.exitScreen) {
if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) {
navigator.replaceAll(HomeScreen())
navigator.replaceAll(HomeScreen)
} else {
navigator.pop()
}

View File

@ -88,7 +88,7 @@ class AccountSelectionScreen : AndroidScreen() {
when (state.navState) {
is NavState.GoToHomeScreen -> {
// using replace makes the app crash due to a screen key conflict
navigator.replaceAll(HomeScreen())
navigator.replaceAll(HomeScreen)
}
is NavState.GoToAccountCredentialsScreen -> {

View File

@ -1,6 +1,5 @@
package com.readrops.app.home
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
@ -20,29 +19,42 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.CurrentTab
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabNavigator
import com.readrops.app.R
import com.readrops.app.account.AccountTab
import com.readrops.app.feeds.FeedTab
import com.readrops.app.item.ItemScreen
import com.readrops.app.more.MoreTab
import com.readrops.app.timelime.TimelineTab
import com.readrops.app.util.components.AndroidScreen
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
class HomeScreen : AndroidScreen() {
object HomeScreen : AndroidScreen() {
private val itemChannel = Channel<Int>()
private val tabChannel = Channel<Tab>()
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val scaffoldInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
LaunchedEffect(Unit) {
itemChannel.receiveAsFlow()
.collect {
navigator.push(ItemScreen(it))
}
}
TabNavigator(
tab = TimelineTab
) { tabNavigator ->
@ -101,6 +113,18 @@ class HomeScreen : AndroidScreen() {
},
contentWindowInsets = scaffoldInsets
) { paddingValues ->
LaunchedEffect(Unit) {
tabChannel.receiveAsFlow()
.collect {
tabNavigator.current = TimelineTab
}
}
BackHandler(
enabled = tabNavigator.current != TimelineTab,
onBack = { tabNavigator.current = TimelineTab }
)
Box(
modifier = Modifier
.fillMaxSize()
@ -109,13 +133,16 @@ class HomeScreen : AndroidScreen() {
) {
CurrentTab()
}
BackHandler(
enabled = tabNavigator.current != TimelineTab,
onBack = { tabNavigator.current = TimelineTab }
)
}
}
}
}
suspend fun openItemScreen(itemId: Int) {
itemChannel.send(itemId)
}
suspend fun openTab(tab: Tab) {
tabChannel.send(tab)
}
}

View File

@ -24,8 +24,7 @@ class LocalRSSRepository(
account: Account
) : BaseRepository(database, account), KoinComponent {
override suspend fun login(account: Account) { /* useless here */
}
override suspend fun login(account: Account) { /* useless here */ }
override suspend fun synchronize(
selectedFeeds: List<Feed>,
@ -52,14 +51,12 @@ class LocalRSSRepository(
try {
val pair = dataSource.queryRSSResource(feed.url!!, headers.build())
pair?.let {
insertNewItems(it.second, feed)
syncResult.items = it.second
pair?.let { // temporary
syncResult.items += insertNewItems(it.second, feed)
}
} catch (e: Exception) {
errors[feed] = e
}
}
return Pair(syncResult, errors)
@ -89,9 +86,8 @@ class LocalRSSRepository(
return@withContext errors
}
private suspend fun insertNewItems(items: List<Item>, feed: Feed) {
items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation
val itemsToInsert = mutableListOf<Item>()
private suspend fun insertNewItems(items: List<Item>, feed: Feed): List<Item> {
val newItems = mutableListOf<Item>()
for (item in items) {
if (!database.itemDao().itemExists(item.guid!!, feed.accountId)) {
@ -106,14 +102,19 @@ class LocalRSSRepository(
}
item.feedId = feed.id
itemsToInsert += item
newItems += item
}
}
database.itemDao().insert(itemsToInsert)
database.itemDao().insert(newItems)
.zip(newItems)
.forEach { (id, item) -> item.id = id.toInt() }
return newItems
}
private suspend fun insertFeed(feed: Feed): Feed {
// TODO better handle this case
require(!database.feedDao().feedExists(feed.url!!, account.id)) {
"Feed already exists for account ${account.accountName}"
}

View File

@ -19,6 +19,7 @@ data class NotificationContent(
val content: String? = null,
val largeIcon: Bitmap? = null,
val item: Item? = null,
val color: Int = 0,
val accountId: Int = 0
)
@ -27,24 +28,24 @@ class SyncAnalyzer(
val database: Database
) : KoinComponent {
suspend fun getNotificationContent(syncResults: Map<Account, SyncResult>): NotificationContent {
suspend fun getNotificationContent(syncResults: Map<Account, SyncResult>): NotificationContent? {
return if (newItemsInMultipleAccounts(syncResults)) { // new items from several accounts
val feeds = database.feedDao().selectFromIds(getFeedsIdsForNewItems(syncResults))
var itemCount = 0
syncResults.values.forEach { syncResult ->
for (syncResult in syncResults.values) {
itemCount += syncResult.items.filter {
isFeedNotificationEnabledForItem(feeds, it)
}.size
}
NotificationContent(title = context.getString(R.string.new_items, itemCount.toString()))
} else { // new items from only one account
getContentFromOneAccount(syncResults)
NotificationContent(title = context.getString(R.string.new_items, "$itemCount"))
} else { // new items from a single account
getSingleAccountContent(syncResults)
}
}
private suspend fun getContentFromOneAccount(syncResults: Map<Account, SyncResult>): NotificationContent {
private suspend fun getSingleAccountContent(syncResults: Map<Account, SyncResult>): NotificationContent? {
val syncResultMap = syncResults.filterValues { it.items.isNotEmpty() }
if (syncResultMap.values.isNotEmpty()) {
@ -59,8 +60,8 @@ class SyncAnalyzer(
syncResult.items.filter { isFeedNotificationEnabledForItem(feeds, it) }
val itemCount = items.size
// new items from several feeds from one account
return when {
// multiple new items from several feeds
feedsIdsForNewItems.size > 1 && itemCount > 1 -> {
NotificationContent(
title = account.accountName!!,
@ -72,24 +73,24 @@ class SyncAnalyzer(
accountId = account.id
)
}
// new items from only one feed from one account
// multiple new items from a single feed
feedsIdsForNewItems.size == 1 ->
oneFeedCase(feedsIdsForNewItems.first(), syncResult.items, account)
itemCount == 1 -> oneFeedCase(items.first().feedId, items, account)
else -> NotificationContent()
singleFeedCase(feedsIdsForNewItems.first(), syncResult.items, account)
// only one new item from a single feed
itemCount == 1 -> singleFeedCase(items.first().feedId, items, account)
else -> null
}
}
}
return NotificationContent()
return null
}
private suspend fun oneFeedCase(
private suspend fun singleFeedCase(
feedId: Int,
items: List<Item>,
account: Account
): NotificationContent {
): NotificationContent? {
val feed = database.feedDao().selectFeed(feedId)
if (feed.isNotificationEnabled) {
@ -101,15 +102,11 @@ class SyncAnalyzer(
.build()
)
target.drawable!!.toBitmap()
target.drawable?.toBitmap()
}
val (item, content) = if (items.size == 1) {
val item = database.itemDao().selectByRemoteId(
items.first().remoteId!!,
items.first().feedId
)
val item = items.first()
item to item.title
} else {
null to context.getString(R.string.new_items, items.size.toString())
@ -120,11 +117,12 @@ class SyncAnalyzer(
largeIcon = icon,
content = content,
item = item,
color = feed.backgroundColor,
accountId = account.id
)
}
return NotificationContent()
return null
}
private fun newItemsInMultipleAccounts(syncResults: Map<Account, SyncResult>): Boolean {

View File

@ -0,0 +1,44 @@
package com.readrops.app.sync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import com.readrops.db.Database
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class SyncBroadcastReceiver : BroadcastReceiver(), KoinComponent {
private val database by inject<Database>()
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) {
NotificationManagerCompat.from(context)
.cancel(SyncWorker.SYNC_RESULT_NOTIFICATION_ID)
when (intent.action) {
ACTION_MARK_READ -> {
val id = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1)
GlobalScope.launch {
database.itemDao().updateReadState(id, true)
}
}
ACTION_SET_FAVORITE -> {
val id = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1)
GlobalScope.launch {
database.itemDao().updateStarState(id, true)
}
}
}
}
companion object {
const val ACTION_MARK_READ = "ACTION_MARK_READ"
const val ACTION_SET_FAVORITE = "ACTION_SET_FAVORITE"
}
}

View File

@ -1,11 +1,13 @@
package com.readrops.app.sync
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.Action
import androidx.core.app.NotificationCompat.Builder
import androidx.core.app.NotificationManagerCompat
import androidx.work.BackoffPolicy
@ -24,6 +26,7 @@ import androidx.work.workDataOf
import com.readrops.api.services.Credentials
import com.readrops.api.services.SyncResult
import com.readrops.api.utils.AuthInterceptor
import com.readrops.app.MainActivity
import com.readrops.app.R
import com.readrops.app.ReadropsApp
import com.readrops.app.repositories.BaseRepository
@ -51,11 +54,13 @@ class SyncWorker(
// TODO handle notification permission for Android 14+ (or 15?)
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
val workManager = WorkManager.getInstance(applicationContext)
val isManual = tags.contains(WORK_MANUAL)
val infos = WorkManager.getInstance(applicationContext)
.getWorkInfosByTagFlow(TAG).first()
val infos = workManager.getWorkInfosByTagFlow(TAG).first()
if (infos.any { it.state == WorkInfo.State.RUNNING && it.id != id }) {
return if (tags.contains(WORK_MANUAL)) {
return if (isManual) {
Result.failure(
workDataOf(
SYNC_FAILURE_KEY to true,
@ -70,41 +75,45 @@ class SyncWorker(
}
}
var workResult: Result
try {
require(notificationManager.areNotificationsEnabled())
val notificationBuilder = Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID)
.setProgress(0, 0, true)
.setSmallIcon(R.drawable.ic_sync)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) // for Android 7.1 and earlier
.setOngoing(true)
.setOnlyAlertOnce(true)
val notificationBuilder =
Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID)
.setProgress(0, 0, true)
.setLargeIcon(BitmapFactory.decodeResource(applicationContext.resources, R.mipmap.ic_launcher_round))
.setSmallIcon(R.drawable.ic_sync)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) // for Android 7.1 and earlier
.setStyle(NotificationCompat.BigTextStyle())
.setOngoing(true)
.setOnlyAlertOnce(true)
return try {
val (workResult, syncResults) = refreshAccounts(notificationBuilder)
notificationManager.cancel(SYNC_NOTIFICATION_ID)
workResult = refreshAccounts(notificationBuilder)
if (!isManual) {
displaySyncResults(syncResults)
}
workResult
} catch (e: Exception) {
Log.e(TAG, "${e::class.simpleName}: ${e.message} ${e.printStackTrace()}")
workResult = Result.failure(
workDataOf(SYNC_FAILURE_KEY to true)
.putSerializable(SYNC_FAILURE_EXCEPTION_KEY, e)
)
} finally {
notificationManager.cancel(SYNC_NOTIFICATION_ID)
}
return workResult
notificationManager.cancel(SYNC_NOTIFICATION_ID)
if (isManual) {
Result.failure(
workDataOf(SYNC_FAILURE_KEY to true)
.putSerializable(SYNC_FAILURE_EXCEPTION_KEY, e)
)
} else {
Result.failure()
}
}
}
@SuppressLint("MissingPermission")
private suspend fun refreshAccounts(notificationBuilder: Builder): Result {
private suspend fun refreshAccounts(notificationBuilder: Builder): Pair<Result, Map<Account, SyncResult>> {
val sharedPreferences = get<SharedPreferences>()
var workResult = Result.success(workDataOf(END_SYNC_KEY to true))
val syncResults = mutableMapOf<Account, SyncResult>()
val accountId = inputData.getInt(ACCOUNT_ID_KEY, 0)
val accounts = if (accountId == 0) {
val accountId = inputData.getInt(ACCOUNT_ID_KEY, -1)
val accounts = if (accountId == -1) {
database.accountDao().selectAllAccounts().first()
} else {
listOf(database.accountDao().select(accountId))
@ -129,21 +138,25 @@ class SyncWorker(
if (account.isLocal) {
val result = refreshLocalAccount(repository, account, notificationBuilder)
if (result.second.isNotEmpty()) {
if (result.second.isNotEmpty() && tags.contains(WORK_MANUAL)) {
workResult = Result.success(
workDataOf(END_SYNC_KEY to true)
.putSerializable(LOCAL_SYNC_ERRORS_KEY, result.second)
)
}
syncResults[account] = result.first
} else {
get<AuthInterceptor>().credentials = Credentials.toCredentials(account)
val syncResult = repository.synchronize()
fetchFeedColors(syncResult, notificationBuilder)
syncResults[account] = syncResult
}
}
return workResult
return workResult to syncResults
}
@SuppressLint("MissingPermission")
@ -172,6 +185,7 @@ class SyncWorker(
selectedFeeds = feeds,
onUpdate = { feed ->
notificationBuilder.setContentText(feed.name)
.setStyle(NotificationCompat.BigTextStyle().bigText(feed.name))
.setProgress(feedMax, ++feedCount, false)
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
@ -206,6 +220,7 @@ class SyncWorker(
val feedName = syncResult.feeds.first { it.id == feedId.toInt() }.name
notificationBuilder.setContentText(feedName)
.setStyle(NotificationCompat.BigTextStyle().bigText(feedName))
.setProgress(syncResult.newFeedIds.size, index + 1, false)
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
@ -219,6 +234,98 @@ class SyncWorker(
}
}
@SuppressLint("MissingPermission")
private suspend fun displaySyncResults(syncResults: Map<Account, SyncResult>) {
val notificationContent = SyncAnalyzer(applicationContext, database)
.getNotificationContent(syncResults)
if (notificationContent != null) {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
if (notificationContent.accountId > 0) {
putExtra(ACCOUNT_ID_KEY, notificationContent.accountId)
}
if (notificationContent.item != null) {
putExtra(ITEM_ID_KEY, notificationContent.item.id)
}
}
val notificationBuilder = Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID)
.setContentTitle(notificationContent.title)
.setContentText(notificationContent.content)
.setStyle(NotificationCompat.BigTextStyle().bigText(notificationContent.content))
.setSmallIcon(R.drawable.ic_notifications)
.setColor(notificationContent.color)
.setContentIntent(
PendingIntent.getActivity(
applicationContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
.setAutoCancel(true)
notificationContent.item?.let { item ->
val itemId = item.id
notificationBuilder
.addAction(getMarkReadAction(itemId))
.addAction(getMarkFavoriteAction(itemId))
}
notificationContent.largeIcon?.let { notificationBuilder.setLargeIcon(it) }
notificationManager.notify(SYNC_RESULT_NOTIFICATION_ID, notificationBuilder.build())
}
}
private fun getMarkReadAction(itemId: Int): Action {
val intent = Intent(applicationContext, SyncBroadcastReceiver::class.java).apply {
action = SyncBroadcastReceiver.ACTION_MARK_READ
putExtra(ITEM_ID_KEY, itemId)
}
val pendingIntent =
PendingIntent.getBroadcast(
applicationContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return Action.Builder(
R.drawable.ic_done_all,
applicationContext.getString(R.string.mark_read),
pendingIntent
)
.setAllowGeneratedReplies(false)
.build()
}
private fun getMarkFavoriteAction(itemId: Int): Action {
val intent = Intent(applicationContext, SyncBroadcastReceiver::class.java).apply {
action = SyncBroadcastReceiver.ACTION_SET_FAVORITE
putExtra(ITEM_ID_KEY, itemId)
}
val pendingIntent =
PendingIntent.getBroadcast(
applicationContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return Action.Builder(
R.drawable.ic_favorite_border,
applicationContext.getString(R.string.add_to_favorite),
pendingIntent
)
.setAllowGeneratedReplies(false)
.build()
}
companion object {
private val TAG: String = SyncWorker::class.java.simpleName
@ -226,13 +333,14 @@ class SyncWorker(
private val WORK_MANUAL = "$TAG-manual"
private const val SYNC_NOTIFICATION_ID = 2
private const val SYNC_RESULT_NOTIFICATION_ID = 3
const val SYNC_RESULT_NOTIFICATION_ID = 3
const val END_SYNC_KEY = "END_SYNC"
const val SYNC_FAILURE_KEY = "SYNC_FAILURE"
const val SYNC_FAILURE_EXCEPTION_KEY = "SYNC_FAILURE_EXCEPTION"
const val ACCOUNT_ID_KEY = "ACCOUNT_ID"
const val FEED_ID_KEY = "FEED_ID"
const val ITEM_ID_KEY = "ITEM_ID"
const val FOLDER_ID_KEY = "FOLDER_ID"
const val FEED_NAME_KEY = "FEED_NAME"
const val FEED_MAX_KEY = "FEED_MAX"

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</vector>

View File

@ -189,4 +189,5 @@
<string name="disable_battery_optimization_subtitle">Peut aider à éviter que le système n\'empêche la synchronisation d\'arrière-plan</string>
<string name="battery_optimization_already_disabled">L\'optimisation de la batterie est déjà optimisée pour cette appli</string>
<string name="opml_export_success">Export OPML réussi</string>
<string name="add_to_favorite">Ajouter aux favoris</string>
</resources>

View File

@ -198,4 +198,5 @@
<string name="disable_battery_optimization_subtitle">Can help with background synchronization not being killed by the system</string>
<string name="battery_optimization_already_disabled">Battery optimization already disabled for this app</string>
<string name="opml_export_success">OPML export success</string>
<string name="add_to_favorite">Add to favorites</string>
</resources>

View File

@ -22,9 +22,6 @@ abstract class ItemDao : BaseDao<Item> {
@RawQuery(observedEntities = [Item::class, ItemState::class])
abstract fun selectItemById(query: SupportSQLiteQuery): Flow<ItemWithFeed>
@Query("Select * From Item Where remoteId = :remoteId And feed_id = :feedId")
abstract suspend fun selectByRemoteId(remoteId: String, feedId: Int): Item
@Query("Update Item Set read = :read Where id = :itemId")
abstract suspend fun updateReadState(itemId: Int, read: Boolean)