Skip to content

Commit 7d2a296

Browse files
authored
fix: Decouple encrypted entry map from blob store (#56)
This resolves Issue #54 by shifting the in-memory map of encrypted entries out of SafeBoxBlobStore and into SafeBox, ensuring immediate read/write access even before disk writes complete. This aligns with EncryptedSharedPreferences behavior.
1 parent 7295fb8 commit 7d2a296

File tree

4 files changed

+110
-102
lines changed

4 files changed

+110
-102
lines changed

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ class SafeBoxTest {
7979
assertEquals("Secured", value)
8080
}
8181

82+
@Test
83+
fun getString_withRealIoDispatcher_shouldReturnCorrectValue() {
84+
safeBox = createSafeBox(ioDispatcher = Dispatchers.IO)
85+
safeBox.edit()
86+
.putString("SafeBox", "Secured")
87+
.apply()
88+
89+
val value = safeBox.getString("SafeBox", null)
90+
91+
assertEquals("Secured", value)
92+
}
93+
8294
@Test
8395
fun getString_afterRemove_shouldReturnDefaultValue() {
8496
safeBox = createSafeBox()

safebox/src/androidTest/java/com/harrytmthy/safebox/storage/SafeBoxBlobStoreTest.kt

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,11 @@ import android.content.Context
2020
import androidx.test.core.app.ApplicationProvider
2121
import androidx.test.ext.junit.runners.AndroidJUnit4
2222
import com.harrytmthy.safebox.extensions.toBytes
23-
import com.harrytmthy.safebox.state.SafeBoxState
24-
import com.harrytmthy.safebox.state.SafeBoxStateListener
25-
import com.harrytmthy.safebox.state.SafeBoxStateManager
2623
import kotlinx.coroutines.ExperimentalCoroutinesApi
27-
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2824
import kotlinx.coroutines.test.runTest
2925
import org.junit.After
3026
import org.junit.runner.RunWith
3127
import java.io.File
32-
import java.util.concurrent.CopyOnWriteArrayList
3328
import kotlin.test.Test
3429
import kotlin.test.assertContentEquals
3530
import kotlin.test.assertEquals
@@ -44,21 +39,12 @@ class SafeBoxBlobStoreTest {
4439

4540
private val fileName: String = "safebox_blob_test"
4641

47-
private val observedStates = CopyOnWriteArrayList<SafeBoxState>()
48-
49-
private val stateListener = SafeBoxStateListener(observedStates::add)
50-
51-
private val blobStore = SafeBoxBlobStore.create(
52-
context,
53-
fileName,
54-
SafeBoxStateManager(fileName, stateListener, UnconfinedTestDispatcher()),
55-
)
42+
private val blobStore = SafeBoxBlobStore.create(context, fileName)
5643

5744
@After
5845
fun teardown() {
5946
blobStore.close()
6047
File(context.noBackupFilesDir, "$fileName.bin").delete()
61-
observedStates.clear()
6248
}
6349

6450
@Test
@@ -70,7 +56,7 @@ class SafeBoxBlobStoreTest {
7056
blobStore.write(firstKey, firstValue)
7157
blobStore.write(secondKey, secondValue)
7258

73-
val result = blobStore.getAll()
59+
val result = blobStore.loadPersistedEntries()
7460

7561
assertEquals(2, result.size)
7662
assertContentEquals(firstValue, result[firstKey])
@@ -91,7 +77,7 @@ class SafeBoxBlobStoreTest {
9177

9278
blobStore.delete(firstKey)
9379

94-
val result = blobStore.getAll()
80+
val result = blobStore.loadPersistedEntries()
9581
assertEquals(2, result.size)
9682
assertFalse(result.any { it == firstKey })
9783
assertContentEquals(secondValue, result[secondKey])
@@ -113,7 +99,7 @@ class SafeBoxBlobStoreTest {
11399

114100
blobStore.delete(secondKey)
115101

116-
val result = blobStore.getAll()
102+
val result = blobStore.loadPersistedEntries()
117103
assertEquals(2, result.size)
118104
assertFalse(result.any { it == secondKey })
119105
assertContentEquals(firstValue, result[firstKey])
@@ -135,7 +121,7 @@ class SafeBoxBlobStoreTest {
135121

136122
blobStore.delete(thirdKey)
137123

138-
val result = blobStore.getAll()
124+
val result = blobStore.loadPersistedEntries()
139125
assertEquals(2, result.size)
140126
assertFalse(result.any { it == thirdKey })
141127
assertContentEquals(firstValue, result[firstKey])
@@ -157,7 +143,7 @@ class SafeBoxBlobStoreTest {
157143
val thirdValue = "789".toByteArray()
158144
blobStore.write(thirdKey, thirdValue)
159145

160-
val result = blobStore.getAll()
146+
val result = blobStore.loadPersistedEntries()
161147
assertEquals(2, result.size)
162148
assertFalse(result.any { it == firstKey })
163149
assertContentEquals(secondValue, result[secondKey])
@@ -176,7 +162,7 @@ class SafeBoxBlobStoreTest {
176162
blobStore.write(key, firstValue)
177163
blobStore.write(key, secondValue)
178164

179-
val result = blobStore.getAll()
165+
val result = blobStore.loadPersistedEntries()
180166
assertEquals(1, result.size)
181167
assertContentEquals(secondValue, result[key])
182168
assertTrue(blobStore.entryMetas.containsKey(key))
@@ -191,7 +177,7 @@ class SafeBoxBlobStoreTest {
191177
blobStore.write(key, firstValue)
192178
blobStore.write(key, secondValue)
193179

194-
val result = blobStore.getAll()
180+
val result = blobStore.loadPersistedEntries()
195181
assertEquals(1, result.size)
196182
assertContentEquals(secondValue, result[key])
197183
assertTrue(blobStore.entryMetas.containsKey(key))
@@ -206,7 +192,7 @@ class SafeBoxBlobStoreTest {
206192
blobStore.write(key, firstValue)
207193
blobStore.write(key, secondValue)
208194

209-
val result = blobStore.getAll()
195+
val result = blobStore.loadPersistedEntries()
210196
assertEquals(1, result.size)
211197
assertContentEquals(secondValue, result[key])
212198
assertTrue(blobStore.entryMetas.containsKey(key))
@@ -216,13 +202,4 @@ class SafeBoxBlobStoreTest {
216202
fun getFileName_shouldReturnFileName() {
217203
assertEquals(fileName, blobStore.getFileName())
218204
}
219-
220-
@Test
221-
fun init_shouldEmitStartingState() {
222-
val expected = listOf(
223-
SafeBoxState.STARTING,
224-
SafeBoxState.IDLE,
225-
)
226-
assertEquals(expected, observedStates)
227-
}
228205
}

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

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
4545
import kotlinx.coroutines.Dispatchers
4646
import kotlinx.coroutines.sync.Mutex
4747
import kotlinx.coroutines.sync.withLock
48+
import java.util.concurrent.ConcurrentHashMap
4849
import java.util.concurrent.CopyOnWriteArrayList
4950
import java.util.concurrent.atomic.AtomicBoolean
5051
import java.util.concurrent.atomic.AtomicReference
@@ -77,7 +78,9 @@ public class SafeBox private constructor(
7778
private val stateManager: SafeBoxStateManager,
7879
) : SharedPreferences {
7980

80-
private val castFailureStrategy = AtomicReference<ValueFallbackStrategy>(WARN)
81+
private val entries: MutableMap<Bytes, ByteArray> = ConcurrentHashMap()
82+
83+
private val castFailureStrategy = AtomicReference(WARN)
8184

8285
private val byteDecoder = ByteDecoder(castFailureStrategy::get)
8386

@@ -92,34 +95,79 @@ public class SafeBox private constructor(
9295

9396
private val delegate = object : Delegate {
9497

98+
private val updateLock = Any()
99+
95100
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
96101
Log.e("SafeBox", "Failed to apply changes.", throwable)
97102
applyCompleted.complete(Unit)
98103
}
99104

100-
override fun commit(entries: LinkedHashMap<String, Action>, cleared: Boolean): Boolean =
101-
stateManager.launchCommitWithWritingState {
105+
override fun commit(entries: LinkedHashMap<String, Action>, cleared: Boolean): Boolean {
106+
val entriesToWrite = LinkedHashMap(entries)
107+
synchronized(updateLock) {
108+
entries.clear() // Prevents stale mutations on reused editor instance
109+
updateEntries(entriesToWrite, cleared)
110+
}
111+
return stateManager.launchCommitWithWritingState {
102112
try {
103113
applyCompleted.await()
104114
commitMutex.withLock {
105-
applyChanges(entries, cleared)
115+
applyChanges(entriesToWrite, cleared)
106116
}
107117
true
108118
} catch (e: Exception) {
109119
Log.e("SafeBox", "Failed to commit changes.", e)
110120
false
111121
}
112122
}
123+
}
113124

114125
override fun apply(entries: LinkedHashMap<String, Action>, cleared: Boolean) {
126+
val entriesToWrite = LinkedHashMap(entries)
127+
synchronized(updateLock) {
128+
entries.clear() // Prevents stale mutations on reused editor instance
129+
updateEntries(entriesToWrite, cleared)
130+
}
115131
stateManager.launchApplyWithWritingState(exceptionHandler) {
116132
applyCompleted = CompletableDeferred()
117133
applyMutex.withLock {
118-
applyChanges(entries, cleared)
134+
applyChanges(entriesToWrite, cleared)
119135
}
120136
applyCompleted.complete(Unit)
121137
}
122138
}
139+
140+
private fun updateEntries(entries: LinkedHashMap<String, Action>, cleared: Boolean) {
141+
if (cleared) {
142+
val keys = this@SafeBox.entries.keys.toHashSet()
143+
this@SafeBox.entries.clear()
144+
for (encryptedKey in keys) {
145+
val key = keyCipherProvider.decrypt(encryptedKey.value).toString(Charsets.UTF_8)
146+
listeners.forEach { it.onSharedPreferenceChanged(this@SafeBox, key) }
147+
}
148+
}
149+
for ((key, action) in entries) {
150+
when (action) {
151+
is Put -> {
152+
val encryptedKey = key.toEncryptedKey()
153+
val encryptedValue = action.encodedValue.value
154+
.let(valueCipherProvider::encrypt)
155+
this@SafeBox.entries[encryptedKey] = encryptedValue
156+
}
157+
is Remove -> {
158+
val encryptedKey = key.toEncryptedKey()
159+
this@SafeBox.entries.remove(encryptedKey)
160+
}
161+
}
162+
listeners.forEach { it.onSharedPreferenceChanged(this@SafeBox, key) }
163+
}
164+
}
165+
}
166+
167+
init {
168+
stateManager.launchWithStartingState {
169+
entries += blobStore.loadPersistedEntries()
170+
}
123171
}
124172

125173
/**
@@ -185,9 +233,8 @@ public class SafeBox private constructor(
185233
}
186234

187235
override fun getAll(): Map<String, Any?> {
188-
val encryptedEntries = blobStore.getAll()
189-
val decryptedEntries = HashMap<String, Any?>(encryptedEntries.size, 1f)
190-
for (entry in encryptedEntries) {
236+
val decryptedEntries = HashMap<String, Any?>(entries.size, 1f)
237+
for (entry in entries) {
191238
val key = keyCipherProvider.decrypt(entry.key.value).toString(Charsets.UTF_8)
192239
val value = valueCipherProvider.decrypt(entry.value)
193240
decryptedEntries[key] = byteDecoder.decodeAny(value)
@@ -226,7 +273,7 @@ public class SafeBox private constructor(
226273
?: defValue
227274

228275
override fun contains(key: String): Boolean =
229-
blobStore.contains(key.toEncryptedKey())
276+
entries.containsKey(key.toEncryptedKey())
230277

231278
override fun edit(): SharedPreferences.Editor = Editor(delegate)
232279

@@ -243,15 +290,12 @@ public class SafeBox private constructor(
243290
}
244291

245292
private fun getDecryptedValue(key: String): ByteArray? =
246-
blobStore.get(key.toEncryptedKey())
293+
entries[key.toEncryptedKey()]
247294
?.let(valueCipherProvider::decrypt)
248295

249296
private suspend fun applyChanges(entries: LinkedHashMap<String, Action>, cleared: Boolean) {
250297
if (cleared) {
251-
blobStore.deleteAll().forEach { encryptedKey ->
252-
val key = keyCipherProvider.decrypt(encryptedKey.value).toString(Charsets.UTF_8)
253-
listeners.forEach { it.onSharedPreferenceChanged(this, key) }
254-
}
298+
blobStore.deleteAll()
255299
}
256300
for ((key, action) in entries) {
257301
when (action) {
@@ -267,9 +311,7 @@ public class SafeBox private constructor(
267311
}
268312
}
269313
}
270-
listeners.forEach { it.onSharedPreferenceChanged(this, key) }
271314
}
272-
entries.clear()
273315
}
274316

275317
private fun String.toEncryptedKey(): Bytes =
@@ -384,7 +426,7 @@ public class SafeBox private constructor(
384426
val keyCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = true)
385427
val valueCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = false)
386428
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
387-
val blobStore = SafeBoxBlobStore.create(context, fileName, stateManager)
429+
val blobStore = SafeBoxBlobStore.create(context, fileName)
388430
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
389431
}
390432

@@ -421,7 +463,7 @@ public class SafeBox private constructor(
421463
): SafeBox {
422464
SafeBoxBlobFileRegistry.register(fileName)
423465
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
424-
val blobStore = SafeBoxBlobStore.create(context, fileName, stateManager)
466+
val blobStore = SafeBoxBlobStore.create(context, fileName)
425467
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
426468
}
427469
}

0 commit comments

Comments
 (0)