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 May 2, 2024
1 parent 67be27a commit 4e9c30b
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 4 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 @@ -36,6 +36,8 @@ interface HistoryRepository {
)

suspend fun clearHistory()

suspend fun clearEntriesOlderThan(minusDays: LocalDateTime)
}

class RealHistoryRepository(
Expand Down Expand Up @@ -94,4 +96,10 @@ class RealHistoryRepository(
.mapNotNull { it.toHistoryEntry() }
.also { cachedHistoryEntries = it }
}

override suspend fun clearEntriesOlderThan(minusDays: LocalDateTime) {
cachedHistoryEntries = null
historyDao.deleteEntriesOlderThan(minusDays)
fetchAndCacheHistoryEntries()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,25 @@
package com.duckduckgo.history.impl

import com.duckduckgo.app.browser.DuckDuckGoUrlDetector
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.history.api.HistoryEntry
import com.duckduckgo.history.api.NavigationHistory
import com.squareup.anvil.annotations.ContributesBinding
import io.reactivex.Single
import javax.inject.Inject

@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,
) : NavigationHistory {
private val currentTimeProvider: CurrentTimeProvider,
) : InternalNavigationHistory {
override suspend fun saveToHistory(
url: String,
title: String?,
Expand All @@ -46,4 +53,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 Down Expand Up @@ -57,4 +58,28 @@ interface HistoryDao {

@Query("DELETE FROM history_entries")
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,11 @@
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 kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
Expand All @@ -29,8 +34,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)
val testee = RealNavigationHistory(mockHistoryRepository, mockDuckDuckGoUrlDetector, mockCurrentTimeProvider)

@Test
fun whenUrlIsSerpThenSaveToHistoryWithQueryAndSerpIsTrue() {
Expand Down Expand Up @@ -66,4 +73,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 @@ -48,8 +48,8 @@ class HistoryDaoTest {

@Test
fun testGetHistoryEntryByUrl() {
val historyEntry = HistoryEntryEntity(url = "url", title = "title", query = "query", isSerp = false)
runTest {
val historyEntry = HistoryEntryEntity(url = "url", title = "title", query = "query", isSerp = false)
historyDao.insertHistoryEntry(historyEntry)

val retrievedEntry = historyDao.getHistoryEntryByUrl("url")
Expand Down Expand Up @@ -81,4 +81,42 @@ class HistoryDaoTest {
Assert.assertEquals(2, historyEntriesWithVisits.first().visits.count())
}
}

@Test
fun whenDeleteOldItemsWithNoOldEnoughItemsThenNothingIsDeleted() {
runTest {
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() {
runTest {
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() {
runTest {
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 4e9c30b

Please sign in to comment.