Skip to content

Commit

Permalink
Schedule deletion of items older than 30 days
Browse files Browse the repository at this point in the history
  • Loading branch information
CrisBarreiro committed Apr 23, 2024
1 parent c391a17 commit e113df0
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.duckduckgo.history.impl.scheduleddeletion

import android.content.Context
import androidx.work.ListenableWorker.Result
import androidx.work.WorkManager
import androidx.work.testing.TestListenableWorkerBuilder
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.history.impl.InternalNavigationHistory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify

class RealHistoryDeletionWorkerTest {

@get:Rule var coroutineRule = CoroutineTestRule()

private val workManager: WorkManager = mock()
private val mockHistory: InternalNavigationHistory = mock()
private val appCoroutineScope: CoroutineScope = TestScope()

private val historyDeletionWorker =
HistoryDeletionWorker(workManager, mockHistory, appCoroutineScope)

private lateinit var context: Context

@Before
fun setup() {
context = mock()
}

@Test
fun whenDoWorkThenCallCleanOldEntriesAndReturnSuccess() {
appCoroutineScope.launch {
val worker =
TestListenableWorkerBuilder<RealHistoryDeletionWorker>(context = context).build()
worker.historyDeletionWorker = historyDeletionWorker

val result = worker.doWork()

verify(mockHistory, never()).clearOldEntries()
Assert.assertEquals(result, Result.success())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@ package com.duckduckgo.common.utils
import android.os.SystemClock
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import java.time.LocalDateTime
import javax.inject.Inject

interface CurrentTimeProvider {
fun elapsedRealtime(): Long

fun currentTimeMillis(): Long

fun localDateTimeNow(): LocalDateTime
}

@ContributesBinding(AppScope::class)
class RealCurrentTimeProvider @Inject constructor() : CurrentTimeProvider {
override fun elapsedRealtime(): Long = SystemClock.elapsedRealtime()

override fun currentTimeMillis(): Long = System.currentTimeMillis()

override fun localDateTimeNow(): LocalDateTime = LocalDateTime.now()
}
5 changes: 5 additions & 0 deletions history/history-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ dependencies {
implementation AndroidX.room.rxJava2
implementation AndroidX.room.ktx

implementation AndroidX.work.runtimeKtx

implementation JakeWharton.timber

testImplementation AndroidX.work.testing
testImplementation AndroidX.test.ext.junit
testImplementation Testing.junit4
testImplementation "org.mockito.kotlin:mockito-kotlin:_"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ import java.time.LocalDateTime
interface HistoryRepository {
fun getHistoryObservable(): Single<List<HistoryEntry>>

fun saveToHistory(
suspend fun saveToHistory(
url: String,
title: String?,
query: String?,
isSerp: Boolean,
)

suspend fun clearHistory()
suspend fun clearEntriesOlderThan(minusDays: LocalDateTime)
}

class RealHistoryRepository(
Expand All @@ -44,7 +45,7 @@ class RealHistoryRepository(
return Single.just(cachedHistoryEntries ?: fetchAndCacheHistoryEntries())
}

override fun saveToHistory(
override suspend fun saveToHistory(
url: String,
title: String?,
query: String?,
Expand All @@ -65,6 +66,12 @@ class RealHistoryRepository(
historyDao.deleteAll()
fetchAndCacheHistoryEntries()
}

override suspend fun clearEntriesOlderThan(minusDays: LocalDateTime) {
cachedHistoryEntries = null
historyDao.deleteEntriesOlderThan(minusDays)
fetchAndCacheHistoryEntries()
}
private fun fetchAndCacheHistoryEntries(): List<HistoryEntry> {
return historyDao.getHistoryEntriesWithVisits().mapNotNull { it.toHistoryEntry() }.also {
cachedHistoryEntries = it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.duckduckgo.history.impl

import com.duckduckgo.app.browser.DuckDuckGoUrlDetector
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.history.api.HistoryEntry
import com.duckduckgo.history.api.NavigationHistory
Expand All @@ -27,12 +28,18 @@ import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

@ContributesBinding(AppScope::class)
interface InternalNavigationHistory : NavigationHistory {
suspend fun clearOldEntries()
}

@ContributesBinding(AppScope::class, boundType = NavigationHistory::class)
@ContributesBinding(AppScope::class, boundType = InternalNavigationHistory::class)
class RealNavigationHistory @Inject constructor(
private val historyRepository: HistoryRepository,
private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector,
private val currentTimeProvider: CurrentTimeProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : NavigationHistory {
) : InternalNavigationHistory {
override fun saveToHistory(
url: String,
title: String?,
Expand All @@ -52,4 +59,8 @@ class RealNavigationHistory @Inject constructor(
override suspend fun clearHistory() {
historyRepository.clearHistory()
}

override suspend fun clearOldEntries() {
historyRepository.clearEntriesOlderThan(currentTimeProvider.localDateTimeNow().minusDays(30))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* 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 com.duckduckgo.history.impl.scheduleddeletion

import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.duckduckgo.anvil.annotations.ContributesWorker
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.history.impl.InternalNavigationHistory
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeUnit.DAYS
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber

@ContributesMultibinding(
scope = AppScope::class,
boundType = MainProcessLifecycleObserver::class,
)
@SingleInstanceIn(AppScope::class)
class HistoryDeletionWorker @Inject constructor(
private val workManager: WorkManager,
private val history: InternalNavigationHistory,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : MainProcessLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
scheduleWorker(workManager)
}

fun clearOldHistoryEntries() {
appCoroutineScope.launch {
history.clearOldEntries()
}
}

companion object {
private const val WORKER_DELETE_HISTORY = "com.duckduckgo.history.delete.worker"

private fun scheduleWorker(workManager: WorkManager) {
Timber.v("Scheduling the HistoryDeletionWorker")

val request = PeriodicWorkRequestBuilder<RealHistoryDeletionWorker>(repeatInterval = 1L, repeatIntervalTimeUnit = DAYS)
.addTag(WORKER_DELETE_HISTORY)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()

workManager.enqueueUniquePeriodicWork(WORKER_DELETE_HISTORY, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}

@ContributesWorker(AppScope::class)
class RealHistoryDeletionWorker(
val context: Context,
parameters: WorkerParameters,
) : CoroutineWorker(context, parameters) {

@Inject
lateinit var historyDeletionWorker: HistoryDeletionWorker

override suspend fun doWork(): Result {
Timber.v("Deleting old history entries")

historyDeletionWorker.clearOldHistoryEntries()

return Result.success()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.duckduckgo.history.impl.store

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
Expand All @@ -31,7 +32,7 @@ interface HistoryDao {
fun getHistoryEntriesWithVisits(): List<HistoryEntryWithVisits>

@Transaction
fun updateOrInsertVisit(url: String, title: String, query: String?, isSerp: Boolean, date: LocalDateTime) {
suspend fun updateOrInsertVisit(url: String, title: String, query: String?, isSerp: Boolean, date: LocalDateTime) {
val existingHistoryEntry = getHistoryEntryByUrl(url)

if (existingHistoryEntry != null) {
Expand All @@ -50,11 +51,34 @@ interface HistoryDao {
fun getHistoryEntryByUrl(url: String): HistoryEntryEntity?

@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertHistoryEntry(historyEntry: HistoryEntryEntity): Long
suspend fun insertHistoryEntry(historyEntry: HistoryEntryEntity): Long

@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertVisit(visit: VisitEntity)
suspend fun insertVisit(visit: VisitEntity)

@Query("DELETE FROM history_entries")
fun deleteAll()
suspend fun deleteAll()

@Delete
suspend fun delete(entryEntity: HistoryEntryEntity)

@Delete
suspend fun delete(entities: List<HistoryEntryEntity>)

@Query("DELETE FROM visits_list WHERE timestamp < :timestamp")
suspend fun deleteOldVisitsByTimestamp(timestamp: String)

@Query(
"SELECT * FROM history_entries WHERE id NOT IN (SELECT DISTINCT historyEntryId FROM visits_list) OR id IN (SELECT historyEntryId FROM visits_list WHERE timestamp < :timestamp)",
)
suspend fun getEntriesWithNoVisitsOrOlderThanTimestamp(timestamp: String): List<HistoryEntryWithVisits>

@Transaction
suspend fun deleteEntriesOlderThan(dateTime: LocalDateTime) {
val timestamp = DatabaseDateFormatter.timestamp(dateTime)

deleteOldVisitsByTimestamp(timestamp)

delete(getEntriesWithNoVisitsOrOlderThanTimestamp(timestamp).map { it.historyEntry })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
package com.duckduckgo.history.impl

import com.duckduckgo.app.browser.DuckDuckGoUrlDetector
import com.duckduckgo.common.utils.CurrentTimeProvider
import java.time.LocalDateTime
import java.time.Month.JANUARY
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import org.junit.Test
Expand All @@ -30,9 +33,10 @@ class HistoryTest {

private val mockHistoryRepository: HistoryRepository = mock()
private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock()
private val mockCurrentTimeProvider: CurrentTimeProvider = mock()
private val testScope = TestScope()

val testee = RealNavigationHistory(mockHistoryRepository, mockDuckDuckGoUrlDetector, testScope)
val testee = RealNavigationHistory(mockHistoryRepository, mockDuckDuckGoUrlDetector, mockCurrentTimeProvider, testScope)

@Test
fun whenUrlIsSerpThenSaveToHistoryWithQueryAndSerpIsTrue() {
Expand Down Expand Up @@ -68,4 +72,13 @@ class HistoryTest {
verify(mockHistoryRepository).saveToHistory(eq("url"), eq("title"), eq(null), eq(false))
}
}

@Test
fun whenClearOldEntriesThenDeleteOldEntriesIsCalledWith30Days() {
whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(LocalDateTime.of(2000, JANUARY, 1, 0, 0))
testScope.launch {
testee.clearOldEntries()
verify(mockHistoryRepository.clearEntriesOlderThan(eq(mockCurrentTimeProvider.localDateTimeNow().minusDays(30))))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,36 @@ class HistoryDaoTest {
Assert.assertEquals(1, historyEntriesWithVisits.count())
Assert.assertEquals(2, historyEntriesWithVisits.first().visits.count())
}

@Test
fun whenDeleteOldItemsWithNoOldEnoughItemsThenNothingIsDeleted() {
val insertDate = LocalDateTime.of(2000, JANUARY, 1, 0, 0)
historyDao.updateOrInsertVisit("url", "title", "query", false, insertDate)
historyDao.updateOrInsertVisit("url2", "title2", "query2", false, insertDate)
historyDao.deleteEntriesOlderThan(insertDate.minusMinutes(1))
val historyEntriesWithVisits = historyDao.getHistoryEntriesWithVisits()
Assert.assertEquals(2, historyEntriesWithVisits.count())
Assert.assertEquals(1, historyEntriesWithVisits.first().visits.count())
}

@Test
fun whenDeleteOldItemsWithNoOldEnoughItemsThenTheyAreDeleted() {
val insertDate = LocalDateTime.of(2000, JANUARY, 1, 0, 0)
historyDao.updateOrInsertVisit("url", "title", "query", false, insertDate)
historyDao.updateOrInsertVisit("url2", "title2", "query2", false, insertDate)
historyDao.deleteEntriesOlderThan(insertDate.plusMinutes(1))
val historyEntriesWithVisits = historyDao.getHistoryEntriesWithVisits()
Assert.assertEquals(0, historyEntriesWithVisits.count())
}

@Test
fun whenDeleteOldItemsWithVisitsBothBeforeAndAfterDeletionTimestampThenDeleteOnlyOldVisitsButNotEntries() {
val insertDate = LocalDateTime.of(2000, JANUARY, 1, 0, 0)
historyDao.updateOrInsertVisit("url", "title", "query", false, insertDate)
historyDao.updateOrInsertVisit("url2", "title2", "query2", false, insertDate.plusMinutes(5))
historyDao.deleteEntriesOlderThan(insertDate.plusMinutes(1))
val historyEntriesWithVisits = historyDao.getHistoryEntriesWithVisits()
Assert.assertEquals(1, historyEntriesWithVisits.count())
Assert.assertEquals(1, historyEntriesWithVisits.first().visits.count())
}
}

0 comments on commit e113df0

Please sign in to comment.