Skip to content

Commit 6cfa07e

Browse files
authored
feat(state): Add SafeBoxGlobalStateObserver and closeWhenIdle support (#14)
1 parent 9cf93cb commit 6cfa07e

File tree

7 files changed

+293
-23
lines changed

7 files changed

+293
-23
lines changed

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

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ 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
2526
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.joinAll
28+
import kotlinx.coroutines.launch
29+
import kotlinx.coroutines.runBlocking
2630
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2731
import kotlinx.coroutines.test.runTest
2832
import org.junit.After
@@ -218,19 +222,65 @@ class SafeBoxBlobStoreTest {
218222
}
219223

220224
@Test
221-
fun write_shouldEmitWritingAndIdleStates() = runTest {
222-
val key = "alpha".toByteArray().toBytes()
223-
val value = "123".toByteArray()
224-
225-
blobStore.write(key, value)
226-
227-
assertEquals(listOf(SafeBoxState.WRITING, SafeBoxState.IDLE), observedStates)
225+
fun write_shouldEmitWritingAndIdleStates() = runBlocking {
226+
buildList {
227+
repeat(10) {
228+
add(
229+
launch(Dispatchers.Default) {
230+
blobStore.write("alpha".toByteArray().toBytes(), "123".toByteArray())
231+
},
232+
)
233+
}
234+
}.joinAll()
235+
236+
val expected = listOf(
237+
SafeBoxState.STARTING,
238+
SafeBoxState.IDLE,
239+
SafeBoxState.WRITING,
240+
SafeBoxState.IDLE,
241+
)
242+
assertEquals(expected, observedStates)
228243
}
229244

230245
@Test
231246
fun close_shouldEmitClosedState() {
232247
blobStore.close()
233248

234-
assertEquals(SafeBoxState.CLOSED, observedStates.last())
249+
val expected = listOf(
250+
SafeBoxState.STARTING,
251+
SafeBoxState.IDLE,
252+
SafeBoxState.CLOSED,
253+
)
254+
assertEquals(expected, observedStates)
255+
}
256+
257+
@Test
258+
fun closeWhenIdle_shouldWaitUntilWritesAreDone() = runTest {
259+
buildList {
260+
repeat(5) {
261+
add(
262+
launch(Dispatchers.Default) {
263+
blobStore.write("alpha".toByteArray().toBytes(), "123".toByteArray())
264+
},
265+
)
266+
}
267+
add(launch(Dispatchers.Default) { blobStore.closeWhenIdle() })
268+
repeat(5) {
269+
add(
270+
launch(Dispatchers.Default) {
271+
blobStore.write("alpha".toByteArray().toBytes(), "123".toByteArray())
272+
},
273+
)
274+
}
275+
}.joinAll()
276+
277+
val expected = listOf(
278+
SafeBoxState.STARTING,
279+
SafeBoxState.IDLE,
280+
SafeBoxState.WRITING,
281+
SafeBoxState.IDLE,
282+
SafeBoxState.CLOSED,
283+
)
284+
assertEquals(expected, observedStates)
235285
}
236286
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,32 @@ public class SafeBox private constructor(
160160
* ⚠️ Once closed, this instance becomes *permanently unusable*. Any further access will fail.
161161
*
162162
* ⚠️ Only use this method when you're certain that no writes are in progress.
163+
*
163164
* Closing during an active write can result in data corruption or incomplete persistence.
164165
*/
165166
public fun close() {
166167
SafeBoxBlobFileRegistry.unregister(blobStore.getFileName())
167168
blobStore.close()
168169
}
169170

171+
/**
172+
* Closes the underlying file channel only after all pending writes have completed.
173+
* Also unregisters the file from [SafeBoxBlobFileRegistry], allowing a new SafeBox
174+
* instance to be created with the same filename.
175+
*
176+
* ⚠️ Once closed, this instance becomes *permanently unusable*. Any further access will fail.
177+
*
178+
* ✅ This is the recommended way to dispose of SafeBox in async environments.
179+
*
180+
* Internally, this launches a coroutine on [safeBoxScope] to wait until the SafeBox
181+
* becomes idle before releasing resources.
182+
*/
183+
public fun closeWhenIdle() {
184+
safeBoxScope.launch(ioDispatcher) {
185+
blobStore.closeWhenIdle()
186+
}
187+
}
188+
170189
override fun getAll(): Map<String, Any?> {
171190
val encryptedEntries = blobStore.getAll()
172191
val decryptedEntries = HashMap<String, Any?>(encryptedEntries.size, 1f)

safebox/src/main/java/com/harrytmthy/safebox/registry/SafeBoxBlobFileRegistry.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.harrytmthy.safebox.registry
1818

1919
import com.harrytmthy.safebox.SafeBox
20+
import com.harrytmthy.safebox.state.SafeBoxGlobalStateObserver
2021
import java.util.Collections
2122
import java.util.concurrent.ConcurrentHashMap
2223

@@ -27,7 +28,7 @@ import java.util.concurrent.ConcurrentHashMap
2728
* This ensures thread safety and prevents corruption due to concurrent `FileChannel` access.
2829
*
2930
* This registry is internal-only and not intended for external observation.
30-
* Please use [SafeBoxStateObserver] to listen for state changes.
31+
* Please use [SafeBoxGlobalStateObserver] to listen for state changes.
3132
*/
3233
internal object SafeBoxBlobFileRegistry {
3334

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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.state.SafeBoxGlobalStateObserver.updateState
20+
import java.util.concurrent.ConcurrentHashMap
21+
import java.util.concurrent.CopyOnWriteArraySet
22+
23+
/**
24+
* Public observer interface for monitoring SafeBox state changes.
25+
*
26+
* Allows clients to observe SafeBox's lifecycle state transitions for a given file name,
27+
* enabling safer orchestration in multi-screen or asynchronous apps.
28+
*/
29+
public object SafeBoxGlobalStateObserver {
30+
31+
private val stateHolder = ConcurrentHashMap<String, SafeBoxState>()
32+
33+
private val listeners = ConcurrentHashMap<String, CopyOnWriteArraySet<SafeBoxStateListener>>()
34+
35+
/**
36+
* Returns the most recently known state of the SafeBox associated with the given file name.
37+
*
38+
* This value reflects the latest emitted state via [updateState], even if no active listener
39+
* was registered at the time of the change.
40+
*
41+
* @param fileName The file name being observed.
42+
* @return The last known [SafeBoxState], or `null` if the file name has never been registered.
43+
*/
44+
@JvmStatic
45+
public fun getCurrentState(fileName: String): SafeBoxState? =
46+
stateHolder[fileName]
47+
48+
/**
49+
* Adds a listener for the given SafeBox file name. The listener will immediately receive
50+
* the current state (if any), followed by all future updates.
51+
*
52+
* @param fileName The name of the SafeBox file to observe.
53+
* @param listener The listener to be notified of state changes.
54+
*/
55+
@JvmStatic
56+
public fun addListener(fileName: String, listener: SafeBoxStateListener) {
57+
listeners.getOrPut(fileName, defaultValue = { CopyOnWriteArraySet() }).add(listener)
58+
stateHolder[fileName]?.let(listener::onStateChanged)
59+
}
60+
61+
/**
62+
* Removes a previously registered listener for a specific file.
63+
*
64+
* @param fileName The file name being observed.
65+
* @param listener The listener to remove.
66+
*/
67+
@JvmStatic
68+
public fun removeListener(fileName: String, listener: SafeBoxStateListener) {
69+
listeners[fileName]?.remove(listener)
70+
}
71+
72+
/**
73+
* Emits a new state for the given file name, updating internal records
74+
* and notifying all registered listeners for that file.
75+
*
76+
* This method is called internally by SafeBox and SafeBoxBlobFileRegistry
77+
* to reflect changes in the instance's lifecycle.
78+
*
79+
* @param fileName The file name whose state has changed.
80+
* @param newState The new [SafeBoxState] to emit.
81+
*/
82+
internal fun updateState(fileName: String, newState: SafeBoxState) {
83+
stateHolder[fileName] = newState
84+
listeners[fileName]?.forEach { it.onStateChanged(newState) }
85+
}
86+
}

safebox/src/main/java/com/harrytmthy/safebox/state/SafeBoxState.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,29 @@ import com.harrytmthy.safebox.SafeBox
2929
public enum class SafeBoxState {
3030

3131
/**
32-
* Indicates that SafeBox is idle and not currently writing to disk.
33-
* This is the default resting state.
32+
* Indicates that SafeBox has been successfully created and currently loading persisted data
33+
* from disk into memory.
34+
*
35+
* During this state, SafeBox is not yet ready to serve read or write operations.
36+
* Any read/write call will be suspended until the state transitions to [IDLE].
37+
*/
38+
STARTING,
39+
40+
/**
41+
* Indicates that SafeBox is ready and currently idle, with no active write operations.
42+
* This is the default state after initialization completes and between writes.
3443
*/
3544
IDLE,
3645

3746
/**
38-
* Indicates that SafeBox is performing a write operation.
47+
* Indicates that SafeBox is currently writing data to disk.
3948
* Avoid closing or deleting the SafeBox during this time.
4049
*/
4150
WRITING,
4251

4352
/**
4453
* Indicates that SafeBox has been closed and is no longer usable.
45-
* Once closed, a SafeBox instance cannot be reused.
54+
* To access the same file again, a new SafeBox instance must be created.
4655
*/
4756
CLOSED,
4857
}

safebox/src/main/java/com/harrytmthy/safebox/storage/SafeBoxBlobStore.kt

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,22 @@ import android.util.Log
2121
import androidx.annotation.VisibleForTesting
2222
import com.harrytmthy.safebox.extensions.safeBoxScope
2323
import com.harrytmthy.safebox.extensions.toBytes
24+
import com.harrytmthy.safebox.state.SafeBoxGlobalStateObserver
25+
import com.harrytmthy.safebox.state.SafeBoxGlobalStateObserver.getCurrentState
2426
import com.harrytmthy.safebox.state.SafeBoxState
27+
import com.harrytmthy.safebox.state.SafeBoxState.CLOSED
28+
import com.harrytmthy.safebox.state.SafeBoxState.IDLE
29+
import com.harrytmthy.safebox.state.SafeBoxState.STARTING
30+
import com.harrytmthy.safebox.state.SafeBoxState.WRITING
2531
import com.harrytmthy.safebox.state.SafeBoxStateListener
2632
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy
2733
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy.ERROR
2834
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy.WARN
2935
import kotlinx.coroutines.CoroutineDispatcher
36+
import kotlinx.coroutines.flow.MutableStateFlow
37+
import kotlinx.coroutines.flow.dropWhile
38+
import kotlinx.coroutines.flow.first
39+
import kotlinx.coroutines.flow.update
3040
import kotlinx.coroutines.launch
3141
import kotlinx.coroutines.sync.Mutex
3242
import kotlinx.coroutines.sync.withLock
@@ -66,12 +76,14 @@ internal class SafeBoxBlobStore private constructor(
6676

6777
private val writeMutex = Mutex()
6878

79+
private val pendingWriteCount = MutableStateFlow(0)
80+
6981
private val nextWritePosition: Int
7082
get() = entryMetas.values.lastOrNull()?.run { offset + size } ?: 0
7183

7284
init {
7385
safeBoxScope.launch(ioDispatcher) {
74-
writeMutex.withLock {
86+
writeMutex.withLockAndStateUpdates(initialState = STARTING) {
7587
var offset = 0
7688
while (offset + HEADER_SIZE <= buffer.capacity()) {
7789
buffer.position(offset)
@@ -199,7 +211,17 @@ internal class SafeBoxBlobStore private constructor(
199211
*/
200212
internal fun close() {
201213
channel.close()
202-
stateListener?.onStateChanged(SafeBoxState.CLOSED)
214+
stateListener?.onStateChanged(CLOSED)
215+
SafeBoxGlobalStateObserver.updateState(getFileName(), CLOSED)
216+
}
217+
218+
internal suspend fun closeWhenIdle() {
219+
if (pendingWriteCount.value == 0 || getCurrentState(getFileName()) == STARTING) {
220+
close()
221+
return
222+
}
223+
pendingWriteCount.dropWhile { it != 0 }.first()
224+
close()
203225
}
204226

205227
private fun writeAtOffset(
@@ -285,18 +307,28 @@ internal class SafeBoxBlobStore private constructor(
285307
}
286308

287309
/**
288-
* Wraps the given [action] with mutex locking and emits SafeBox state transitions.
289-
*
290-
* This ensures that any critical write operation is surrounded by `WRITING` and `IDLE` events,
291-
* which helps external listeners track write progress.
310+
* Wraps the given [action] with a mutex lock and emits corresponding state transitions.
311+
* This allows external listeners to track internal state changes.
292312
*/
293-
private suspend inline fun <T> Mutex.withLockAndStateUpdates(crossinline action: () -> T): T =
294-
withLock {
295-
stateListener?.onStateChanged(SafeBoxState.WRITING)
313+
private suspend inline fun <T> Mutex.withLockAndStateUpdates(
314+
initialState: SafeBoxState = WRITING,
315+
crossinline action: () -> T,
316+
): T {
317+
pendingWriteCount.update { it + 1 }
318+
return withLock {
319+
if (getCurrentState(getFileName()) != initialState) {
320+
stateListener?.onStateChanged(initialState)
321+
SafeBoxGlobalStateObserver.updateState(getFileName(), initialState)
322+
}
296323
val result = action()
297-
stateListener?.onStateChanged(SafeBoxState.IDLE)
324+
pendingWriteCount.update { it - 1 }
325+
if (pendingWriteCount.value == 0) {
326+
stateListener?.onStateChanged(IDLE)
327+
SafeBoxGlobalStateObserver.updateState(getFileName(), IDLE)
328+
}
298329
result
299330
}
331+
}
300332

301333
@VisibleForTesting
302334
internal data class EntryMeta(val offset: Int, val size: Int)

0 commit comments

Comments
 (0)