Skip to content

Commit 77d822e

Browse files
committed
feat(state): Add SafeBoxStateManager for better lifecycle support
1 parent 01cb575 commit 77d822e

File tree

5 files changed

+196
-137
lines changed

5 files changed

+196
-137
lines changed

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ import androidx.test.core.app.ApplicationProvider
2222
import androidx.test.ext.junit.runners.AndroidJUnit4
2323
import com.harrytmthy.safebox.SafeBox.Companion.DEFAULT_KEY_ALIAS
2424
import com.harrytmthy.safebox.SafeBox.Companion.DEFAULT_VALUE_KEYSTORE_ALIAS
25+
import com.harrytmthy.safebox.state.SafeBoxState
26+
import com.harrytmthy.safebox.state.SafeBoxStateListener
27+
import kotlinx.coroutines.Dispatchers
2528
import kotlinx.coroutines.ExperimentalCoroutinesApi
2629
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2730
import org.junit.After
2831
import org.junit.runner.RunWith
2932
import java.io.File
3033
import java.security.KeyStore
34+
import java.util.concurrent.CopyOnWriteArrayList
3135
import kotlin.test.Test
3236
import kotlin.test.assertContentEquals
3337
import kotlin.test.assertEquals
@@ -158,4 +162,40 @@ class SafeBoxTest {
158162
assertContentEquals(expectedKeyChanges, changedKeys)
159163
assertContentEquals(expectedValueChanges, changedValues)
160164
}
165+
166+
@Test
167+
fun closeWhenIdle_shouldWaitUntilWritesAreDoneBeforeClosing() {
168+
val observedStates = CopyOnWriteArrayList<SafeBoxState>()
169+
var shouldLoop = true
170+
val safeBox = SafeBox.create(
171+
context = context,
172+
fileName = "${fileName}2",
173+
ioDispatcher = Dispatchers.IO,
174+
stateListener = SafeBoxStateListener {
175+
observedStates.add(it)
176+
shouldLoop = it != SafeBoxState.CLOSED
177+
},
178+
)
179+
repeat(5) {
180+
safeBox.edit()
181+
.putString("key", "value")
182+
.apply()
183+
}
184+
safeBox.closeWhenIdle()
185+
repeat(5) {
186+
safeBox.edit()
187+
.putString("key", "value")
188+
.apply()
189+
}
190+
while (shouldLoop) {
191+
Thread.sleep(3)
192+
}
193+
val expected = listOf(
194+
SafeBoxState.STARTING,
195+
SafeBoxState.WRITING,
196+
SafeBoxState.IDLE,
197+
SafeBoxState.CLOSED,
198+
)
199+
assertEquals(expected, observedStates)
200+
}
161201
}

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

Lines changed: 3 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
2222
import com.harrytmthy.safebox.extensions.toBytes
2323
import com.harrytmthy.safebox.state.SafeBoxState
2424
import com.harrytmthy.safebox.state.SafeBoxStateListener
25-
import kotlinx.coroutines.Dispatchers
25+
import com.harrytmthy.safebox.state.SafeBoxStateManager
2626
import kotlinx.coroutines.ExperimentalCoroutinesApi
27-
import kotlinx.coroutines.joinAll
28-
import kotlinx.coroutines.launch
2927
import kotlinx.coroutines.test.UnconfinedTestDispatcher
3028
import kotlinx.coroutines.test.runTest
3129
import org.junit.After
@@ -53,8 +51,7 @@ class SafeBoxBlobStoreTest {
5351
private val blobStore = SafeBoxBlobStore.create(
5452
context,
5553
fileName,
56-
UnconfinedTestDispatcher(),
57-
stateListener,
54+
SafeBoxStateManager(fileName, stateListener, UnconfinedTestDispatcher()),
5855
)
5956

6057
@After
@@ -221,64 +218,10 @@ class SafeBoxBlobStoreTest {
221218
}
222219

223220
@Test
224-
fun write_shouldEmitWritingAndIdleStates() = runTest {
225-
buildList {
226-
repeat(10) {
227-
add(
228-
launch(Dispatchers.Default) {
229-
blobStore.write("alpha".toByteArray().toBytes(), "123".toByteArray())
230-
},
231-
)
232-
}
233-
}.joinAll()
234-
235-
val expected = listOf(
236-
SafeBoxState.STARTING,
237-
SafeBoxState.IDLE,
238-
SafeBoxState.WRITING,
239-
SafeBoxState.IDLE,
240-
)
241-
assertEquals(expected, observedStates)
242-
}
243-
244-
@Test
245-
fun close_shouldEmitClosedState() {
246-
blobStore.close()
247-
221+
fun init_shouldEmitStartingState() {
248222
val expected = listOf(
249223
SafeBoxState.STARTING,
250224
SafeBoxState.IDLE,
251-
SafeBoxState.CLOSED,
252-
)
253-
assertEquals(expected, observedStates)
254-
}
255-
256-
@Test
257-
fun closeWhenIdle_shouldWaitUntilWritesAreDone() = runTest {
258-
buildList {
259-
repeat(5) {
260-
add(
261-
launch(Dispatchers.Default) {
262-
blobStore.write("alpha".toByteArray().toBytes(), "123".toByteArray())
263-
},
264-
)
265-
}
266-
add(launch(Dispatchers.Default) { blobStore.closeWhenIdle() })
267-
repeat(5) {
268-
add(
269-
launch(Dispatchers.Default) {
270-
blobStore.write("alpha".toByteArray().toBytes(), "123".toByteArray())
271-
},
272-
)
273-
}
274-
}.joinAll()
275-
276-
val expected = listOf(
277-
SafeBoxState.STARTING,
278-
SafeBoxState.IDLE,
279-
SafeBoxState.WRITING,
280-
SafeBoxState.IDLE,
281-
SafeBoxState.CLOSED,
282225
)
283226
assertEquals(expected, observedStates)
284227
}

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

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import com.harrytmthy.safebox.extensions.toBytes
3333
import com.harrytmthy.safebox.extensions.toEncodedByteArray
3434
import com.harrytmthy.safebox.keystore.SecureRandomKeyProvider
3535
import com.harrytmthy.safebox.registry.SafeBoxBlobFileRegistry
36-
import com.harrytmthy.safebox.state.SafeBoxState
3736
import com.harrytmthy.safebox.state.SafeBoxStateListener
37+
import com.harrytmthy.safebox.state.SafeBoxStateManager
3838
import com.harrytmthy.safebox.storage.Bytes
3939
import com.harrytmthy.safebox.storage.SafeBoxBlobStore
4040
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy
@@ -43,8 +43,6 @@ import kotlinx.coroutines.CompletableDeferred
4343
import kotlinx.coroutines.CoroutineDispatcher
4444
import kotlinx.coroutines.CoroutineExceptionHandler
4545
import kotlinx.coroutines.Dispatchers
46-
import kotlinx.coroutines.launch
47-
import kotlinx.coroutines.runBlocking
4846
import kotlinx.coroutines.sync.Mutex
4947
import kotlinx.coroutines.sync.withLock
5048
import java.util.concurrent.CopyOnWriteArrayList
@@ -70,13 +68,13 @@ import java.util.concurrent.atomic.AtomicReference
7068
* @param blobStore Internal storage engine managing encrypted key-value pairs.
7169
* @param keyCipherProvider Cipher used for encrypting and decrypting keys (deterministic).
7270
* @param valueCipherProvider Cipher used for encrypting and decrypting values (randomized).
73-
* @param ioDispatcher Coroutine dispatcher for IO operations.
71+
* @param stateManager Responsible for managing SafeBox lifecycle states and its concurrency.
7472
*/
7573
public class SafeBox private constructor(
7674
private val blobStore: SafeBoxBlobStore,
7775
private val keyCipherProvider: CipherProvider,
7876
private val valueCipherProvider: CipherProvider,
79-
private val ioDispatcher: CoroutineDispatcher,
77+
private val stateManager: SafeBoxStateManager,
8078
) : SharedPreferences {
8179

8280
private val castFailureStrategy = AtomicReference<ValueFallbackStrategy>(WARN)
@@ -100,7 +98,7 @@ public class SafeBox private constructor(
10098
}
10199

102100
override fun commit(entries: LinkedHashMap<String, Action>, cleared: Boolean): Boolean =
103-
runBlocking {
101+
stateManager.launchCommitWithWritingState {
104102
try {
105103
applyCompleted.await()
106104
commitMutex.withLock {
@@ -114,7 +112,7 @@ public class SafeBox private constructor(
114112
}
115113

116114
override fun apply(entries: LinkedHashMap<String, Action>, cleared: Boolean) {
117-
safeBoxScope.launch(ioDispatcher + exceptionHandler) {
115+
stateManager.launchApplyWithWritingState(exceptionHandler) {
118116
applyCompleted = CompletableDeferred()
119117
applyMutex.withLock {
120118
applyChanges(entries, cleared)
@@ -181,9 +179,7 @@ public class SafeBox private constructor(
181179
* becomes idle before releasing resources.
182180
*/
183181
public fun closeWhenIdle() {
184-
safeBoxScope.launch(ioDispatcher) {
185-
blobStore.closeWhenIdle()
186-
}
182+
stateManager.closeWhenIdle(::close)
187183
}
188184

189185
override fun getAll(): Map<String, Any?> {
@@ -372,7 +368,6 @@ public class SafeBox private constructor(
372368
stateListener: SafeBoxStateListener? = null,
373369
): SafeBox {
374370
SafeBoxBlobFileRegistry.register(fileName)
375-
stateListener?.onStateChanged(SafeBoxState.IDLE)
376371
val aesGcmCipherProvider = AesGcmCipherProvider.create(
377372
alias = valueKeyStoreAlias,
378373
aad = additionalAuthenticatedData,
@@ -386,8 +381,9 @@ public class SafeBox private constructor(
386381
)
387382
val keyCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = true)
388383
val valueCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = false)
389-
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher, stateListener)
390-
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, ioDispatcher)
384+
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
385+
val blobStore = SafeBoxBlobStore.create(context, fileName, stateManager)
386+
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
391387
}
392388

393389
/**
@@ -422,8 +418,9 @@ public class SafeBox private constructor(
422418
stateListener: SafeBoxStateListener? = null,
423419
): SafeBox {
424420
SafeBoxBlobFileRegistry.register(fileName)
425-
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher, stateListener)
426-
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, ioDispatcher)
421+
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
422+
val blobStore = SafeBoxBlobStore.create(context, fileName, stateManager)
423+
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
427424
}
428425
}
429426
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2025 Harry Timothy Tumalewa
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.harrytmthy.safebox.state
18+
19+
import com.harrytmthy.safebox.SafeBox
20+
import com.harrytmthy.safebox.extensions.safeBoxScope
21+
import com.harrytmthy.safebox.state.SafeBoxState.CLOSED
22+
import com.harrytmthy.safebox.state.SafeBoxState.IDLE
23+
import com.harrytmthy.safebox.state.SafeBoxState.STARTING
24+
import com.harrytmthy.safebox.state.SafeBoxState.WRITING
25+
import kotlinx.coroutines.CompletableDeferred
26+
import kotlinx.coroutines.CoroutineDispatcher
27+
import kotlinx.coroutines.CoroutineExceptionHandler
28+
import kotlinx.coroutines.launch
29+
import kotlinx.coroutines.runBlocking
30+
import java.util.concurrent.atomic.AtomicInteger
31+
import java.util.concurrent.atomic.AtomicReference
32+
33+
/**
34+
* Manages the lifecycle state of a [SafeBox] instance and coordinates concurrent read/write access.
35+
*
36+
* This class emits state changes to both the instance-bound [SafeBoxStateListener] and the global
37+
* [SafeBoxGlobalStateObserver].
38+
*
39+
* Key behaviors:
40+
* - Tracks concurrent writes using an atomic counter.
41+
* - Waits for the blob store's initial read before permitting writes or close.
42+
* - Guarantees transition to [IDLE] after all writes complete.
43+
* - Supports safe, deferred closing via [closeWhenIdle], ensuring no writes are in progress.
44+
*
45+
* @param fileName The unique file identifier associated with this SafeBox instance.
46+
* @param stateListener Optional listener for observing state transitions on this instance.
47+
* @param ioDispatcher Dispatcher used for coroutine-based I/O tasks.
48+
*/
49+
internal class SafeBoxStateManager(
50+
private val fileName: String,
51+
private val stateListener: SafeBoxStateListener?,
52+
private val ioDispatcher: CoroutineDispatcher,
53+
) {
54+
55+
private val concurrentWriteCount = AtomicInteger(0)
56+
57+
private val initialReadCompleted = CompletableDeferred<Unit>()
58+
59+
private val writeCompleted = AtomicReference<CompletableDeferred<Unit>>()
60+
61+
inline fun launchWithStartingState(crossinline block: suspend () -> Unit) {
62+
updateState(STARTING)
63+
safeBoxScope.launch(ioDispatcher) {
64+
block()
65+
if (concurrentWriteCount.get() == 0) {
66+
updateState(IDLE)
67+
} else {
68+
updateState(WRITING)
69+
}
70+
initialReadCompleted.complete(Unit)
71+
}
72+
}
73+
74+
inline fun launchCommitWithWritingState(crossinline block: suspend () -> Boolean): Boolean {
75+
if (concurrentWriteCount.incrementAndGet() == 1) {
76+
writeCompleted.set(CompletableDeferred())
77+
if (initialReadCompleted.isCompleted) {
78+
updateState(WRITING)
79+
}
80+
}
81+
return runBlocking {
82+
initialReadCompleted.await()
83+
val result = block()
84+
finalizeWriting()
85+
result
86+
}
87+
}
88+
89+
inline fun launchApplyWithWritingState(
90+
exceptionHandler: CoroutineExceptionHandler,
91+
crossinline block: suspend () -> Unit,
92+
) {
93+
if (concurrentWriteCount.incrementAndGet() == 1) {
94+
writeCompleted.set(CompletableDeferred())
95+
if (initialReadCompleted.isCompleted) {
96+
updateState(WRITING)
97+
}
98+
}
99+
safeBoxScope.launch(ioDispatcher + exceptionHandler) {
100+
initialReadCompleted.await()
101+
block()
102+
}.invokeOnCompletion {
103+
finalizeWriting()
104+
}
105+
}
106+
107+
inline fun closeWhenIdle(crossinline block: () -> Unit) {
108+
if (concurrentWriteCount.get() == 0 && initialReadCompleted.isCompleted) {
109+
block()
110+
updateState(CLOSED)
111+
return
112+
}
113+
safeBoxScope.launch(ioDispatcher) {
114+
initialReadCompleted.await()
115+
writeCompleted.get()?.await()
116+
block()
117+
updateState(CLOSED)
118+
}
119+
}
120+
121+
private fun updateState(newState: SafeBoxState) {
122+
stateListener?.onStateChanged(newState)
123+
SafeBoxGlobalStateObserver.updateState(fileName, newState)
124+
}
125+
126+
private fun finalizeWriting() {
127+
if (concurrentWriteCount.decrementAndGet() == 0) {
128+
updateState(IDLE)
129+
writeCompleted.get()?.complete(Unit)
130+
}
131+
}
132+
}

0 commit comments

Comments
 (0)