Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,16 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.harrytmthy.safebox.SafeBox.Companion.DEFAULT_KEY_ALIAS
import com.harrytmthy.safebox.SafeBox.Companion.DEFAULT_VALUE_KEYSTORE_ALIAS
import com.harrytmthy.safebox.state.SafeBoxState
import com.harrytmthy.safebox.state.SafeBoxStateListener
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.runner.RunWith
import java.io.File
import java.security.KeyStore
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

Expand All @@ -56,15 +48,6 @@ class SafeBoxTest {
safeBox.edit()
.clear()
.commit()
safeBox.close()

KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
deleteEntry(DEFAULT_VALUE_KEYSTORE_ALIAS)
}

File(context.noBackupFilesDir, "$fileName.bin").delete()
File(context.noBackupFilesDir, "$DEFAULT_KEY_ALIAS.bin").delete()
}

@Test
Expand Down Expand Up @@ -179,68 +162,6 @@ class SafeBoxTest {
assertContentEquals(expectedValueChanges, changedValues)
}

@Test
fun closeWhenIdle_shouldWaitUntilWritesAreDoneBeforeClosing() {
val observedStates = CopyOnWriteArrayList<SafeBoxState>()
val closed = AtomicBoolean(false)
safeBox = createSafeBox(
ioDispatcher = Dispatchers.IO,
stateListener = SafeBoxStateListener { state ->
observedStates.add(state)
closed.set(state == SafeBoxState.CLOSED)
},
)
repeat(5) {
safeBox.edit()
.putString("key", "value")
.apply()
}
safeBox.closeWhenIdle()
repeat(5) {
safeBox.edit()
.putString("key", "value")
.apply()
}
while (!closed.get()) {
Thread.sleep(3)
}
val expectedOnSlowInit = listOf(
SafeBoxState.STARTING,
SafeBoxState.WRITING,
SafeBoxState.IDLE,
SafeBoxState.CLOSED,
)
val expectedOnFastInit = listOf(
SafeBoxState.STARTING,
SafeBoxState.IDLE, // finished STARTING before launching any write operation
SafeBoxState.WRITING,
SafeBoxState.IDLE,
SafeBoxState.CLOSED,
)
assertTrue(observedStates == expectedOnSlowInit || observedStates == expectedOnFastInit)
}

@Test
fun putString_shouldDoNothingAfterClosing() {
val hasEmissionAfterClose = AtomicBoolean(false)
val closed = AtomicBoolean(false)
safeBox = createSafeBox(
stateListener = SafeBoxStateListener { state ->
if (closed.get()) {
hasEmissionAfterClose.set(true)
}
closed.set(state == SafeBoxState.CLOSED)
},
)

safeBox.closeWhenIdle()
safeBox.edit()
.putString("key", "value")
.commit()

assertFalse(hasEmissionAfterClose.get())
}

private fun createSafeBox(
ioDispatcher: CoroutineDispatcher = UnconfinedTestDispatcher(),
stateListener: SafeBoxStateListener? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ class SafeBoxMigrationHelperTest {
esp.edit()
.clear()
.commit()
safeBox.close()
context.deleteSharedPreferences(fileEsp)
context.deleteFile("$fileSafeBox.bin")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ class SafeBoxBlobStoreTest {

@After
fun teardown() {
blobStore.close()
File(context.noBackupFilesDir, "$fileName.bin").delete()
}

Expand Down
33 changes: 21 additions & 12 deletions safebox/src/main/java/com/harrytmthy/safebox/SafeBox.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import com.harrytmthy.safebox.extensions.safeBoxScope
import com.harrytmthy.safebox.extensions.toBytes
import com.harrytmthy.safebox.extensions.toEncodedByteArray
import com.harrytmthy.safebox.keystore.SecureRandomKeyProvider
import com.harrytmthy.safebox.registry.SafeBoxBlobFileRegistry
import com.harrytmthy.safebox.state.SafeBoxStateListener
import com.harrytmthy.safebox.state.SafeBoxStateManager
import com.harrytmthy.safebox.storage.Bytes
Expand Down Expand Up @@ -199,27 +198,25 @@ public class SafeBox private constructor(
}

/**
* **Deprecated:** SafeBox no longer supports instance closing.
*
* Immediately closes the underlying file channel and releases resources.
* Also unregisters the file from [SafeBoxBlobFileRegistry], allowing a new SafeBox
* instance to be created with the same filename.
*
* ⚠️ Once closed, this instance becomes *permanently unusable*. Any further access will fail.
*
* ⚠️ Only use this method when you're certain that no writes are in progress.
*
* Closing during an active write can result in data corruption or incomplete persistence.
*/
@Deprecated(message = "This method is now a no-op, as SafeBox is always active and reusable.")
public fun close() {
SafeBoxBlobFileRegistry.unregister(blobStore.getFileName())
blobStore.close()
keyCipherProvider.destroyKey()
valueCipherProvider.destroyKey()
// no-op
}

/**
* **Deprecated:** SafeBox no longer supports instance closing.
*
* Closes the underlying file channel only after all pending writes have completed.
* Also unregisters the file from [SafeBoxBlobFileRegistry], allowing a new SafeBox
* instance to be created with the same filename.
*
* ⚠️ Once closed, this instance becomes *permanently unusable*. Any further access will fail.
*
Expand All @@ -228,8 +225,9 @@ public class SafeBox private constructor(
* Internally, this launches a coroutine on [safeBoxScope] to wait until the SafeBox
* becomes idle before releasing resources.
*/
@Deprecated(message = "This method is now a no-op, as SafeBox is always active and reusable.")
public fun closeWhenIdle() {
stateManager.closeWhenIdle(::close)
// no-op
}

override fun getAll(): Map<String, Any?> {
Expand Down Expand Up @@ -376,6 +374,9 @@ public class SafeBox private constructor(
@VisibleForTesting
internal const val DEFAULT_VALUE_KEYSTORE_ALIAS = "SafeBoxValue"

@VisibleForTesting
internal val instances = ConcurrentHashMap<String, SafeBox>()

/**
* Creates a [SafeBox] instance with secure defaults:
* - Keys are deterministically encrypted using [ChaCha20CipherProvider].
Expand Down Expand Up @@ -411,7 +412,10 @@ public class SafeBox private constructor(
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
stateListener: SafeBoxStateListener? = null,
): SafeBox {
SafeBoxBlobFileRegistry.register(fileName)
instances[fileName]?.let { safeBox ->
stateListener?.let(safeBox.stateManager::setStateListener)
return safeBox
}
val aesGcmCipherProvider = AesGcmCipherProvider.create(
alias = valueKeyStoreAlias,
aad = additionalAuthenticatedData,
Expand All @@ -428,6 +432,7 @@ public class SafeBox private constructor(
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
val blobStore = SafeBoxBlobStore.create(context, fileName)
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
.also { instances[fileName] = it }
}

/**
Expand Down Expand Up @@ -461,10 +466,14 @@ public class SafeBox private constructor(
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
stateListener: SafeBoxStateListener? = null,
): SafeBox {
SafeBoxBlobFileRegistry.register(fileName)
instances[fileName]?.let { safeBox ->
stateListener?.let(safeBox.stateManager::setStateListener)
return safeBox
}
val stateManager = SafeBoxStateManager(fileName, stateListener, ioDispatcher)
val blobStore = SafeBoxBlobStore.create(context, fileName)
return SafeBox(blobStore, keyCipherProvider, valueCipherProvider, stateManager)
.also { instances[fileName] = it }
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,6 @@ public enum class SafeBoxState {
* Indicates that SafeBox has been closed and is no longer usable.
* To access the same file again, a new SafeBox instance must be created.
*/
@Deprecated(message = "This state is never emitted, as SafeBox is always active and reusable.")
CLOSED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ package com.harrytmthy.safebox.state
* A listener interface for observing [SafeBoxState] changes tied to a specific SafeBox file.
*
* This is typically used in non-singleton SafeBox use cases (e.g. ViewModel-scoped),
* where consumers need to track if the instance is writing, idle, or closed.
* where consumers need to track if the instance is starting, writing, or idle.
*
* @see SafeBoxState
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package com.harrytmthy.safebox.state

import com.harrytmthy.safebox.SafeBox
import com.harrytmthy.safebox.extensions.safeBoxScope
import com.harrytmthy.safebox.state.SafeBoxState.CLOSED
import com.harrytmthy.safebox.state.SafeBoxState.IDLE
import com.harrytmthy.safebox.state.SafeBoxState.STARTING
import com.harrytmthy.safebox.state.SafeBoxState.WRITING
Expand All @@ -27,7 +26,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference

Expand All @@ -39,17 +37,16 @@ import java.util.concurrent.atomic.AtomicReference
*
* Key behaviors:
* - Tracks concurrent writes using an atomic counter.
* - Waits for the blob store's initial read before permitting writes or close.
* - Waits for the blob store's initial read before permitting writes.
* - Guarantees transition to [IDLE] after all writes complete.
* - Supports safe, deferred closing via [closeWhenIdle], ensuring no writes are in progress.
*
* @param fileName The unique file identifier associated with this SafeBox instance.
* @param stateListener Optional listener for observing state transitions on this instance.
* @param ioDispatcher Dispatcher used for coroutine-based I/O tasks.
*/
internal class SafeBoxStateManager(
private val fileName: String,
private val stateListener: SafeBoxStateListener?,
private var stateListener: SafeBoxStateListener?,
private val ioDispatcher: CoroutineDispatcher,
) {

Expand All @@ -59,7 +56,9 @@ internal class SafeBoxStateManager(

private val writeCompleted = AtomicReference<CompletableDeferred<Unit>>()

private val closed = AtomicBoolean(false)
fun setStateListener(stateListener: SafeBoxStateListener?) {
this.stateListener = stateListener
}

inline fun launchWithStartingState(crossinline block: suspend () -> Unit) {
updateState(STARTING)
Expand All @@ -75,9 +74,6 @@ internal class SafeBoxStateManager(
}

inline fun launchCommitWithWritingState(crossinline block: suspend () -> Boolean): Boolean {
if (closed.get()) {
return false
}
if (concurrentWriteCount.incrementAndGet() == 1) {
writeCompleted.set(CompletableDeferred())
if (initialReadCompleted.isCompleted) {
Expand All @@ -96,9 +92,6 @@ internal class SafeBoxStateManager(
exceptionHandler: CoroutineExceptionHandler,
crossinline block: suspend () -> Unit,
) {
if (closed.get()) {
return
}
if (concurrentWriteCount.incrementAndGet() == 1) {
writeCompleted.set(CompletableDeferred())
if (initialReadCompleted.isCompleted) {
Expand All @@ -113,22 +106,6 @@ internal class SafeBoxStateManager(
}
}

inline fun closeWhenIdle(crossinline block: () -> Unit) {
if (concurrentWriteCount.get() == 0 && initialReadCompleted.isCompleted) {
closed.set(true)
block()
updateState(CLOSED)
return
}
safeBoxScope.launch(ioDispatcher) {
initialReadCompleted.await()
writeCompleted.get()?.await()
closed.set(true)
block()
updateState(CLOSED)
}
}

private fun updateState(newState: SafeBoxState) {
stateListener?.onStateChanged(newState)
SafeBoxGlobalStateObserver.updateState(fileName, newState)
Expand Down
Loading