From 73ccc6471746e6d0d7825ec183e07a1981379962 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Mon, 25 Sep 2023 09:58:43 +0530 Subject: [PATCH 1/3] Refresh feeds based on when they are last updated when app is opened At this point we are using a unified last updated at, but we can break it down for individual feeds if we want to in future. --- .../sasikanth/rss/reader/app/AppPresenter.kt | 29 +++++++++- .../rss/reader/refresh/LastUpdatedAt.kt | 58 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/refresh/LastUpdatedAt.kt diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt index b5ea77a49..3635174d2 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt @@ -24,11 +24,14 @@ import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.arkivanov.essenty.lifecycle.doOnStart import com.arkivanov.essenty.parcelable.Parcelable import com.arkivanov.essenty.parcelable.Parcelize import dev.sasikanth.rss.reader.bookmarks.BookmarksPresenter import dev.sasikanth.rss.reader.di.scopes.ActivityScope import dev.sasikanth.rss.reader.home.HomePresenter +import dev.sasikanth.rss.reader.refresh.LastUpdatedAt +import dev.sasikanth.rss.reader.repository.RssRepository import dev.sasikanth.rss.reader.repository.SettingsRepository import dev.sasikanth.rss.reader.search.SearchPresenter import dev.sasikanth.rss.reader.settings.SettingsPresenter @@ -43,6 +46,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Inject private typealias HomePresenterFactory = @@ -77,14 +81,18 @@ class AppPresenter( private val homePresenter: HomePresenterFactory, private val searchPresenter: SearchPresentFactory, private val bookmarksPresenter: BookmarkPresenterFactory, - private val settingsPresenter: SettingsPresenterFactory + private val settingsPresenter: SettingsPresenterFactory, + private val lastUpdatedAt: LastUpdatedAt, + private val rssRepository: RssRepository ) : ComponentContext by componentContext { private val presenterInstance = instanceKeeper.getOrCreate { PresenterInstance( dispatchersProvider = dispatchersProvider, - settingsRepository = settingsRepository + settingsRepository = settingsRepository, + lastUpdatedAt = lastUpdatedAt, + rssRepository = rssRepository ) } @@ -99,6 +107,10 @@ class AppPresenter( childFactory = ::createScreen, ) + init { + lifecycle.doOnStart { presenterInstance.refreshFeedsIfExpired() } + } + fun onBackClicked() { navigation.pop() } @@ -129,7 +141,9 @@ class AppPresenter( private class PresenterInstance( dispatchersProvider: DispatchersProvider, - private val settingsRepository: SettingsRepository + settingsRepository: SettingsRepository, + private val lastUpdatedAt: LastUpdatedAt, + private val rssRepository: RssRepository ) : InstanceKeeper.Instance { private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.main) @@ -148,6 +162,15 @@ class AppPresenter( .launchIn(coroutineScope) } + fun refreshFeedsIfExpired() { + coroutineScope.launch { + if (lastUpdatedAt.hasExpired()) { + rssRepository.updateFeeds() + lastUpdatedAt.refresh() + } + } + } + override fun onDestroy() { coroutineScope.cancel() } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/refresh/LastUpdatedAt.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/refresh/LastUpdatedAt.kt new file mode 100644 index 000000000..4ae8a9898 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/refresh/LastUpdatedAt.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.refresh + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.sasikanth.rss.reader.di.scopes.AppScope +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Clock +import kotlinx.datetime.toInstant +import me.tatarka.inject.annotations.Inject + +@Inject +@AppScope +class LastUpdatedAt(private val dataStore: DataStore) { + + companion object { + private val UPDATE_DURATION = 60.minutes + } + + private val lastUpdatedAtKey = stringPreferencesKey("pref_last_updated_at") + + suspend fun refresh() { + dataStore.edit { preferences -> preferences[lastUpdatedAtKey] = Clock.System.now().toString() } + } + + suspend fun hasExpired(): Boolean { + val lastUpdatedAt = fetchLastUpdatedAt() ?: return true + val currentTime = Clock.System.now() + val lastUpdateDuration = currentTime - lastUpdatedAt + + return lastUpdateDuration > UPDATE_DURATION + } + + private suspend fun fetchLastUpdatedAt() = + dataStore.data + .map { preferences -> preferences[lastUpdatedAtKey] ?: return@map null } + .first() + ?.toInstant() +} From ee62938f239ab24eadea70b9c824c7c7cd67e723 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Thu, 5 Oct 2023 13:36:02 +0530 Subject: [PATCH 2/3] Update last updated at after running refresh worker on Android --- .../kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt | 5 ++++- .../kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt | 7 ++++++- .../sasikanth/rss/reader/di/SharedApplicationComponent.kt | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt index 2b9f2878d..c4becc204 100644 --- a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt +++ b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt @@ -22,6 +22,7 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkerParameters +import dev.sasikanth.rss.reader.refresh.LastUpdatedAt import dev.sasikanth.rss.reader.repository.RssRepository import io.sentry.kotlin.multiplatform.Sentry import java.lang.Exception @@ -30,7 +31,8 @@ import java.time.Duration class FeedsRefreshWorker( context: Context, workerParameters: WorkerParameters, - private val rssRepository: RssRepository + private val rssRepository: RssRepository, + private val lastUpdatedAt: LastUpdatedAt ) : CoroutineWorker(context, workerParameters) { companion object { @@ -53,6 +55,7 @@ class FeedsRefreshWorker( override suspend fun doWork(): Result { return try { rssRepository.updateFeeds() + lastUpdatedAt.refresh() Result.success() } catch (e: Exception) { Sentry.captureException(e) diff --git a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt index 16f2e25a0..15b9fa2ec 100644 --- a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt +++ b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt @@ -59,7 +59,12 @@ class ReaderApplication : Application(), Configuration.Provider { workerClassName: String, workerParameters: WorkerParameters ): ListenableWorker { - return FeedsRefreshWorker(appContext, workerParameters, appComponent.rssRepository) + return FeedsRefreshWorker( + context = appContext, + workerParameters = workerParameters, + rssRepository = appComponent.rssRepository, + lastUpdatedAt = appComponent.lastUpdatedAt + ) } } ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt index bc84f4bae..28a024d81 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt @@ -19,6 +19,7 @@ import dev.sasikanth.rss.reader.components.image.ImageLoader import dev.sasikanth.rss.reader.di.scopes.AppScope import dev.sasikanth.rss.reader.initializers.Initializer import dev.sasikanth.rss.reader.network.NetworkComponent +import dev.sasikanth.rss.reader.refresh.LastUpdatedAt import dev.sasikanth.rss.reader.sentry.SentryComponent import dev.sasikanth.rss.reader.utils.DefaultDispatchersProvider import dev.sasikanth.rss.reader.utils.DispatchersProvider @@ -31,5 +32,7 @@ abstract class SharedApplicationComponent : abstract val initializers: Set + abstract val lastUpdatedAt: LastUpdatedAt + @Provides @AppScope fun DefaultDispatchersProvider.bind(): DispatchersProvider = this } From 1bbab7c8d8bcba52998e53f1e100f36d0b855273 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Thu, 5 Oct 2023 14:24:23 +0530 Subject: [PATCH 3/3] Update last updated at after doing background refresh on iOS --- iosApp/iosApp/AppDelegate.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 221cb6d7c..b2479975a 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -44,6 +44,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { func refreshFeeds(task: BGAppRefreshTask) { scheduledRefreshFeeds() applicationComponent.rssRepository.updateFeeds { error in + if error != nil { + self.applicationComponent.lastUpdatedAt.refresh { error in + // no-op + } + } task.setTaskCompleted(success: error == nil) } }