Skip to content

Commit ef244a2

Browse files
authored
feat(registry): Add SafeBoxBlobFileRegistry to enforce single instance per file (#11)
1 parent a5a5c41 commit ef244a2

File tree

8 files changed

+146
-92
lines changed

8 files changed

+146
-92
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class SafeBoxTest {
5353
safeBox.edit()
5454
.clear()
5555
.commit()
56+
safeBox.close()
5657

5758
KeyStore.getInstance("AndroidKeyStore").apply {
5859
load(null)

safebox/src/androidTest/java/com/harrytmthy/safebox/migration/SafeBoxMigrationHelperTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class SafeBoxMigrationHelperTest {
6464
esp.edit()
6565
.clear()
6666
.commit()
67+
safeBox.close()
6768
context.deleteSharedPreferences(fileEsp)
6869
context.deleteFile("$fileSafeBox.bin")
6970
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,9 @@ class SafeBoxBlobStoreTest {
198198
assertContentEquals(secondValue, result[key])
199199
assertTrue(blobStore.entryMetas.containsKey(key))
200200
}
201+
202+
@Test
203+
fun getFileName_shouldReturnFileName() {
204+
assertEquals(fileName, blobStore.getFileName())
205+
}
201206
}

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.harrytmthy.safebox.extensions.safeBoxScope
3232
import com.harrytmthy.safebox.extensions.toBytes
3333
import com.harrytmthy.safebox.extensions.toEncodedByteArray
3434
import com.harrytmthy.safebox.keystore.SecureRandomKeyProvider
35+
import com.harrytmthy.safebox.registry.SafeBoxBlobFileRegistry
3536
import com.harrytmthy.safebox.storage.Bytes
3637
import com.harrytmthy.safebox.storage.SafeBoxBlobStore
3738
import com.harrytmthy.safebox.strategy.ValueFallbackStrategy
@@ -150,12 +151,17 @@ public class SafeBox private constructor(
150151
}
151152

152153
/**
153-
* Releases resources and closes the underlying file channel.
154+
* Immediately closes the underlying file channel and releases resources.
155+
* Also unregisters the file from [SafeBoxBlobFileRegistry], allowing a new SafeBox
156+
* instance to be created with the same filename.
154157
*
155-
* Call this when SafeBox is no longer in use to ensure file handles are cleaned up.
156-
* Failing to do so may result in resource leaks or file corruption.
158+
* ⚠️ Once closed, this instance becomes *permanently unusable*. Any further access will fail.
159+
*
160+
* ⚠️ Only use this method when you're certain that no writes are in progress.
161+
* Closing during an active write can result in data corruption or incomplete persistence.
157162
*/
158163
public fun close() {
164+
SafeBoxBlobFileRegistry.unregister(blobStore.getFileName())
159165
blobStore.close()
160166
}
161167

@@ -329,9 +335,11 @@ public class SafeBox private constructor(
329335
* @param ioDispatcher The dispatcher used for I/O operations (default: [Dispatchers.IO])
330336
*
331337
* @return A fully configured [SafeBox] instance
338+
* @throws IllegalStateException if the file is already registered.
332339
*/
333340
@JvmOverloads
334341
@JvmStatic
342+
@Throws(IllegalStateException::class)
335343
public fun create(
336344
context: Context,
337345
fileName: String,
@@ -340,6 +348,7 @@ public class SafeBox private constructor(
340348
additionalAuthenticatedData: ByteArray = fileName.toByteArray(),
341349
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
342350
): SafeBox {
351+
SafeBoxBlobFileRegistry.register(fileName)
343352
val aesGcmCipherProvider = AesGcmCipherProvider.create(
344353
alias = valueKeyStoreAlias,
345354
aad = additionalAuthenticatedData,
@@ -353,7 +362,8 @@ public class SafeBox private constructor(
353362
)
354363
val keyCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = true)
355364
val valueCipherProvider = ChaCha20CipherProvider(keyProvider, deterministic = false)
356-
return create(context, fileName, keyCipherProvider, valueCipherProvider, ioDispatcher)
365+
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher)
366+
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, ioDispatcher)
357367
}
358368

359369
/**
@@ -373,16 +383,19 @@ public class SafeBox private constructor(
373383
* @param ioDispatcher The dispatcher used for I/O operations (default: [Dispatchers.IO])
374384
*
375385
* @return A [SafeBox] instance with the provided [CipherProvider]
386+
* @throws IllegalStateException if the file is already registered.
376387
*/
377388
@JvmOverloads
378389
@JvmStatic
390+
@Throws(IllegalStateException::class)
379391
public fun create(
380392
context: Context,
381393
fileName: String,
382394
keyCipherProvider: CipherProvider,
383395
valueCipherProvider: CipherProvider,
384396
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
385397
): SafeBox {
398+
SafeBoxBlobFileRegistry.register(fileName)
386399
val blobStore = SafeBoxBlobStore.create(context, fileName, ioDispatcher)
387400
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, ioDispatcher)
388401
}

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

Lines changed: 0 additions & 84 deletions
This file was deleted.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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.registry
18+
19+
import com.harrytmthy.safebox.SafeBox
20+
import java.util.Collections
21+
import java.util.concurrent.ConcurrentHashMap
22+
23+
/**
24+
* An internal registry that tracks active SafeBox blob files.
25+
*
26+
* Prevents multiple [SafeBox] instances from accessing the same file simultaneously.
27+
* This ensures thread safety and prevents corruption due to concurrent `FileChannel` access.
28+
*
29+
* This registry is internal-only and not intended for external observation.
30+
* Please use [SafeBoxStateObserver] to listen for state changes.
31+
*/
32+
internal object SafeBoxBlobFileRegistry {
33+
34+
private val registry: MutableSet<String> = Collections.newSetFromMap(ConcurrentHashMap())
35+
36+
/**
37+
* Registers the given file name as currently in use.
38+
*
39+
* @param fileName The file name associated with a SafeBox instance.
40+
* @throws IllegalStateException if the file is already registered.
41+
*/
42+
@Throws(IllegalStateException::class)
43+
fun register(fileName: String) {
44+
if (registry.contains(fileName)) {
45+
error("SafeBox with file name '$fileName' is already in use. Please close it first.")
46+
}
47+
registry.add(fileName)
48+
}
49+
50+
/**
51+
* Unregisters the given file name, allowing the creation of a new SafeBox instances with
52+
* an existing file name.
53+
*
54+
* @param fileName The file name to unregister.
55+
*/
56+
fun unregister(fileName: String) {
57+
registry.remove(fileName)
58+
}
59+
}

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ import java.util.concurrent.atomic.AtomicReference
4545
*/
4646
internal class SafeBoxBlobStore private constructor(
4747
ioDispatcher: CoroutineDispatcher,
48-
private val channel: FileChannel,
48+
private val file: File,
4949
) {
5050

51+
private val channel = RandomAccessFile(file, "rw").channel
52+
5153
private val buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, BUFFER_CAPACITY.toLong())
5254

5355
private val entries = HashMap<Bytes, ByteArray>()
@@ -167,7 +169,7 @@ internal class SafeBoxBlobStore private constructor(
167169
*
168170
* @return a set of [Bytes] keys that were removed, used for notifying listeners.
169171
*/
170-
suspend fun deleteAll(): Set<Bytes> =
172+
internal suspend fun deleteAll(): Set<Bytes> =
171173
writeMutex.withLock {
172174
buffer.position(0)
173175
buffer.put(ByteArray(nextWritePosition))
@@ -178,6 +180,16 @@ internal class SafeBoxBlobStore private constructor(
178180
keys
179181
}
180182

183+
/**
184+
* Returns the name of the backing file, excluding its extension.
185+
*
186+
* This is used by the internal registry to uniquely identify open SafeBox instances
187+
* and prevent concurrent access to the same file.
188+
*
189+
* @return The file name without extension.
190+
*/
191+
internal fun getFileName(): String = file.nameWithoutExtension
192+
181193
/**
182194
* Closes the underlying file channel and releases associated resources.
183195
* Must be called when SafeBoxBlobStore is no longer in use to prevent memory leaks.
@@ -284,8 +296,7 @@ internal class SafeBoxBlobStore private constructor(
284296
if (!file.exists()) {
285297
file.createNewFile()
286298
}
287-
val raf = RandomAccessFile(file, "rw")
288-
return SafeBoxBlobStore(ioDispatcher, raf.channel)
299+
return SafeBoxBlobStore(ioDispatcher, file)
289300
}
290301
}
291302
}
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.registry
18+
19+
import kotlin.test.Test
20+
import kotlin.test.assertFailsWith
21+
22+
class SafeBoxBlobFileRegistryTest {
23+
24+
@Test
25+
fun `register and unregister should succeed`() {
26+
val fileName = "test_file"
27+
28+
SafeBoxBlobFileRegistry.register(fileName)
29+
SafeBoxBlobFileRegistry.unregister(fileName)
30+
SafeBoxBlobFileRegistry.register(fileName)
31+
}
32+
33+
@Test
34+
fun `register twice should throw IllegalStateException`() {
35+
val fileName = "duplicate_file"
36+
37+
SafeBoxBlobFileRegistry.register(fileName)
38+
39+
assertFailsWith<IllegalStateException> { SafeBoxBlobFileRegistry.register(fileName) }
40+
}
41+
42+
@Test
43+
fun `unregister non-registered file should succeed`() {
44+
val fileName = "nonexistent_file"
45+
46+
SafeBoxBlobFileRegistry.unregister(fileName)
47+
}
48+
}

0 commit comments

Comments
 (0)