Skip to content

Commit 9cf93cb

Browse files
authored
feat(storage): Add instance-bound SafeBoxStateListener support to SafeBoxBlobStore (#13)
1 parent ef244a2 commit 9cf93cb

File tree

5 files changed

+146
-7
lines changed

5 files changed

+146
-7
lines changed

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ 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
2325
import kotlinx.coroutines.ExperimentalCoroutinesApi
2426
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2527
import kotlinx.coroutines.test.runTest
2628
import org.junit.After
2729
import org.junit.runner.RunWith
2830
import java.io.File
31+
import java.util.concurrent.CopyOnWriteArrayList
2932
import kotlin.test.Test
3033
import kotlin.test.assertContentEquals
3134
import kotlin.test.assertEquals
@@ -40,12 +43,22 @@ class SafeBoxBlobStoreTest {
4043

4144
private val fileName: String = "safebox_blob_test"
4245

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

4557
@After
4658
fun teardown() {
4759
blobStore.close()
4860
File(context.noBackupFilesDir, "$fileName.bin").delete()
61+
observedStates.clear()
4962
}
5063

5164
@Test
@@ -203,4 +216,21 @@ class SafeBoxBlobStoreTest {
203216
fun getFileName_shouldReturnFileName() {
204217
assertEquals(fileName, blobStore.getFileName())
205218
}
219+
220+
@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)
228+
}
229+
230+
@Test
231+
fun close_shouldEmitClosedState() {
232+
blobStore.close()
233+
234+
assertEquals(SafeBoxState.CLOSED, observedStates.last())
235+
}
206236
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +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
37+
import com.harrytmthy.safebox.state.SafeBoxStateListener
3638
import com.harrytmthy.safebox.storage.Bytes
3739
import com.harrytmthy.safebox.storage.SafeBoxBlobStore
3840
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy
@@ -333,6 +335,7 @@ public class SafeBox private constructor(
333335
* @param valueKeyStoreAlias The Android Keystore alias used for AES-GCM key generation
334336
* @param additionalAuthenticatedData Optional AAD bound to the AES-GCM (default: fileName)
335337
* @param ioDispatcher The dispatcher used for I/O operations (default: [Dispatchers.IO])
338+
* @param stateListener The listener to observe instance-bound state transitions
336339
*
337340
* @return A fully configured [SafeBox] instance
338341
* @throws IllegalStateException if the file is already registered.
@@ -347,8 +350,10 @@ public class SafeBox private constructor(
347350
valueKeyStoreAlias: String = DEFAULT_VALUE_KEYSTORE_ALIAS,
348351
additionalAuthenticatedData: ByteArray = fileName.toByteArray(),
349352
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
353+
stateListener: SafeBoxStateListener? = null,
350354
): SafeBox {
351355
SafeBoxBlobFileRegistry.register(fileName)
356+
stateListener?.onStateChanged(SafeBoxState.IDLE)
352357
val aesGcmCipherProvider = AesGcmCipherProvider.create(
353358
alias = valueKeyStoreAlias,
354359
aad = additionalAuthenticatedData,
@@ -362,7 +367,7 @@ public class SafeBox private constructor(
362367
)
363368
val keyCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = true)
364369
val valueCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = false)
365-
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher)
370+
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher, stateListener)
366371
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, ioDispatcher)
367372
}
368373

@@ -381,6 +386,7 @@ public class SafeBox private constructor(
381386
* @param keyCipherProvider Cipher used for encrypting and decrypting keys
382387
* @param valueCipherProvider Cipher used for encrypting and decrypting values
383388
* @param ioDispatcher The dispatcher used for I/O operations (default: [Dispatchers.IO])
389+
* @param stateListener The listener to observe instance-bound state transitions
384390
*
385391
* @return A [SafeBox] instance with the provided [CipherProvider]
386392
* @throws IllegalStateException if the file is already registered.
@@ -394,9 +400,10 @@ public class SafeBox private constructor(
394400
keyCipherProvider: CipherProvider,
395401
valueCipherProvider: CipherProvider,
396402
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
403+
stateListener: SafeBoxStateListener? = null,
397404
): SafeBox {
398405
SafeBoxBlobFileRegistry.register(fileName)
399-
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher)
406+
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher, stateListener)
400407
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, ioDispatcher)
401408
}
402409
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
21+
/**
22+
* Represents the current lifecycle state of a [SafeBox] instance.
23+
*
24+
* These states are exposed through [SafeBoxStateListener] and help consumers track
25+
* SafeBox activity, especially during asynchronous operations.
26+
*
27+
* @see SafeBoxStateListener
28+
*/
29+
public enum class SafeBoxState {
30+
31+
/**
32+
* Indicates that SafeBox is idle and not currently writing to disk.
33+
* This is the default resting state.
34+
*/
35+
IDLE,
36+
37+
/**
38+
* Indicates that SafeBox is performing a write operation.
39+
* Avoid closing or deleting the SafeBox during this time.
40+
*/
41+
WRITING,
42+
43+
/**
44+
* Indicates that SafeBox has been closed and is no longer usable.
45+
* Once closed, a SafeBox instance cannot be reused.
46+
*/
47+
CLOSED,
48+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
/**
20+
* A listener interface for observing [SafeBoxState] changes tied to a specific SafeBox file.
21+
*
22+
* This is typically used in non-singleton SafeBox use cases (e.g. ViewModel-scoped),
23+
* where consumers need to track if the instance is writing, idle, or closed.
24+
*
25+
* @see SafeBoxState
26+
*/
27+
public fun interface SafeBoxStateListener {
28+
29+
/**
30+
* Called whenever there is a state update.
31+
*
32+
* @param state The latest state of the SafeBox instance.
33+
*/
34+
fun onStateChanged(state: SafeBoxState)
35+
}

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ 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.SafeBoxState
25+
import com.harrytmthy.safebox.state.SafeBoxStateListener
2426
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy
2527
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy.ERROR
2628
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy.WARN
@@ -46,6 +48,7 @@ import java.util.concurrent.atomic.AtomicReference
4648
internal class SafeBoxBlobStore private constructor(
4749
ioDispatcher: CoroutineDispatcher,
4850
private val file: File,
51+
private val stateListener: SafeBoxStateListener?,
4952
) {
5053

5154
private val channel = RandomAccessFile(file, "rw").channel
@@ -121,7 +124,7 @@ internal class SafeBoxBlobStore private constructor(
121124
* @throws IllegalStateException if the blob file does not have enough remaining capacity.
122125
*/
123126
internal suspend fun write(encryptedKey: Bytes, encryptedValue: ByteArray) {
124-
writeMutex.withLock {
127+
writeMutex.withLockAndStateUpdates {
125128
if (!entryMetas.contains(encryptedKey)) {
126129
writeAtOffset(encryptedKey, encryptedValue)
127130
} else {
@@ -139,7 +142,7 @@ internal class SafeBoxBlobStore private constructor(
139142
* @param encryptedKeys Vararg array of keys to delete.
140143
*/
141144
internal suspend fun delete(vararg encryptedKeys: Bytes) {
142-
writeMutex.withLock {
145+
writeMutex.withLockAndStateUpdates {
143146
val metas = entryMetas.values.toList()
144147
for (encryptedKey in encryptedKeys) {
145148
val currentIndex = entryMetas.keys.indexOf(encryptedKey)
@@ -170,7 +173,7 @@ internal class SafeBoxBlobStore private constructor(
170173
* @return a set of [Bytes] keys that were removed, used for notifying listeners.
171174
*/
172175
internal suspend fun deleteAll(): Set<Bytes> =
173-
writeMutex.withLock {
176+
writeMutex.withLockAndStateUpdates {
174177
buffer.position(0)
175178
buffer.put(ByteArray(nextWritePosition))
176179
buffer.force()
@@ -196,6 +199,7 @@ internal class SafeBoxBlobStore private constructor(
196199
*/
197200
internal fun close() {
198201
channel.close()
202+
stateListener?.onStateChanged(SafeBoxState.CLOSED)
199203
}
200204

201205
private fun writeAtOffset(
@@ -280,6 +284,20 @@ internal class SafeBoxBlobStore private constructor(
280284
}
281285
}
282286

287+
/**
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.
292+
*/
293+
private suspend inline fun <T> Mutex.withLockAndStateUpdates(crossinline action: () -> T): T =
294+
withLock {
295+
stateListener?.onStateChanged(SafeBoxState.WRITING)
296+
val result = action()
297+
stateListener?.onStateChanged(SafeBoxState.IDLE)
298+
result
299+
}
300+
283301
@VisibleForTesting
284302
internal data class EntryMeta(val offset: Int, val size: Int)
285303

@@ -291,12 +309,13 @@ internal class SafeBoxBlobStore private constructor(
291309
context: Context,
292310
fileName: String,
293311
ioDispatcher: CoroutineDispatcher,
312+
stateListener: SafeBoxStateListener?,
294313
): SafeBoxBlobStore {
295314
val file = File(context.noBackupFilesDir, "$fileName.bin")
296315
if (!file.exists()) {
297316
file.createNewFile()
298317
}
299-
return SafeBoxBlobStore(ioDispatcher, file)
318+
return SafeBoxBlobStore(ioDispatcher, file, stateListener)
300319
}
301320
}
302321
}

0 commit comments

Comments
 (0)