diff --git a/app/core/src/main/java/com/fsck/k9/cache/Cache.kt b/app/core/src/main/java/com/fsck/k9/cache/Cache.kt new file mode 100644 index 000000000..24452809b --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/cache/Cache.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.cache + +interface Cache { + + operator fun get(key: KEY): VALUE? + + operator fun set(key: KEY, value: VALUE) + + fun hasKey(key: KEY): Boolean + + fun clear() +} diff --git a/app/core/src/main/java/com/fsck/k9/cache/ExpiringCache.kt b/app/core/src/main/java/com/fsck/k9/cache/ExpiringCache.kt new file mode 100644 index 000000000..834042085 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/cache/ExpiringCache.kt @@ -0,0 +1,45 @@ +package com.fsck.k9.cache + +import com.fsck.k9.Clock + +internal class ExpiringCache( + private val clock: Clock, + private val delegateCache: Cache = InMemoryCache(), + private var lastClearTime: Long = clock.time, + private val cacheTimeValidity: Long = CACHE_TIME_VALIDITY_IN_MILLIS +) : Cache { + + override fun get(key: KEY): VALUE? { + recycle() + return delegateCache[key] + } + + override fun set(key: KEY, value: VALUE) { + recycle() + delegateCache[key] = value + } + + override fun hasKey(key: KEY): Boolean { + recycle() + return delegateCache.hasKey(key) + } + + override fun clear() { + lastClearTime = clock.time + delegateCache.clear() + } + + private fun recycle() { + if (isExpired()) { + clear() + } + } + + private fun isExpired(): Boolean { + return (clock.time - lastClearTime) >= cacheTimeValidity + } + + private companion object { + const val CACHE_TIME_VALIDITY_IN_MILLIS = 30_000L + } +} diff --git a/app/core/src/main/java/com/fsck/k9/cache/InMemoryCache.kt b/app/core/src/main/java/com/fsck/k9/cache/InMemoryCache.kt new file mode 100644 index 000000000..835f0a91e --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/cache/InMemoryCache.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.cache + +internal class InMemoryCache( + private val cache: MutableMap = mutableMapOf() +) : Cache { + override fun get(key: KEY): VALUE? { + return cache[key] + } + + override fun set(key: KEY, value: VALUE) { + cache[key] = value + } + + override fun hasKey(key: KEY): Boolean { + return cache.containsKey(key) + } + + override fun clear() { + cache.clear() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/cache/SynchronizedCache.kt b/app/core/src/main/java/com/fsck/k9/cache/SynchronizedCache.kt new file mode 100644 index 000000000..518f4716d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/cache/SynchronizedCache.kt @@ -0,0 +1,30 @@ +package com.fsck.k9.cache + +internal class SynchronizedCache( + private val delegateCache: Cache +) : Cache { + + override fun get(key: KEY): VALUE? { + synchronized(delegateCache) { + return delegateCache[key] + } + } + + override fun set(key: KEY, value: VALUE) { + synchronized(delegateCache) { + delegateCache[key] = value + } + } + + override fun hasKey(key: KEY): Boolean { + synchronized(delegateCache) { + return delegateCache.hasKey(key) + } + } + + override fun clear() { + synchronized(delegateCache) { + delegateCache.clear() + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/cache/CacheTest.kt b/app/core/src/test/java/com/fsck/k9/cache/CacheTest.kt new file mode 100644 index 000000000..605080f81 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/cache/CacheTest.kt @@ -0,0 +1,84 @@ +package com.fsck.k9.cache + +import com.fsck.k9.TestClock +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +data class CacheTestData( + val name: String, + val createCache: () -> Cache +) { + override fun toString(): String = name +} + +@RunWith(Parameterized::class) +class CacheTest(data: CacheTestData) { + + private val testSubject = data.createCache() + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> { + return listOf( + CacheTestData("InMemoryCache") { InMemoryCache() }, + CacheTestData("ExpiringCache") { ExpiringCache(TestClock(), InMemoryCache()) }, + CacheTestData("SynchronizedCache") { SynchronizedCache(InMemoryCache()) } + ) + } + + const val KEY = "key" + const val VALUE = "value" + } + + @Test + fun `get should return null with empty cache`() { + assertThat(testSubject[KEY]).isNull() + } + + @Test + fun `get should return entry when present`() { + testSubject[KEY] = VALUE + + assertThat(testSubject[KEY]).isEqualTo(VALUE) + } + + @Test + fun `set should add entry with empty cache`() { + testSubject[KEY] = VALUE + + assertThat(testSubject[KEY]).isEqualTo(VALUE) + } + + @Test + fun `set should overwrite entry when already present`() { + testSubject[KEY] = VALUE + + testSubject[KEY] = "$VALUE changed" + + assertThat(testSubject[KEY]).isEqualTo("$VALUE changed") + } + + @Test + fun `hasKey should answer no with empty cache`() { + assertThat(testSubject.hasKey(KEY)).isFalse() + } + + @Test + fun `hasKey should answer yes when cache has entry`() { + testSubject[KEY] = VALUE + + assertThat(testSubject.hasKey(KEY)).isTrue() + } + + @Test + fun `clear should empty cache`() { + testSubject[KEY] = VALUE + + testSubject.clear() + + assertThat(testSubject[KEY]).isNull() + } +} diff --git a/app/core/src/test/java/com/fsck/k9/cache/ExpiringCacheTest.kt b/app/core/src/test/java/com/fsck/k9/cache/ExpiringCacheTest.kt new file mode 100644 index 000000000..123892dbf --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/cache/ExpiringCacheTest.kt @@ -0,0 +1,68 @@ +package com.fsck.k9.cache + +import com.fsck.k9.TestClock +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class ExpiringCacheTest { + + private val clock = TestClock() + + private val testSubject: Cache = ExpiringCache(clock, InMemoryCache()) + + @Test + fun `get should return null when entry present and cache expired`() { + testSubject[KEY] = VALUE + advanceClockBy(CACHE_TIME_VALIDITY_IN_MILLIS) + + val result = testSubject[KEY] + + assertThat(result).isNull() + } + + @Test + fun `set should clear cache and add new entry when cache expired`() { + testSubject[KEY] = VALUE + advanceClockBy(CACHE_TIME_VALIDITY_IN_MILLIS) + + testSubject[KEY + 1] = "$VALUE changed" + + assertThat(testSubject[KEY]).isNull() + assertThat(testSubject[KEY + 1]).isEqualTo("$VALUE changed") + } + + @Test + fun `hasKey should answer no when cache has entry and validity expired`() { + testSubject[KEY] = VALUE + advanceClockBy(CACHE_TIME_VALIDITY_IN_MILLIS) + + assertThat(testSubject.hasKey(KEY)).isFalse() + } + + @Test + fun `should keep cache when time progresses within expiration`() { + testSubject[KEY] = VALUE + advanceClockBy(CACHE_TIME_VALIDITY_IN_MILLIS - 1) + + assertThat(testSubject[KEY]).isEqualTo(VALUE) + } + + @Test + fun `should empty cache after time progresses to expiration`() { + testSubject[KEY] = VALUE + + advanceClockBy(CACHE_TIME_VALIDITY_IN_MILLIS) + + assertThat(testSubject[KEY]).isNull() + } + + private fun advanceClockBy(timeInMillis: Long) { + clock.time = clock.time + timeInMillis + } + + private companion object { + const val KEY = "key" + const val VALUE = "value" + const val CACHE_TIME_VALIDITY_IN_MILLIS = 30_000L + } +}