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
43 changes: 43 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Publish to Maven Central

on:
push:
tags:
- 'v*' # triggers on v1.1.0, v2.0.0-beta01, etc.
workflow_dispatch:

jobs:
publish:
name: Publish SafeBox to Maven Central
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

- name: Prepare GPG directory
run: mkdir -p /home/runner/.gnupg && chmod 700 /home/runner/.gnupg

- name: Import GPG key
run: echo "${{ secrets.SIGNING_SECRET_KEY_RING_BASE64 }}" | base64 -d > /home/runner/.gnupg/secring.gpg

- name: Set GPG key file permissions
run: chmod 600 /home/runner/.gnupg/secring.gpg

- name: Publish to Maven Central
run: ./gradlew publishToMavenCentral --no-configuration-cache --stacktrace
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
ORG_GRADLE_PROJECT_signing.keyId: ${{ secrets.SIGNING_KEY_ID }}
ORG_GRADLE_PROJECT_signing.password: ${{ secrets.SIGNING_PASSWORD }}
ORG_GRADLE_PROJECT_signing.secretKeyRingFile: /home/runner/.gnupg/secring.gpg
20 changes: 18 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

All notable changes to this project will be documented in this file.

## [1.1.0-alpha02] - 2025-06-02

### Added
- **SafeBoxBlobFileRegistry** prevents multiple `SafeBox` instances from accessing the same blob file. This enforces a **single-instance-per-file** constraint internally, resolving the risk documented in [#3](https://github.com/harrytmthy-dev/safebox/issues/3). ([#10](https://github.com/harrytmthy-dev/safebox/issues/10))
- **SafeBoxStateListener** for tracking `SafeBox` lifecycle states (`STARTING`, `IDLE`, `WRITING`, `CLOSED`). It can be attached per-instance via `SafeBox.create(...)` or registered globally via `SafeBoxGlobalStateObserver`. ([#12](https://github.com/harrytmthy-dev/safebox/issues/12))
- **SafeBoxGlobalStateObserver** tracks `SafeBox` state transitions by file name, with support for multiple listeners. ([#12](https://github.com/harrytmthy-dev/safebox/issues/12))
- `SafeBox#closeWhenIdle()` defers closure until all pending writes are complete, preventing premature teardown in async environments. ([#12](https://github.com/harrytmthy-dev/safebox/issues/12))

### Behavior Changes
- Calling `SafeBox.create(...)` before closing the existing instance with the same file name now throws `IllegalStateException`.
- Consecutive write operations are now tracked using `MutableStateFlow`, enabling precise notifications of state transitions.

### Docs & Migration
- `README.md` and `MIGRATION.md` updated to reflect the new state registry and observability APIs.
- KDocs improved to clarify the correct usage of `close()` and `closeWhenIdle()`.

## [1.1.0-alpha01] - 2025-05-26

### Added
Expand All @@ -13,7 +29,7 @@ All notable changes to this project will be documented in this file.
- 🎉 First stable release published to Maven Central
- `SafeBox.create(...)` API as a drop-in replacement for `EncryptedSharedPreferences`
- Memory-mapped file storage layer for faster I/O performance
- Dual-layer encryption: ChaCha20-Poly1305 for keys & values, AES-GCM key wrapping via AndroidKeyStore
- Dual-layer encryption: ChaCha20-Poly1305 for keys & values, AES-GCM key wrapping via `AndroidKeyStore`
- Fully documented public APIs with attached source and Javadoc jars
- Kotlin-first implementation with SharedPreferences compatibility
- Kotlin-first implementation with `SharedPreferences` compatibility
- `MIGRATION.md` guide for easy switch from `EncryptedSharedPreferences`
96 changes: 71 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ Compared to EncryptedSharedPreferences:

Average times measured over **100 samples** on an emulator:

![Read Performance](docs/charts/read_performance_chart.png)

![Write Performance](docs/charts/write_performance_chart.png)

![Write then Commit Performance](docs/charts/write_commit_performance_chart.png)

<details>

<summary>📊 Comparison Tables</summary>

| Operation | SafeBox | EncryptedSharedPreferences |
|------------------------------|------------|----------------------------|
| Write 1 entry then commit | **0.55ms** | 1.31ms (*138% slower*) |
Expand All @@ -65,23 +75,13 @@ Even on **multiple single commits**, SafeBox remains faster:
| Write and commit 10 entries | **5.47ms** | 11.27ms (*106% slower*) |
| Write and commit 100 entries | **33.19ms** | 71.34ms (*115% slower*) |

<details>

<summary>View Charts</summary>

![Read Performance](docs/charts/read_performance_chart.png)

![Write Performance](docs/charts/write_performance_chart.png)

![Write then Commit Performance](docs/charts/write_commit_performance_chart.png)

</details>

## Installation

```kotlin
dependencies {
implementation("io.github.harrytmthy-dev:safebox:1.1.0-alpha01")
implementation("io.github.harrytmthy-dev:safebox:1.1.0-alpha02")
}
```

Expand All @@ -93,14 +93,7 @@ First, provide SafeBox as a singleton:
@Singleton
@Provides
fun provideEncryptedSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
SafeBox.create(context, PREF_FILE_NAME) // Replacing EncryptedSharedPreferences
```

Or use the built-in singleton helper:

```kotlin
SafeBoxProvider.init(context, PREF_FILE_NAME)
val prefs = SafeBoxProvider.get()
SafeBox.create(context, PREF_FILE_NAME) // Ensuring single instance per file
```

Then use it like any `SharedPreferences`:
Expand All @@ -115,14 +108,16 @@ val userId = prefs.getInt("userId", -1)
val email = prefs.getString("email", null)
```

### Anti-Patterns
<details>

<summary>⚠️ Anti-Patterns</summary>

#### ❌ Do NOT create multiple SafeBox instances with the same file name
#### ❌ Do NOT create multiple SafeBox instances with the same file name before closing the previous one

```kotlin
class HomeViewModel @Inject constructor() : ViewModel() {

private val safeBox = SafeBox.create(context, PREF_FILE_NAME) // ❌ New instance per ViewModel
fun saveUsername(value: String) {
SafeBox.create(context, PREF_FILE_NAME)
.edit { putString("username", value) } // ❌ New instance per function call
}
```

Expand All @@ -145,11 +140,62 @@ object SomeModule {
class HomeViewModel @Inject constructor(private val safeBox: SafeBox) : ViewModel() {

override fun onCleared() {
safeBox.close() // Technically safe, but why re-create SafeBox for every ViewModel?
safeBox.closeWhenIdle() // Technically safe, but why re-create SafeBox for every ViewModel?
}
}
```

</details>

### Observing State Changes

You can observe SafeBox lifecycle state transitions (`STARTING`, `WRITING`, `IDLE`, `CLOSED`) in two ways:

#### 1. Instance-bound listener

```kotlin
val safeBox = SafeBox.create(
context = context,
fileName = PREF_FILE_NAME,
listener = SafeBoxStateListener { state ->
when (state) {
STARTING -> trackStart() // Loading data into memory
IDLE -> trackIdle() // No active operations
WRITING -> trackWrite() // Writing to disk
CLOSED -> trackClose() // Instance is no longer usable
}
}
)
```

#### 2. Global observer

Manually add listeners by file name:

```kotlin
val listener = SafeBoxStateListener { state ->
when (state) {
STARTING -> trackStart() // Loading data into memory
IDLE -> trackIdle() // No active operations
WRITING -> trackWrite() // Writing to disk
CLOSED -> trackClose() // Instance is no longer usable
}
}
SafeBoxGlobalStateObserver.addListener(PREF_FILE_NAME, listener)
```

and remove it when it's no longer needed:

```kotlin
SafeBoxGlobalStateObserver.removeListener(PREF_FILE_NAME, listener)
```

You can also query the current state at any time:

```kotlin
val state = SafeBoxGlobalStateObserver.getCurrentState(PREF_FILE_NAME)
```

## Migrating from EncryptedSharedPreferences

SafeBox is a drop-in replacement for `EncryptedSharedPreferences`.
Expand Down
8 changes: 1 addition & 7 deletions docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,7 @@ If your app already stores data in `EncryptedSharedPreferences` or even plain `S
SafeBoxMigrationHelper.migrate(from = encryptedPrefs, to = safeBox)
```

✅ This helper is available in version 1.1.0-alpha01:

```kotlin
dependencies {
implementation("io.github.harrytmthy-dev:safebox:1.1.0-alpha01")
}
```
✅ This helper is available since version 1.1.0-alpha01.

## Still unsure?

Expand Down
4 changes: 1 addition & 3 deletions safebox/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ plugins {
id("com.vanniktech.maven.publish")
}

val safeBoxVersion = "1.1.0-alpha01"

group = "io.github.harrytmthy-dev"
version = safeBoxVersion
version = "1.1.0-alpha02"

android {
namespace = "com.harrytmthy.safebox"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After
Expand Down Expand Up @@ -222,7 +221,7 @@ class SafeBoxBlobStoreTest {
}

@Test
fun write_shouldEmitWritingAndIdleStates() = runBlocking {
fun write_shouldEmitWritingAndIdleStates() = runTest {
buildList {
repeat(10) {
add(
Expand Down