Skip to content

Commit

Permalink
Add support for deleting read posts after a certain period (#173)
Browse files Browse the repository at this point in the history
* Add query to delete read posts that are before a certain period

* Add KotlinX datetime dependency to android app module

* Move `FeedsRefreshWorker` to background package

* Add data store preference for saving posts deletion period

* Add extension to calculate instant before a given period

* Add background worker on Android for posts clean up

* Add `postsDeletionPeriodImmediate` to settings repository

* Add background processing task on iOS for posts clean up

* Rename `RssRepository#deletePosts` to `deleteReadPosts`

* Add setting to configure read posts deletion period

* Rename `Period.YEAR` to `Period.ONE_YEAR`
  • Loading branch information
msasikanth authored Dec 3, 2023
1 parent cb698ac commit 4699066
Show file tree
Hide file tree
Showing 19 changed files with 383 additions and 20 deletions.
1 change: 1 addition & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,5 @@ dependencies {
implementation(libs.androidx.work)
implementation(libs.sentry)
coreLibraryDesugaring(libs.desugarJdk)
implementation(libs.kotlinx.datetime)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import androidx.work.ListenableWorker
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import dev.sasikanth.rss.reader.background.FeedsRefreshWorker
import dev.sasikanth.rss.reader.background.PostsCleanUpWorker
import dev.sasikanth.rss.reader.di.ApplicationComponent
import dev.sasikanth.rss.reader.di.create

Expand Down Expand Up @@ -51,12 +53,25 @@ class ReaderApplication : Application(), Configuration.Provider {
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker {
return FeedsRefreshWorker(
context = appContext,
workerParameters = workerParameters,
rssRepository = appComponent.rssRepository,
lastUpdatedAt = appComponent.lastUpdatedAt
)
return when (workerClassName) {
FeedsRefreshWorker::class.qualifiedName -> {
FeedsRefreshWorker(
context = appContext,
workerParameters = workerParameters,
rssRepository = appComponent.rssRepository,
lastUpdatedAt = appComponent.lastUpdatedAt
)
}
PostsCleanUpWorker::class.qualifiedName -> {
PostsCleanUpWorker(
context = appContext,
workerParameters = workerParameters,
rssRepository = appComponent.rssRepository,
settingsRepository = appComponent.settingsRepository
)
}
else -> throw IllegalArgumentException("Unknown background worker")
}
}
}
)
Expand All @@ -65,13 +80,22 @@ class ReaderApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
enqueuePeriodicFeedsRefresh()
enqueuePeriodicPostsCleanUp()

appComponent.initializers.forEach { it.initialize() }
}

private fun enqueuePeriodicPostsCleanUp() {
workManager.enqueueUniquePeriodicWork(
PostsCleanUpWorker.TAG,
ExistingPeriodicWorkPolicy.KEEP,
PostsCleanUpWorker.periodicRequest()
)
}

private fun enqueuePeriodicFeedsRefresh() {
workManager.enqueueUniquePeriodicWork(
FeedsRefreshWorker.UNIQUE_WORK_NAME,
FeedsRefreshWorker.TAG,
ExistingPeriodicWorkPolicy.KEEP,
FeedsRefreshWorker.periodicRequest()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.sasikanth.rss.reader
package dev.sasikanth.rss.reader.background

import android.content.Context
import androidx.work.Constraints
Expand All @@ -40,7 +40,7 @@ class FeedsRefreshWorker(

companion object {

const val UNIQUE_WORK_NAME = "REFRESH_FEEDS"
const val TAG = "REFRESH_FEEDS"

fun periodicRequest(): PeriodicWorkRequest {
val constraints =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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.background

import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters
import dev.sasikanth.rss.reader.repository.RssRepository
import dev.sasikanth.rss.reader.repository.SettingsRepository
import dev.sasikanth.rss.reader.utils.calculateInstantBeforePeriod
import io.sentry.Sentry
import java.time.Duration
import kotlin.coroutines.cancellation.CancellationException

class PostsCleanUpWorker(
context: Context,
workerParameters: WorkerParameters,
private val rssRepository: RssRepository,
private val settingsRepository: SettingsRepository
) : CoroutineWorker(context, workerParameters) {

companion object {

const val TAG = "POSTS_CLEAN_UP"

fun periodicRequest(): PeriodicWorkRequest {
val constraints =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()

return PeriodicWorkRequestBuilder<PostsCleanUpWorker>(repeatInterval = Duration.ofDays(1))
.setConstraints(constraints)
.build()
}
}

override suspend fun doWork(): Result {
try {
val postsDeletionPeriod = settingsRepository.postsDeletionPeriodImmediate()
rssRepository.deleteReadPosts(before = postsDeletionPeriod.calculateInstantBeforePeriod())
return Result.success()
} catch (e: CancellationException) {
// no-op
} catch (e: Exception) {
Sentry.captureException(e)
}

return Result.failure()
}
}
43 changes: 42 additions & 1 deletion iosApp/iosApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,53 @@ class AppDelegate: NSObject, UIApplicationDelegate {
initializer.initialize()
}

BGTaskScheduler.shared.register(forTaskWithIdentifier: "dev.sasikanth.reader.feeds_refresh", using: DispatchQueue.main) { (task) in
BGTaskScheduler.shared.register(forTaskWithIdentifier: "dev.sasikanth.reader.feeds_refresh", using: nil) { (task) in
self.refreshFeeds(task: task as! BGAppRefreshTask)
}

BGTaskScheduler.shared.register(forTaskWithIdentifier: "dev.sasikanth.reader.posts_cleanup", using: nil) { (task) in
self.cleanUpPosts(task: task as! BGProcessingTask)
}

return true
}

func scheduleCleanUpPosts(earliest: Date) {
let request = BGProcessingTaskRequest(identifier: "dev.sasikanth.reader.posts_cleanup")
request.earliestBeginDate = earliest

do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule posts cleanup \(error)")
}
}

func cleanUpPosts(task: BGProcessingTask) {
// Schedule next clean up task 24 hours in future
scheduleCleanUpPosts(earliest: Date(timeIntervalSinceNow: 60 * 60 * 24))

Task(priority: .background) {
do {
let postsDeletionPeriod = try await applicationComponent.settingsRepository.postsDeletionPeriodImmediate()
let before = postsDeletionPeriod.calculateInstantBeforePeriod()

try await applicationComponent.rssRepository.deleteReadPosts(before: before)
task.setTaskCompleted(success: true)
} catch {
let breadcrumb = Breadcrumb()
breadcrumb.level = .info
breadcrumb.category = "Background"

let scope = Scope()
scope.addBreadcrumb(breadcrumb)

SentrySDK.capture(error: error, scope: scope)

task.setTaskCompleted(success: false)
}
}
}

func scheduledRefreshFeeds() {
let request = BGAppRefreshTaskRequest(identifier: "dev.sasikanth.reader.feeds_refresh")
Expand Down
6 changes: 4 additions & 2 deletions iosApp/iosApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>dev.sasikanth.reader.posts_cleanup</string>
<string>dev.sasikanth.reader.feeds_refresh</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
Expand All @@ -30,6 +29,8 @@
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
Expand All @@ -40,6 +41,7 @@
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchScreen</key>
<dict/>
Expand Down
2 changes: 2 additions & 0 deletions iosApp/iosApp/iOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ struct iOSApp: App {
case .background:
LifecycleRegistryExtKt.stop(rootHolder.lifecycle)
appDelegate.scheduledRefreshFeeds()
// Run 5 minutes after the app goes into the background
appDelegate.scheduleCleanUpPosts(earliest: Date(timeIntervalSinceNow: 5 * 60))

case .inactive: LifecycleRegistryExtKt.pause(rootHolder.lifecycle)
case .active: LifecycleRegistryExtKt.resume(rootHolder.lifecycle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ val EnTwineStrings =
settingsOpmlImporting = { progress -> "Importing.. $progress%" },
settingsOpmlExporting = { progress -> "Exporting.. $progress%" },
settingsOpmlCancel = "Cancel",
settingsPostsDeletionPeriodTitle = "Delete read posts",
settingsPostsDeletionPeriodSubtitle =
"App will automatically delete read posts that are older than the selected period",
settingsPostsDeletionPeriodOneWeek = "1 week",
settingsPostsDeletionPeriodOneMonth = "1 month",
settingsPostsDeletionPeriodThreeMonths = "3 months",
settingsPostsDeletionPeriodSixMonths = "6 months",
settingsPostsDeletionPeriodOneYear = "1 year",
feeds = "Feeds",
editFeeds = "Edit feeds",
comments = "Comments",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ data class TwineStrings(
val settingsOpmlImporting: (Int) -> String,
val settingsOpmlExporting: (Int) -> String,
val settingsOpmlCancel: String,
val settingsPostsDeletionPeriodTitle: String,
val settingsPostsDeletionPeriodSubtitle: String,
val settingsPostsDeletionPeriodOneWeek: String,
val settingsPostsDeletionPeriodOneMonth: String,
val settingsPostsDeletionPeriodThreeMonths: String,
val settingsPostsDeletionPeriodSixMonths: String,
val settingsPostsDeletionPeriodOneYear: String,
val feeds: String,
val editFeeds: String,
val comments: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.os.Build
import dev.sasikanth.rss.reader.app.AppInfo
import dev.sasikanth.rss.reader.di.scopes.AppScope
import dev.sasikanth.rss.reader.repository.RssRepository
import dev.sasikanth.rss.reader.repository.SettingsRepository
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides

Expand All @@ -30,6 +31,8 @@ abstract class ApplicationComponent(@get:Provides val context: Context) :

abstract val rssRepository: RssRepository

abstract val settingsRepository: SettingsRepository

@Provides
@AppScope
fun providesAppInfo(context: Context): AppInfo {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ class RssRepository(
return feedQueries.numberOfFeeds().asFlow().mapToOne(ioDispatcher)
}

suspend fun deleteReadPosts(before: Instant) {
withContext(ioDispatcher) { postQueries.deleteReadPosts(before = before) }
}

private fun sanitizeSearchQuery(searchQuery: String): String {
return searchQuery.replace(Regex.fromLiteral("\""), "\"\"").run { "\"$this\"" }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import dev.sasikanth.rss.reader.di.scopes.AppScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import me.tatarka.inject.annotations.Inject

Expand All @@ -32,6 +33,7 @@ class SettingsRepository(private val dataStore: DataStore<Preferences>) {
private val browserTypeKey = stringPreferencesKey("pref_browser_type")
private val enableFeaturedItemBlurKey = booleanPreferencesKey("pref_enable_blur")
private val showUnreadPostsCountKey = booleanPreferencesKey("show_unread_posts_count")
private val postsDeletionPeriodKey = stringPreferencesKey("posts_cleanup_frequency")

val browserType: Flow<BrowserType> =
dataStore.data.map { preferences ->
Expand All @@ -44,6 +46,15 @@ class SettingsRepository(private val dataStore: DataStore<Preferences>) {
val showUnreadPostsCount: Flow<Boolean> =
dataStore.data.map { preferences -> preferences[showUnreadPostsCountKey] ?: true }

val postsDeletionPeriod: Flow<Period> =
dataStore.data.map { preferences ->
mapToPostsDeletionPeriod(preferences[postsDeletionPeriodKey]) ?: Period.ONE_MONTH
}

suspend fun postsDeletionPeriodImmediate(): Period {
return postsDeletionPeriod.first()
}

suspend fun updateBrowserType(browserType: BrowserType) {
dataStore.edit { preferences -> preferences[browserTypeKey] = browserType.name }
}
Expand All @@ -56,13 +67,30 @@ class SettingsRepository(private val dataStore: DataStore<Preferences>) {
dataStore.edit { preferences -> preferences[showUnreadPostsCountKey] = value }
}

suspend fun updatePostsDeletionPeriod(postsDeletionPeriod: Period) {
dataStore.edit { preferences -> preferences[postsDeletionPeriodKey] = postsDeletionPeriod.name }
}

private fun mapToBrowserType(pref: String?): BrowserType? {
if (pref.isNullOrBlank()) return null
return BrowserType.valueOf(pref)
}

private fun mapToPostsDeletionPeriod(pref: String?): Period? {
if (pref.isNullOrBlank()) return null
return Period.valueOf(pref)
}
}

enum class BrowserType {
Default,
InApp
}

enum class Period {
ONE_WEEK,
ONE_MONTH,
THREE_MONTHS,
SIX_MONTHS,
ONE_YEAR
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package dev.sasikanth.rss.reader.settings

import dev.sasikanth.rss.reader.repository.BrowserType
import dev.sasikanth.rss.reader.repository.Period

sealed interface SettingsEvent {

Expand All @@ -34,4 +35,6 @@ sealed interface SettingsEvent {
data object ExportOpmlClicked : SettingsEvent

data object CancelOpmlImportOrExport : SettingsEvent

data class PostsDeletionPeriodChanged(val newPeriod: Period) : SettingsEvent
}
Loading

0 comments on commit 4699066

Please sign in to comment.