This repository has been archived by the owner on Jun 20, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 497
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Wrap EncryptedSharedPreferences initialization in a retry mechanism (…
…EXPOSUREAPP-1851) (#1235) * Add retry mechanism with backoff to EncryptedSharedPreferences creation. * Increase default delays and make the actual delay a randomized value between the last and the new delay. * Upgrade to "androidx.security:security-crypto:1.0.0-rc03" * Further space retry delays. * Unit tests for retry mechanism and factory init. * klint * Fixed flaky test. Co-authored-by: harambasicluka <[email protected]>
- Loading branch information
1 parent
44090fa
commit 7106941
Showing
6 changed files
with
247 additions
and
20 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
77 changes: 77 additions & 0 deletions
77
Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/RetryMechanism.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
package de.rki.coronawarnapp.util | ||
|
||
import timber.log.Timber | ||
import kotlin.math.pow | ||
import kotlin.math.roundToLong | ||
|
||
object RetryMechanism { | ||
|
||
fun <T> retryWithBackOff( | ||
delayCalculator: (Attempt) -> Long? = createDelayCalculator(), | ||
delayOperation: (Long) -> Unit = { Thread.sleep(it) }, | ||
retryCondition: (Attempt) -> Boolean = { true }, | ||
action: () -> T | ||
): T { | ||
var current = Attempt() | ||
while (true) { | ||
Timber.v("Executing attempt: %s", current) | ||
try { | ||
return action() | ||
} catch (e: Exception) { | ||
current = current.copy(exception = e) | ||
} | ||
|
||
if (!retryCondition(current)) throw current.exception!! | ||
|
||
val newDelay = delayCalculator(current) | ||
|
||
if (newDelay == null) { | ||
Timber.w("Retrycondition exceeded: %s", current) | ||
throw current.exception!! | ||
} else { | ||
delayOperation(newDelay) | ||
} | ||
|
||
current = current.copy( | ||
count = current.count + 1, | ||
lastDelay = newDelay, | ||
totalDelay = current.totalDelay + newDelay | ||
) | ||
} | ||
} | ||
|
||
private const val DEFAULT_TOTAL_MAX_RETRY = 15 * 1000L // 15 seconds total delay | ||
private const val DEFAULT_MAX_DELAY = 3 * 1000L // 3 seconds max between retries | ||
private const val DEFAULT_MIN_DELAY = 25L // Almost immediate retry | ||
private const val DEFAULT_RETRY_MULTIPLIER = 1.5 | ||
|
||
fun createDelayCalculator( | ||
maxTotalDelay: Long = DEFAULT_TOTAL_MAX_RETRY, | ||
maxDelay: Long = DEFAULT_MAX_DELAY, | ||
minDelay: Long = DEFAULT_MIN_DELAY, | ||
multiplier: Double = DEFAULT_RETRY_MULTIPLIER | ||
): (Attempt) -> Long? = { attempt -> | ||
if (attempt.totalDelay > maxTotalDelay) { | ||
Timber.w("Max retry duration exceeded.") | ||
null | ||
} else { | ||
val exp = 2.0.pow(attempt.count.toDouble()) | ||
val calculatedDelay = (multiplier * exp).roundToLong() | ||
|
||
val newDelay = if (calculatedDelay > attempt.lastDelay) { | ||
(attempt.lastDelay..calculatedDelay).random() | ||
} else { | ||
(calculatedDelay..attempt.lastDelay).random() | ||
} | ||
|
||
newDelay.coerceAtMost(maxDelay).coerceAtLeast(minDelay) | ||
} | ||
} | ||
|
||
data class Attempt( | ||
val count: Int = 1, | ||
val totalDelay: Long = 0L, | ||
val lastDelay: Long = 0L, | ||
val exception: Exception? = null | ||
) | ||
} |
40 changes: 40 additions & 0 deletions
40
...-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactory.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
package de.rki.coronawarnapp.util.security | ||
|
||
import android.content.Context | ||
import android.content.SharedPreferences | ||
import androidx.security.crypto.EncryptedSharedPreferences | ||
import androidx.security.crypto.MasterKeys | ||
import de.rki.coronawarnapp.util.RetryMechanism | ||
import timber.log.Timber | ||
import java.security.KeyException | ||
import javax.inject.Inject | ||
import javax.inject.Singleton | ||
|
||
@Singleton | ||
class EncryptedPreferencesFactory @Inject constructor( | ||
private val context: Context | ||
) { | ||
|
||
private val masterKeyAlias by lazy { | ||
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) | ||
} | ||
|
||
private fun createInstance(fileName: String) = EncryptedSharedPreferences.create( | ||
fileName, | ||
masterKeyAlias, | ||
context, | ||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, | ||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM | ||
) | ||
|
||
fun create(fileName: String): SharedPreferences = try { | ||
RetryMechanism.retryWithBackOff { | ||
Timber.d("Creating EncryptedSharedPreferences instance.") | ||
createInstance(fileName).also { | ||
Timber.d("Instance created, %d entries.", it.all.size) | ||
} | ||
} | ||
} catch (e: Exception) { | ||
throw KeyException("Permantly failed to instantiate encrypted preferences", e) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
92 changes: 92 additions & 0 deletions
92
Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/RetryMechanismTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
package de.rki.coronawarnapp.util | ||
|
||
import de.rki.coronawarnapp.util.RetryMechanism.createDelayCalculator | ||
import io.kotest.assertions.throwables.shouldThrow | ||
import io.kotest.matchers.longs.beInRange | ||
import io.kotest.matchers.should | ||
import io.kotest.matchers.shouldBe | ||
import io.kotest.matchers.shouldNotBe | ||
import io.kotest.matchers.types.instanceOf | ||
import io.mockk.MockKAnnotations | ||
import io.mockk.clearAllMocks | ||
import io.mockk.every | ||
import io.mockk.impl.annotations.MockK | ||
import io.mockk.verify | ||
import org.junit.jupiter.api.AfterEach | ||
import org.junit.jupiter.api.BeforeEach | ||
import org.junit.jupiter.api.Test | ||
import testhelpers.BaseTest | ||
import java.util.UUID | ||
|
||
class RetryMechanismTest : BaseTest() { | ||
@MockK lateinit var mockFunction: () -> String | ||
|
||
@BeforeEach | ||
fun setup() { | ||
MockKAnnotations.init(this) | ||
every { mockFunction.invoke() } answers { | ||
throw RuntimeException(UUID.randomUUID().toString()) | ||
} | ||
} | ||
|
||
@AfterEach | ||
fun teardown() { | ||
clearAllMocks() | ||
} | ||
|
||
@Test | ||
fun `simple retry`() { | ||
val attempts = mutableListOf<RetryMechanism.Attempt>() | ||
shouldThrow<RuntimeException> { | ||
RetryMechanism.retryWithBackOff( | ||
delayCalculator = createDelayCalculator(), | ||
delayOperation = { Thread.sleep(it) }, | ||
retryCondition = { | ||
attempts.add(it) | ||
it.count < 3 | ||
}, | ||
action = mockFunction | ||
) | ||
} | ||
verify(exactly = 3) { mockFunction() } | ||
|
||
attempts[0].apply { | ||
count shouldBe 1 | ||
totalDelay shouldBe 0L | ||
lastDelay shouldBe 0L | ||
exception should instanceOf(RuntimeException::class) | ||
} | ||
attempts[1].apply { | ||
count shouldBe 2 | ||
totalDelay shouldBe lastDelay | ||
lastDelay shouldBe totalDelay | ||
exception should instanceOf(RuntimeException::class) | ||
} | ||
attempts[2].apply { | ||
count shouldBe 3 | ||
totalDelay shouldBe attempts[1].totalDelay + lastDelay | ||
lastDelay shouldBe totalDelay - attempts[1].totalDelay | ||
exception should instanceOf(RuntimeException::class) | ||
} | ||
attempts[0].exception shouldNotBe attempts[1].exception | ||
attempts[1].exception shouldNotBe attempts[2].exception | ||
} | ||
|
||
@Test | ||
fun `test clamping`() { | ||
val calculator = createDelayCalculator() | ||
RetryMechanism.Attempt(count = -5, lastDelay = 20).let { | ||
calculator(it) shouldBe 25 // -X .. 20 -> clamp to min (25) | ||
} | ||
RetryMechanism.Attempt(count = 100, lastDelay = 3 * 1000L).let { | ||
calculator(it) shouldBe 3 * 1000L // lastDelay .. HugeNewDelay -> clamp to max (3k) | ||
} | ||
|
||
RetryMechanism.Attempt(count = 10, lastDelay = 16 * 1000L).let { | ||
calculator(it) shouldBe beInRange(1536L..3000L) | ||
} | ||
RetryMechanism.Attempt(count = 100, lastDelay = 1).let { | ||
calculator(it) shouldBe 3 * 1000L | ||
} | ||
} | ||
} |
35 changes: 35 additions & 0 deletions
35
...n-App/src/test/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactoryTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package de.rki.coronawarnapp.util.security | ||
|
||
import android.content.Context | ||
import io.kotest.assertions.throwables.shouldNotThrowAny | ||
import io.mockk.Called | ||
import io.mockk.MockKAnnotations | ||
import io.mockk.clearAllMocks | ||
import io.mockk.impl.annotations.MockK | ||
import io.mockk.verify | ||
import org.junit.jupiter.api.AfterEach | ||
import org.junit.jupiter.api.BeforeEach | ||
import org.junit.jupiter.api.Test | ||
import testhelpers.BaseTest | ||
|
||
class EncryptedPreferencesFactoryTest : BaseTest() { | ||
@MockK lateinit var context: Context | ||
|
||
@BeforeEach | ||
fun setup() { | ||
MockKAnnotations.init(this) | ||
} | ||
|
||
@AfterEach | ||
fun teardown() { | ||
clearAllMocks() | ||
} | ||
|
||
@Test | ||
fun `sideeffect free init`() { | ||
shouldNotThrowAny { | ||
EncryptedPreferencesFactory(context) | ||
} | ||
verify { context.getSharedPreferences(any(), any()) wasNot Called } | ||
} | ||
} |