Skip to content

Commit 1e32d49

Browse files
committed
feat: Add atomicity and new getters to SafeBox
1 parent 49efdc8 commit 1e32d49

File tree

2 files changed

+95
-24
lines changed

2 files changed

+95
-24
lines changed

safebox/src/androidTest/java/com/harrytmthy/safebox/SafeBoxTest.kt

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,19 @@ import android.content.Context
2020
import android.content.SharedPreferences
2121
import androidx.test.core.app.ApplicationProvider
2222
import androidx.test.ext.junit.runners.AndroidJUnit4
23+
import com.harrytmthy.safebox.SafeBox.Companion.DEFAULT_KEY_ALIAS
2324
import com.harrytmthy.safebox.state.SafeBoxStateListener
2425
import kotlinx.coroutines.CoroutineDispatcher
2526
import kotlinx.coroutines.Dispatchers
2627
import kotlinx.coroutines.ExperimentalCoroutinesApi
28+
import kotlinx.coroutines.launch
2729
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2830
import kotlinx.coroutines.test.runTest
2931
import kotlinx.coroutines.withTimeout
3032
import org.junit.After
3133
import org.junit.runner.RunWith
34+
import java.io.File
35+
import java.util.concurrent.atomic.AtomicReference
3236
import kotlin.test.Test
3337
import kotlin.test.assertContentEquals
3438
import kotlin.test.assertEquals
@@ -48,9 +52,45 @@ class SafeBoxTest {
4852

4953
@After
5054
fun tearDown() {
51-
safeBox.edit()
52-
.clear()
53-
.commit()
55+
SafeBox.getOrNull(fileName)
56+
?.edit()
57+
?.clear()
58+
?.commit()
59+
60+
File(context.noBackupFilesDir, "$fileName.bin").delete()
61+
File(context.noBackupFilesDir, "$DEFAULT_KEY_ALIAS.bin").delete()
62+
}
63+
64+
@Test
65+
fun create_then_get_shouldReturnSameInstance() {
66+
safeBox = createSafeBox()
67+
68+
val instance = SafeBox.get(fileName)
69+
70+
assertTrue(safeBox === instance)
71+
}
72+
73+
@Test
74+
fun create_withConcurrentCalls_shouldReturnSameInstance() = runTest {
75+
val createdInstance = AtomicReference<SafeBox>()
76+
repeat(10) {
77+
launch(Dispatchers.IO) {
78+
val newInstance = createSafeBox()
79+
val previousInstance = createdInstance.getAndSet(newInstance) ?: return@launch
80+
assertTrue(newInstance === previousInstance)
81+
}
82+
}
83+
}
84+
85+
@Test
86+
fun create_withDifferentFileName_shouldReturnDifferentInstances() {
87+
safeBox = createSafeBox()
88+
89+
val anotherSafeBox = createSafeBox(fileName = "test_safebox.bin")
90+
91+
assertTrue(safeBox !== anotherSafeBox)
92+
assertTrue(safeBox === createSafeBox())
93+
File(context.noBackupFilesDir, "test_safebox.bin").delete()
5494
}
5595

5696
@Test
@@ -187,6 +227,7 @@ class SafeBoxTest {
187227
}
188228

189229
private fun createSafeBox(
230+
fileName: String = this.fileName,
190231
ioDispatcher: CoroutineDispatcher = UnconfinedTestDispatcher(),
191232
stateListener: SafeBoxStateListener? = null,
192233
): SafeBox =

safebox/src/main/java/com/harrytmthy/safebox/SafeBox.kt

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -418,23 +418,29 @@ public class SafeBox private constructor(
418418
stateListener?.let(safeBox.stateManager::setStateListener)
419419
return safeBox
420420
}
421-
val aesGcmCipherProvider = AesGcmCipherProvider.create(
422-
alias = valueKeyStoreAlias,
423-
aad = additionalAuthenticatedData,
424-
)
425-
val keyProvider = SecureRandomKeyProvider.create(
426-
context = context,
427-
fileName = keyAlias,
428-
keySize = ChaCha20CipherProvider.KEY_SIZE,
429-
algorithm = ChaCha20CipherProvider.ALGORITHM,
430-
cipherProvider = aesGcmCipherProvider,
431-
)
432-
val keyCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = true)
433-
val valueCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = false)
434-
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
435-
val blobStore = SafeBoxBlobStore.create(context, fileName)
436-
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
437-
.also { instances[fileName] = it }
421+
return synchronized(instances) {
422+
instances[fileName]?.let { safeBox ->
423+
stateListener?.let(safeBox.stateManager::setStateListener)
424+
return safeBox
425+
}
426+
val aesGcmCipherProvider = AesGcmCipherProvider.create(
427+
alias = valueKeyStoreAlias,
428+
aad = additionalAuthenticatedData,
429+
)
430+
val keyProvider = SecureRandomKeyProvider.create(
431+
context = context,
432+
fileName = keyAlias,
433+
keySize = ChaCha20CipherProvider.KEY_SIZE,
434+
algorithm = ChaCha20CipherProvider.ALGORITHM,
435+
cipherProvider = aesGcmCipherProvider,
436+
)
437+
val keyCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = true)
438+
val valueCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = false)
439+
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
440+
val blobStore = SafeBoxBlobStore.create(context, fileName)
441+
SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
442+
.also { instances[fileName] = it }
443+
}
438444
}
439445

440446
/**
@@ -472,10 +478,34 @@ public class SafeBox private constructor(
472478
stateListener?.let(safeBox.stateManager::setStateListener)
473479
return safeBox
474480
}
475-
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
476-
val blobStore = SafeBoxBlobStore.create(context, fileName)
477-
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
478-
.also { instances[fileName] = it }
481+
return synchronized(instances) {
482+
instances[fileName]?.let { safeBox ->
483+
stateListener?.let(safeBox.stateManager::setStateListener)
484+
return safeBox
485+
}
486+
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
487+
val blobStore = SafeBoxBlobStore.create(context, fileName)
488+
SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
489+
.also { instances[fileName] = it }
490+
}
479491
}
492+
493+
/**
494+
* Returns the previously created instance for [fileName].
495+
*
496+
* @return The existing [SafeBox] instance.
497+
* @throws IllegalStateException if [create] has not been called for this file.
498+
*/
499+
@JvmStatic
500+
public fun get(fileName: String): SafeBox =
501+
instances[fileName] ?: error("SafeBox '$fileName' is not initialized.")
502+
503+
/**
504+
* Returns the previously created instance for [fileName], or null if not initialized.
505+
*
506+
* @return The existing [SafeBox] instance, or null.
507+
*/
508+
@JvmStatic
509+
public fun getOrNull(fileName: String): SafeBox? = instances[fileName]
480510
}
481511
}

0 commit comments

Comments
 (0)