Add background worker for synchronization

This commit is contained in:
Shinokuni 2024-06-02 00:11:23 +02:00
parent bc3015ab95
commit 303d7b6786
9 changed files with 188 additions and 27 deletions

View File

@ -93,4 +93,5 @@ dependencies {
coreLibraryDesugaring(libs.jdk.desugar) coreLibraryDesugaring(libs.jdk.desugar)
implementation(libs.encrypted.preferences) implementation(libs.encrypted.preferences)
implementation(libs.work.manager)
} }

View File

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".ReadropsApp" android:name=".ReadropsApp"

View File

@ -1,6 +1,9 @@
package com.readrops.app.compose package com.readrops.app.compose
import android.app.Application import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import com.readrops.api.apiModule import com.readrops.api.apiModule
@ -23,6 +26,8 @@ open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
modules(apiModule, dbModule, composeAppModule) modules(apiModule, dbModule, composeAppModule)
} }
createNotificationChannels()
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
@ -31,4 +36,33 @@ open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
.crossfade(true) .crossfade(true)
.build() .build()
} }
// TODO check each channel usefulness
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val feedsColorsChannel = NotificationChannel(FEEDS_COLORS_CHANNEL_ID,
getString(R.string.feeds_colors), NotificationManager.IMPORTANCE_DEFAULT)
feedsColorsChannel.description = getString(R.string.get_feeds_colors)
val opmlExportChannel = NotificationChannel(OPML_EXPORT_CHANNEL_ID,
getString(R.string.opml_export), NotificationManager.IMPORTANCE_DEFAULT)
opmlExportChannel.description = getString(R.string.opml_export_description)
val syncChannel = NotificationChannel(SYNC_CHANNEL_ID,
getString(R.string.auto_synchro), NotificationManager.IMPORTANCE_LOW)
syncChannel.description = getString(R.string.account_synchro)
val manager = getSystemService(NotificationManager::class.java)!!
manager.createNotificationChannel(feedsColorsChannel)
manager.createNotificationChannel(opmlExportChannel)
manager.createNotificationChannel(syncChannel)
}
}
companion object {
const val FEEDS_COLORS_CHANNEL_ID = "feedsColorsChannel"
const val OPML_EXPORT_CHANNEL_ID = "opmlExportChannel"
const val SYNC_CHANNEL_ID = "syncChannel"
}
} }

View File

@ -0,0 +1,122 @@
package com.readrops.app.compose.sync
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.readrops.api.services.SyncResult
import com.readrops.app.compose.R
import com.readrops.app.compose.ReadropsApp
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.app.compose.util.FeedColors
import com.readrops.db.Database
import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
class SyncWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params), KoinComponent {
private val notificationManager = NotificationManagerCompat.from(appContext)
private val database = get<Database>()
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
val sharedPreferences = get<SharedPreferences>()
var result = Result.success(workDataOf(END_SYNC_KEY to true))
try {
require(notificationManager.areNotificationsEnabled())
val notificationBuilder =
NotificationCompat.Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID)
.setProgress(0, 0, true)
.setSmallIcon(R.drawable.ic_notifications) // TODO use better icon
.setPriority(NotificationCompat.PRIORITY_DEFAULT) // for Android 7.1 and earlier
.setStyle(NotificationCompat.BigTextStyle())
.setOnlyAlertOnce(true)
val accounts = database.newAccountDao().selectAllAccounts().first()
for (account in accounts) {
account.login = sharedPreferences.getString(account.loginKey, null)
account.password = sharedPreferences.getString(account.passwordKey, null)
val repository = get<BaseRepository> { parametersOf(account) }
notificationBuilder.setContentTitle(
applicationContext.resources.getString(
R.string.updating_account,
account.accountName
)
)
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
val syncResult = repository.synchronize()
fetchFeedColors(syncResult, notificationBuilder)
}
} catch (e: Exception) {
Log.e(TAG, "${e.message}")
result = Result.failure(workDataOf(SYNC_FAILURE_KEY to true))
} finally {
notificationManager.cancel(SYNC_NOTIFICATION_ID)
}
return result
}
@SuppressLint("MissingPermission")
private suspend fun fetchFeedColors(syncResult: SyncResult, notificationBuilder: NotificationCompat.Builder) {
notificationBuilder.setContentTitle(applicationContext.getString(R.string.get_feeds_colors))
for ((index, feedId) in syncResult.newFeedIds.withIndex()) {
val feedName = syncResult.feeds.first { it.id == feedId.toInt() }.name
notificationBuilder.setContentText(feedName)
.setProgress(syncResult.newFeedIds.size, index + 1, false)
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
val color = try {
FeedColors.getFeedColor(syncResult.feeds.first { it.id == feedId.toInt() }.iconUrl!!)
} catch (e: Exception) {
Log.e(TAG, "${e.message}")
0
}
database.newFeedDao().updateFeedColor(feedId.toInt(), color)
}
}
companion object {
private val TAG: String = SyncWorker::class.java.simpleName
private const val SYNC_NOTIFICATION_ID = 2
private const val SYNC_RESULT_NOTIFICATION_ID = 3
const val END_SYNC_KEY = "END_SYNC"
const val SYNC_FAILURE_KEY = "SYNC_FAILURE"
suspend fun startNow(context: Context, onUpdate: (WorkInfo) -> Unit) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag(TAG)
.build()
WorkManager.getInstance(context).apply {
enqueue(request)
getWorkInfoByIdFlow(request.id)
.collect { workInfo -> onUpdate(workInfo) }
}
}
}
}

View File

@ -2,7 +2,6 @@ package com.readrops.app.compose.timelime
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
@ -12,7 +11,7 @@ import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.compose.base.TabScreenModel import com.readrops.app.compose.base.TabScreenModel
import com.readrops.app.compose.repositories.ErrorResult import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.repositories.GetFoldersWithFeeds import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.app.compose.util.FeedColors import com.readrops.app.compose.sync.SyncWorker
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder import com.readrops.db.entities.Folder
@ -102,33 +101,16 @@ class TimelineScreenModel(
} }
} }
fun refreshTimeline() { fun refreshTimeline(context: Context) {
screenModelScope.launch(dispatcher) { screenModelScope.launch(dispatcher) {
if (currentAccount!!.isLocal) { if (currentAccount!!.isLocal) {
refreshLocalAccount() refreshLocalAccount()
} else { } else {
_timelineState.update { it.copy(isRefreshing = true) } _timelineState.update { it.copy(isRefreshing = true) }
try { SyncWorker.startNow(context) { data ->
val result = repository!!.synchronize() when {
data.outputData.getBoolean(SyncWorker.END_SYNC_KEY, false) -> {
// TODO put this in a foreground worker
for (feedId in result.newFeedIds) {
val color = try {
FeedColors.getFeedColor(result.feeds.first { it.id == feedId.toInt() }.iconUrl!!)
} catch (e: Exception) {
0
}
database.newFeedDao().updateFeedColor(feedId.toInt(), color)
}
} catch (e: Exception) {
Log.e("TimelineScreenModel", "${e.message}")
_timelineState.update { it.copy(syncError = e, isRefreshing = false) }
return@launch
}
_timelineState.update { _timelineState.update {
it.copy( it.copy(
isRefreshing = false, isRefreshing = false,
@ -136,6 +118,18 @@ class TimelineScreenModel(
) )
} }
} }
data.outputData.getBoolean(SyncWorker.SYNC_FAILURE_KEY, false) -> {
_timelineState.update {
it.copy(
syncError = Exception(), // TODO see how to improve this
isRefreshing = false
)
}
}
}
}
}
} }
} }
@ -340,6 +334,10 @@ class TimelineScreenModel(
fun resetEndSynchronizing() { fun resetEndSynchronizing() {
_timelineState.update { it.copy(endSynchronizing = false) } _timelineState.update { it.copy(endSynchronizing = false) }
} }
fun resetSyncError() {
_timelineState.update { it.copy(syncError = null) }
}
} }
@Stable @Stable

View File

@ -98,7 +98,7 @@ object TimelineTab : Tab {
// so we need to listen to the internal state change to trigger the refresh // so we need to listen to the internal state change to trigger the refresh
LaunchedEffect(pullToRefreshState.isRefreshing) { LaunchedEffect(pullToRefreshState.isRefreshing) {
if (pullToRefreshState.isRefreshing && !state.isRefreshing) { if (pullToRefreshState.isRefreshing && !state.isRefreshing) {
viewModel.refreshTimeline() viewModel.refreshTimeline(context)
} }
} }
@ -158,6 +158,7 @@ object TimelineTab : Tab {
LaunchedEffect(state.syncError) { LaunchedEffect(state.syncError) {
if (state.syncError != null) { if (state.syncError != null) {
snackbarHostState.showSnackbar(ErrorMessage.get(state.syncError!!, context)) snackbarHostState.showSnackbar(ErrorMessage.get(state.syncError!!, context))
viewModel.resetSyncError()
} }
} }
@ -272,7 +273,7 @@ object TimelineTab : Tab {
} }
IconButton( IconButton(
onClick = { viewModel.refreshTimeline() } onClick = { viewModel.refreshTimeline(context) }
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_sync), painter = painterResource(id = R.drawable.ic_sync),

View File

@ -165,4 +165,5 @@
<string name="http_error_5XX">Erreur HTTP %1$d, erreur serveur</string> <string name="http_error_5XX">Erreur HTTP %1$d, erreur serveur</string>
<string name="http_error">Erreur HTTP %1$d</string> <string name="http_error">Erreur HTTP %1$d</string>
<string name="feed_url_read_only">L\'API de FreshRSS ne permet pas de modifier l\'URL</string> <string name="feed_url_read_only">L\'API de FreshRSS ne permet pas de modifier l\'URL</string>
<string name="updating_account">Mise à jour du compte %1$s</string>
</resources> </resources>

View File

@ -171,4 +171,5 @@
<string name="http_error_5XX">HTTP error %1$d, server error</string> <string name="http_error_5XX">HTTP error %1$d, server error</string>
<string name="http_error">HTTP error %1$d</string> <string name="http_error">HTTP error %1$d</string>
<string name="feed_url_read_only">FreshRSS API doesn\'t support Feed URL modification</string> <string name="feed_url_read_only">FreshRSS API doesn\'t support Feed URL modification</string>
<string name="updating_account">Updating %1$s account</string>
</resources> </resources>

View File

@ -67,6 +67,8 @@ jdk-desugar = "com.android.tools:desugar_jdk_libs:2.0.4"
encrypted-preferences = "androidx.security:security-crypto:1.1.0-alpha06" encrypted-preferences = "androidx.security:security-crypto:1.1.0-alpha06"
work-manager = "androidx.work:work-runtime-ktx:2.9.0"
[bundles] [bundles]
compose = ["bom", "compose-foundation", "compose-runtime", "compose-animation", compose = ["bom", "compose-foundation", "compose-runtime", "compose-animation",
"compose-ui", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3"] "compose-ui", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3"]