react-native-nitro-storage 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +50 -12
- package/SECURITY.md +2 -2
- package/android/build.gradle +5 -0
- package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +12 -13
- package/android/src/main/cpp/JniSize.hpp +21 -0
- package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +94 -7
- package/android/src/main/java/com/nitrostorage/DiskSqliteStore.kt +147 -34
- package/cpp/bindings/HybridStorage.cpp +12 -4
- package/cpp/core/SqliteDiskStore.cpp +172 -26
- package/cpp/core/SqliteDiskStore.hpp +10 -1
- package/docs/api-reference.md +11 -11
- package/docs/native-libraries.md +28 -0
- package/ios/IOSStorageAdapterCpp.mm +89 -22
- package/lib/commonjs/storage-core.js +26 -6
- package/lib/commonjs/storage-core.js.map +1 -1
- package/lib/module/storage-core.js +26 -6
- package/lib/module/storage-core.js.map +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/storage-core.d.ts.map +1 -1
- package/lib/typescript/testing.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/storage-core.ts +26 -6
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
Breaking changes are always listed first in each release section.
|
|
8
8
|
|
|
9
|
+
## [0.13.0] - 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Breaking changes
|
|
12
|
+
|
|
13
|
+
- **Android no longer deletes a corrupt Disk database.** In 0.12.0, Android replaced a corrupt Disk database with an empty one when it opened. Now the file is kept and every Disk call fails with `storage_corruption`, as on iOS, until the app calls `storage.clear(StorageScope.Disk)` or `storage.clearAll()`. Migration: catch `storage_corruption` on Disk calls and call `storage.clear(StorageScope.Disk)` when the app can continue without the stored data.
|
|
14
|
+
- **iOS reports undecodable Secure data as `storage_corruption`.** Reading a Secure or biometric item whose Keychain data is empty or is not UTF-8 text failed with an untagged `failed with status 0` error. It now fails with the `storage_corruption` code. The item can still be deleted or replaced. Migration: handle `storage_corruption` on Secure reads if you matched the old message.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `storage.clear(StorageScope.Disk)` and `storage.clearAll()` recover a corrupt Disk database on iOS and Android: they delete the database and its journal files and create an empty store. `clear` with `except`, namespace clears, and `removeByPrefix` need to read keys and still fail on a corrupt database.
|
|
19
|
+
- `storage.clear(StorageScope.Disk)` works on a full device. When the delete fails with `storage_full`, the database is deleted and created again, which frees its space.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Android Disk uses SQLite WAL. 0.12.0 documented WAL but did not enable it. An existing database converts on first open and keeps its rows. If the conversion cannot run, for example on a full device, the database opens without WAL so reads keep working, and the next launch tries again. With WAL and `synchronous=NORMAL`, a power loss can drop the most recent commits; an app kill cannot. This matches iOS.
|
|
24
|
+
- `getKeysByPrefix` on Disk uses the key index instead of reading every key, on iOS and Android. On iOS, `size(StorageScope.Disk)` no longer builds the full key list. Results are unchanged.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `storage.clear(StorageScope.Disk)` no longer fails before it reaches native storage when a Disk listener or event observer is registered and the stored values cannot be read, or when pending async Disk writes cannot be flushed. The pending writes are dropped and listeners receive a `clear` batch with no key changes.
|
|
29
|
+
- Android: `setBatch`, `removeBatch`, and the first-launch import report a full Disk database with the `storage_full` code. They used to fail with an untagged `cannot rollback` error.
|
|
30
|
+
- Android: Disk values larger than 2 MiB can be read. They could be written but not read.
|
|
31
|
+
- Android: a Disk I/O error that occurs with less than 1 MiB of free space carries the `storage_full` code. Read errors do not.
|
|
32
|
+
- iOS: Disk I/O errors caused by a full device or an exceeded quota carry the `storage_full` code, including while the database opens.
|
|
33
|
+
- iOS: a Disk read of a legacy value that is not yet migrated no longer fails when the Disk database is full.
|
|
34
|
+
- iOS: `clearSecureBiometric` reports an unavailable Keychain with the `keychain_locked` code.
|
|
35
|
+
- iOS: the Disk WAL file is truncated after checkpoints on every SQLite build.
|
|
36
|
+
- iOS: a Disk key that is not valid UTF-8 is rejected before anything is written or removed.
|
|
37
|
+
- `removeByPrefix("", scope)` rejects an invalid scope.
|
|
38
|
+
- Unknown native failures in `hasSecureBiometric`, `setSecureAccessControl`, `setSecureWritesAsync`, and `setKeychainAccessGroup` surface as a `NitroStorage: … failed (unknown error)` error.
|
|
39
|
+
- The Android library declares its unit-test dependencies only inside the repository, so consumer builds do not resolve them.
|
|
40
|
+
|
|
9
41
|
## [0.12.0] - 2026-10-01
|
|
10
42
|
|
|
11
43
|
### Breaking changes
|
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ before installing this package, then rebuild the native app so the generated
|
|
|
91
91
|
Nitro bindings and native runtime use the same major-minor version:
|
|
92
92
|
|
|
93
93
|
```sh
|
|
94
|
-
bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.
|
|
94
|
+
bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.13.0
|
|
95
95
|
bunx expo prebuild
|
|
96
96
|
```
|
|
97
97
|
|
|
@@ -752,17 +752,17 @@ Native and web adapters tag classified failures with stable error codes. Use
|
|
|
752
752
|
Errors never swallow the underlying cause silently: the original platform
|
|
753
753
|
message is preserved on the error for diagnostics.
|
|
754
754
|
|
|
755
|
-
| Code | Meaning
|
|
756
|
-
| ----------------------------- |
|
|
757
|
-
| `keychain_locked` | The protected store is locked. Retry after the device unlocks.
|
|
758
|
-
| `authentication_required` | The item needs user authentication, or the user cancelled the prompt.
|
|
759
|
-
| `key_invalidated` | The protecting key was invalidated, for example by a biometric enrolment change.
|
|
760
|
-
| `biometric_unavailable` | The requested biometric level is not available on this device or OS version.
|
|
761
|
-
| `storage_corruption` | Stored secure data could not be
|
|
762
|
-
| `storage_compensation_failed` | A multi-step write failed and restoring the previous state also failed.
|
|
763
|
-
| `unsupported` | The operation is not available on this platform or environment.
|
|
764
|
-
| `storage_full` | The device or database is out of space, or the web storage quota is exceeded. Free space before retrying.
|
|
765
|
-
| `invalid_key` | The storage key is empty. Keys must be non-empty strings.
|
|
755
|
+
| Code | Meaning |
|
|
756
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
757
|
+
| `keychain_locked` | The protected store is locked. Retry after the device unlocks. |
|
|
758
|
+
| `authentication_required` | The item needs user authentication, or the user cancelled the prompt. |
|
|
759
|
+
| `key_invalidated` | The protecting key was invalidated, for example by a biometric enrolment change. |
|
|
760
|
+
| `biometric_unavailable` | The requested biometric level is not available on this device or OS version. |
|
|
761
|
+
| `storage_corruption` | Stored secure data could not be decoded, the Disk database is corrupt, or (Android) the Secure master key or store cannot be created. Do not retry. |
|
|
762
|
+
| `storage_compensation_failed` | A multi-step write failed and restoring the previous state also failed. |
|
|
763
|
+
| `unsupported` | The operation is not available on this platform or environment. |
|
|
764
|
+
| `storage_full` | The device or database is out of space, or the web storage quota is exceeded. Free space before retrying. |
|
|
765
|
+
| `invalid_key` | The storage key is empty. Keys must be non-empty strings. |
|
|
766
766
|
|
|
767
767
|
Invalid scopes and non-finite numeric levels are rejected with untagged errors
|
|
768
768
|
before they reach native storage.
|
|
@@ -788,6 +788,44 @@ function saveDraft(draft: string): "saved" | "storage_full" {
|
|
|
788
788
|
}
|
|
789
789
|
```
|
|
790
790
|
|
|
791
|
+
A full device does not block cleanup. `storage.clear(StorageScope.Disk)`
|
|
792
|
+
deletes and recreates the Disk database when a normal delete fails with
|
|
793
|
+
`storage_full`, which frees the space the database used. `remove` and
|
|
794
|
+
`removeBatch` can still fail with `storage_full`, because a delete also writes
|
|
795
|
+
to the database log.
|
|
796
|
+
|
|
797
|
+
A corrupt Disk database is never deleted automatically. Every Disk call fails
|
|
798
|
+
with `storage_corruption` until the app clears Disk storage:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
import {
|
|
802
|
+
isStorageError,
|
|
803
|
+
storage,
|
|
804
|
+
StorageScope,
|
|
805
|
+
} from "react-native-nitro-storage";
|
|
806
|
+
|
|
807
|
+
function readDraft(): string | undefined {
|
|
808
|
+
try {
|
|
809
|
+
return storage.getString("draft", StorageScope.Disk);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
if (!isStorageError(error, "storage_corruption")) throw error;
|
|
812
|
+
storage.clear(StorageScope.Disk);
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
`storage.clear(StorageScope.Disk)` and `storage.clearAll()` are the recovery
|
|
819
|
+
calls. `clear` with `except`, namespace clears, and `removeByPrefix` read keys
|
|
820
|
+
first and fail on a corrupt database. A `clear` batch event with an empty
|
|
821
|
+
`changes` array means every key in that scope was removed and the previous
|
|
822
|
+
values could not be read; treat all keys in the scope as deleted.
|
|
823
|
+
|
|
824
|
+
On Android, Disk values above 512 KiB are read in 512 KiB chunks, and read
|
|
825
|
+
time grows faster than value size (about 30 ms for 5 MiB and 265 ms for 20 MiB
|
|
826
|
+
in a host measurement; devices are slower). Keep single Disk values below about
|
|
827
|
+
5 MiB.
|
|
828
|
+
|
|
791
829
|
## Platform Support
|
|
792
830
|
|
|
793
831
|
| Platform | Status |
|
package/SECURITY.md
CHANGED
package/android/build.gradle
CHANGED
|
@@ -83,4 +83,9 @@ dependencies {
|
|
|
83
83
|
implementation "com.facebook.react:react-native:+"
|
|
84
84
|
implementation "androidx.security:security-crypto:1.1.0"
|
|
85
85
|
implementation project(":react-native-nitro-modules")
|
|
86
|
+
|
|
87
|
+
if (file("src/test").exists()) {
|
|
88
|
+
testImplementation "junit:junit:4.13.2"
|
|
89
|
+
testImplementation "org.robolectric:robolectric:4.13"
|
|
90
|
+
}
|
|
86
91
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#include "AndroidStorageAdapterCpp.hpp"
|
|
2
|
+
#include "JniSize.hpp"
|
|
2
3
|
|
|
3
4
|
#include <limits>
|
|
4
5
|
#include <stdexcept>
|
|
@@ -13,11 +14,8 @@ namespace {
|
|
|
13
14
|
local_ref<JString> toJavaString(const std::string& value) {
|
|
14
15
|
// fbjni's std::string overload uses c_str(), which truncates embedded NUL.
|
|
15
16
|
if (value.find('\0') == std::string::npos) return make_jstring(value);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
const auto size = static_cast<jsize>(value.size());
|
|
20
|
-
auto bytes = JArrayByte::newArray(size);
|
|
17
|
+
const jsize size = toJniSize(value.size(), "Storage string exceeds the Java array limit");
|
|
18
|
+
auto bytes = JArrayByte::newArray(value.size());
|
|
21
19
|
bytes->setRegion(0, size, reinterpret_cast<const jbyte*>(value.data()));
|
|
22
20
|
static auto constructor = JString::javaClassStatic()->getConstructor<
|
|
23
21
|
jstring(jbyteArray, jstring)>();
|
|
@@ -26,10 +24,11 @@ local_ref<JString> toJavaString(const std::string& value) {
|
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
local_ref<JavaStringArray> toJavaStringArray(const std::vector<std::string>& values) {
|
|
29
|
-
|
|
27
|
+
toJniSize(values.size(), "Storage batch exceeds the Java array limit");
|
|
28
|
+
auto javaArray = JavaStringArray::newArray(values.size());
|
|
30
29
|
for (size_t i = 0; i < values.size(); ++i) {
|
|
31
30
|
auto javaValue = toJavaString(values[i]);
|
|
32
|
-
javaArray->setElement(
|
|
31
|
+
javaArray->setElement(i, javaValue.get());
|
|
33
32
|
}
|
|
34
33
|
return javaArray;
|
|
35
34
|
}
|
|
@@ -38,9 +37,9 @@ std::vector<std::optional<std::string>> fromNullableJavaStringArray(alias_ref<Ja
|
|
|
38
37
|
std::vector<std::optional<std::string>> parsedValues;
|
|
39
38
|
if (!values) return parsedValues;
|
|
40
39
|
|
|
41
|
-
const
|
|
40
|
+
const size_t size = values->size();
|
|
42
41
|
parsedValues.reserve(size);
|
|
43
|
-
for (
|
|
42
|
+
for (size_t i = 0; i < size; ++i) {
|
|
44
43
|
auto currentValue = values->getElement(i);
|
|
45
44
|
if (!currentValue) {
|
|
46
45
|
parsedValues.push_back(std::nullopt);
|
|
@@ -53,10 +52,10 @@ std::vector<std::optional<std::string>> fromNullableJavaStringArray(alias_ref<Ja
|
|
|
53
52
|
|
|
54
53
|
std::vector<std::string> fromJavaStringArray(alias_ref<JavaStringArray> values) {
|
|
55
54
|
if (!values) return {};
|
|
56
|
-
const
|
|
55
|
+
const size_t size = values->size();
|
|
57
56
|
std::vector<std::string> result;
|
|
58
57
|
result.reserve(size);
|
|
59
|
-
for (
|
|
58
|
+
for (size_t i = 0; i < size; ++i) {
|
|
60
59
|
auto currentValue = values->getElement(i);
|
|
61
60
|
// Null entries are dropped so a missing key can never surface as the
|
|
62
61
|
// empty-string clear sentinel used by change listeners.
|
|
@@ -115,7 +114,7 @@ std::vector<std::string> AndroidStorageAdapterCpp::getKeysByPrefixDisk(const std
|
|
|
115
114
|
|
|
116
115
|
size_t AndroidStorageAdapterCpp::sizeDisk() {
|
|
117
116
|
static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jint()>("sizeDisk");
|
|
118
|
-
return
|
|
117
|
+
return fromJniSize(method(AndroidStorageAdapterJava::javaClassStatic()));
|
|
119
118
|
}
|
|
120
119
|
|
|
121
120
|
void AndroidStorageAdapterCpp::setDiskBatch(
|
|
@@ -199,7 +198,7 @@ std::vector<std::string> AndroidStorageAdapterCpp::getKeysByPrefixSecure(const s
|
|
|
199
198
|
|
|
200
199
|
size_t AndroidStorageAdapterCpp::sizeSecure() {
|
|
201
200
|
static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jint()>("sizeSecure");
|
|
202
|
-
return
|
|
201
|
+
return fromJniSize(method(AndroidStorageAdapterJava::javaClassStatic()));
|
|
203
202
|
}
|
|
204
203
|
|
|
205
204
|
void AndroidStorageAdapterCpp::setSecureBatch(
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include <cstddef>
|
|
4
|
+
#include <cstdint>
|
|
5
|
+
#include <limits>
|
|
6
|
+
#include <stdexcept>
|
|
7
|
+
|
|
8
|
+
namespace NitroStorage {
|
|
9
|
+
|
|
10
|
+
inline int32_t toJniSize(size_t size, const char* what) {
|
|
11
|
+
if (size > static_cast<size_t>(std::numeric_limits<int32_t>::max())) {
|
|
12
|
+
throw std::length_error(what);
|
|
13
|
+
}
|
|
14
|
+
return static_cast<int32_t>(size);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
inline size_t fromJniSize(int32_t size) {
|
|
18
|
+
return size > 0 ? static_cast<size_t>(size) : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
} // namespace NitroStorage
|
|
@@ -4,9 +4,11 @@ package com.nitrostorage
|
|
|
4
4
|
|
|
5
5
|
import android.content.Context
|
|
6
6
|
import android.database.sqlite.SQLiteDatabaseCorruptException
|
|
7
|
+
import android.database.sqlite.SQLiteDiskIOException
|
|
7
8
|
import android.database.sqlite.SQLiteException
|
|
8
9
|
import android.database.sqlite.SQLiteFullException
|
|
9
10
|
import android.os.Build
|
|
11
|
+
import android.os.StatFs
|
|
10
12
|
import android.security.keystore.KeyGenParameterSpec
|
|
11
13
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
|
12
14
|
import android.security.keystore.KeyProperties
|
|
@@ -20,7 +22,7 @@ import java.security.KeyStore
|
|
|
20
22
|
import java.security.KeyStoreException
|
|
21
23
|
import javax.crypto.AEADBadTagException
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
internal fun Throwable.hasCause(type: Class<*>): Boolean {
|
|
24
26
|
var current: Throwable? = this
|
|
25
27
|
while (current != null) {
|
|
26
28
|
if (type.isInstance(current)) return true
|
|
@@ -29,7 +31,7 @@ private fun Throwable.hasCause(type: Class<*>): Boolean {
|
|
|
29
31
|
return false
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
internal fun Throwable.storageErrorCode(): String? {
|
|
33
35
|
val taggedCode = message
|
|
34
36
|
?.let { Regex("\\[nitro-error:([a-z_]+)]").find(it)?.groupValues?.get(1) }
|
|
35
37
|
if (taggedCode == "storage_compensation_failed") {
|
|
@@ -47,7 +49,34 @@ private fun Throwable.storageErrorCode(): String? {
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
internal const val OUT_OF_SPACE_USABLE_BYTES = 1L shl 20
|
|
53
|
+
|
|
54
|
+
private val sqliteExtendedCodePattern = Regex("\\(code (\\d+)")
|
|
55
|
+
private const val SQLITE_IOERR_READ = 266
|
|
56
|
+
private const val SQLITE_IOERR_SHORT_READ = 522
|
|
57
|
+
|
|
58
|
+
internal fun isOutOfSpaceFailure(error: Throwable, usableBytes: Long): Boolean {
|
|
59
|
+
var current: Throwable? = error
|
|
60
|
+
while (current != null && current !is SQLiteDiskIOException) {
|
|
61
|
+
current = current.cause
|
|
62
|
+
}
|
|
63
|
+
val ioError = current ?: return false
|
|
64
|
+
val extendedCode = ioError.message
|
|
65
|
+
?.let { sqliteExtendedCodePattern.find(it)?.groupValues?.get(1)?.toIntOrNull() }
|
|
66
|
+
?: return false
|
|
67
|
+
if (extendedCode == SQLITE_IOERR_READ || extendedCode == SQLITE_IOERR_SHORT_READ) {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
return usableBytes in 0 until OUT_OF_SPACE_USABLE_BYTES
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
internal fun isRecoverableByRecreate(error: Throwable): Boolean {
|
|
74
|
+
val message = error.message ?: return false
|
|
75
|
+
return message.startsWith("[nitro-error:storage_corruption] NitroStorage: Disk SQLite ") ||
|
|
76
|
+
message.startsWith("[nitro-error:storage_full] NitroStorage: Disk SQLite ")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
internal fun Throwable.wrapStorageException(
|
|
51
80
|
defaultMessage: String,
|
|
52
81
|
defaultCode: String? = null,
|
|
53
82
|
): RuntimeException {
|
|
@@ -66,8 +95,34 @@ private fun Throwable.wrapStorageException(
|
|
|
66
95
|
class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
67
96
|
private val sharedPreferences: SharedPreferences =
|
|
68
97
|
context.getSharedPreferences("NitroStorage", Context.MODE_PRIVATE)
|
|
69
|
-
private val
|
|
70
|
-
|
|
98
|
+
private val diskStoreLock = Any()
|
|
99
|
+
private var openDiskStore: DiskSqliteStore? = null
|
|
100
|
+
private val diskStore: DiskSqliteStore
|
|
101
|
+
get() = synchronized(diskStoreLock) {
|
|
102
|
+
openDiskStore ?: DiskSqliteStore(context, sharedPreferences, diskDatabaseOpener)
|
|
103
|
+
.also { openDiskStore = it }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private fun recreateDiskStore() {
|
|
107
|
+
synchronized(diskStoreLock) {
|
|
108
|
+
openDiskStore?.let { store -> runCatching { store.close() } }
|
|
109
|
+
openDiskStore = null
|
|
110
|
+
DiskSqliteStore.deleteDatabaseFiles(context)
|
|
111
|
+
sharedPreferences.edit().clear().commit()
|
|
112
|
+
openDiskStore = DiskSqliteStore(context, sharedPreferences, diskDatabaseOpener)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun diskFullCodeFor(error: Throwable): String? {
|
|
117
|
+
if (!error.hasCause(SQLiteDiskIOException::class.java)) {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
val usableBytes = try {
|
|
121
|
+
StatFs(context.filesDir.path).availableBytes
|
|
122
|
+
} catch (statError: IllegalArgumentException) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
return if (isOutOfSpaceFailure(error, usableBytes)) "storage_full" else null
|
|
71
126
|
}
|
|
72
127
|
|
|
73
128
|
private val masterKeyAlias = "${context.packageName}.nitro_storage.master_key"
|
|
@@ -549,6 +604,9 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
549
604
|
@Volatile
|
|
550
605
|
private var instance: AndroidStorageAdapter? = null
|
|
551
606
|
|
|
607
|
+
@Volatile
|
|
608
|
+
internal var diskDatabaseOpener: DiskDatabaseOpener = ::openDiskDatabase
|
|
609
|
+
|
|
552
610
|
private fun getInstanceOrThrow(): AndroidStorageAdapter {
|
|
553
611
|
return instance ?: throw IllegalStateException(
|
|
554
612
|
"NitroStorage not initialized. Call AndroidStorageAdapter.init(this) in your MainApplication.onCreate(), " +
|
|
@@ -585,8 +643,22 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
585
643
|
): T {
|
|
586
644
|
val instance = getInstanceOrThrow()
|
|
587
645
|
try {
|
|
588
|
-
|
|
646
|
+
val store = instance.diskStore
|
|
647
|
+
return try {
|
|
648
|
+
store.block()
|
|
649
|
+
} catch (closed: IllegalStateException) {
|
|
650
|
+
val current = instance.diskStore
|
|
651
|
+
if (current === store) {
|
|
652
|
+
throw closed
|
|
653
|
+
}
|
|
654
|
+
current.block()
|
|
655
|
+
}
|
|
589
656
|
} catch (e: SQLiteException) {
|
|
657
|
+
throw e.wrapStorageException(
|
|
658
|
+
"NitroStorage: Disk SQLite $operation failed: ${e.message}",
|
|
659
|
+
instance.diskFullCodeFor(e),
|
|
660
|
+
)
|
|
661
|
+
} catch (e: IllegalStateException) {
|
|
590
662
|
throw e.wrapStorageException(
|
|
591
663
|
"NitroStorage: Disk SQLite $operation failed: ${e.message}",
|
|
592
664
|
)
|
|
@@ -645,7 +717,22 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
645
717
|
|
|
646
718
|
@JvmStatic
|
|
647
719
|
fun clearDisk() {
|
|
648
|
-
|
|
720
|
+
try {
|
|
721
|
+
diskOperation("clear") { clear() }
|
|
722
|
+
} catch (e: RuntimeException) {
|
|
723
|
+
if (!isRecoverableByRecreate(e)) {
|
|
724
|
+
throw e
|
|
725
|
+
}
|
|
726
|
+
val instance = getInstanceOrThrow()
|
|
727
|
+
try {
|
|
728
|
+
instance.recreateDiskStore()
|
|
729
|
+
} catch (recreateError: SQLiteException) {
|
|
730
|
+
throw recreateError.wrapStorageException(
|
|
731
|
+
"NitroStorage: Disk SQLite clear failed: ${recreateError.message}",
|
|
732
|
+
instance.diskFullCodeFor(recreateError),
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
649
736
|
}
|
|
650
737
|
|
|
651
738
|
// --- Secure (async apply by default, sync commit when requested) ---
|
|
@@ -2,21 +2,49 @@ package com.nitrostorage
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.content.SharedPreferences
|
|
5
|
+
import android.database.DatabaseErrorHandler
|
|
5
6
|
import android.database.sqlite.SQLiteDatabase
|
|
7
|
+
import android.database.sqlite.SQLiteDatabaseCorruptException
|
|
8
|
+
import android.database.sqlite.SQLiteException
|
|
9
|
+
import java.io.ByteArrayOutputStream
|
|
6
10
|
import java.io.File
|
|
7
11
|
|
|
12
|
+
internal typealias DiskDatabaseOpener = (path: String, flags: Int) -> SQLiteDatabase
|
|
13
|
+
|
|
14
|
+
internal fun openDiskDatabase(path: String, flags: Int): SQLiteDatabase {
|
|
15
|
+
return SQLiteDatabase.openDatabase(path, null, flags, DatabaseErrorHandler { })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
internal fun openDiskDatabaseWithWalFallback(
|
|
19
|
+
path: String,
|
|
20
|
+
opener: DiskDatabaseOpener,
|
|
21
|
+
): SQLiteDatabase {
|
|
22
|
+
val flags = SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.NO_LOCALIZED_COLLATORS
|
|
23
|
+
return try {
|
|
24
|
+
opener(path, flags or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
|
|
25
|
+
} catch (walError: SQLiteDatabaseCorruptException) {
|
|
26
|
+
throw walError
|
|
27
|
+
} catch (walError: SQLiteException) {
|
|
28
|
+
try {
|
|
29
|
+
opener(path, flags)
|
|
30
|
+
} catch (fallbackError: Exception) {
|
|
31
|
+
walError.addSuppressed(fallbackError)
|
|
32
|
+
throw walError
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
8
37
|
internal class DiskSqliteStore(
|
|
9
38
|
context: Context,
|
|
10
39
|
private val legacyPreferences: SharedPreferences,
|
|
40
|
+
opener: DiskDatabaseOpener = ::openDiskDatabase,
|
|
11
41
|
) {
|
|
12
42
|
private val db: SQLiteDatabase =
|
|
13
|
-
|
|
43
|
+
openDiskDatabaseWithWalFallback(databaseFile(context).path, opener)
|
|
14
44
|
|
|
15
45
|
init {
|
|
16
46
|
try {
|
|
17
|
-
|
|
18
|
-
db.rawQuery("PRAGMA journal_mode=WAL", null).close()
|
|
19
|
-
db.execSQL("PRAGMA synchronous=NORMAL")
|
|
47
|
+
db.execSQL(synchronousPragmaFor(queryPragma("PRAGMA journal_mode")))
|
|
20
48
|
db.execSQL(
|
|
21
49
|
"CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)",
|
|
22
50
|
)
|
|
@@ -30,6 +58,17 @@ internal class DiskSqliteStore(
|
|
|
30
58
|
}
|
|
31
59
|
}
|
|
32
60
|
|
|
61
|
+
private fun queryPragma(sql: String): String? {
|
|
62
|
+
return db.rawQuery(sql, null).use { cursor ->
|
|
63
|
+
if (cursor.moveToFirst()) cursor.getString(0) else null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Synchronized
|
|
68
|
+
fun close() {
|
|
69
|
+
db.close()
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
@Synchronized
|
|
34
73
|
fun set(key: String, value: String) {
|
|
35
74
|
db.execSQL(
|
|
@@ -40,9 +79,39 @@ internal class DiskSqliteStore(
|
|
|
40
79
|
|
|
41
80
|
@Synchronized
|
|
42
81
|
fun get(key: String): String? {
|
|
43
|
-
db.rawQuery(
|
|
44
|
-
|
|
82
|
+
db.rawQuery(
|
|
83
|
+
"SELECT length(CAST(value AS BLOB)), " +
|
|
84
|
+
"CASE WHEN length(CAST(value AS BLOB)) <= $VALUE_CHUNK_BYTES THEN value END " +
|
|
85
|
+
"FROM kv WHERE key = ? LIMIT 1",
|
|
86
|
+
arrayOf(key),
|
|
87
|
+
).use { cursor ->
|
|
88
|
+
if (!cursor.moveToFirst()) {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
if (!cursor.isNull(1)) {
|
|
92
|
+
return cursor.getString(1)
|
|
93
|
+
}
|
|
94
|
+
return readLargeValue(key, cursor.getLong(0))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private fun readLargeValue(key: String, byteCount: Long): String {
|
|
99
|
+
val bytes = ByteArrayOutputStream(byteCount.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
|
|
100
|
+
var offset = 1L
|
|
101
|
+
while (offset <= byteCount) {
|
|
102
|
+
val chunk = db.rawQuery(
|
|
103
|
+
"SELECT substr(CAST(value AS BLOB), ?, $VALUE_CHUNK_BYTES) FROM kv WHERE key = ?",
|
|
104
|
+
arrayOf(offset.toString(), key),
|
|
105
|
+
).use { cursor ->
|
|
106
|
+
if (cursor.moveToFirst()) cursor.getBlob(0) else null
|
|
107
|
+
}
|
|
108
|
+
if (chunk == null || chunk.isEmpty()) {
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
bytes.write(chunk, 0, chunk.size)
|
|
112
|
+
offset += chunk.size
|
|
45
113
|
}
|
|
114
|
+
return String(bytes.toByteArray(), Charsets.UTF_8)
|
|
46
115
|
}
|
|
47
116
|
|
|
48
117
|
@Synchronized
|
|
@@ -61,17 +130,13 @@ internal class DiskSqliteStore(
|
|
|
61
130
|
@Synchronized
|
|
62
131
|
fun setBatch(keys: Array<String>, values: Array<String>) {
|
|
63
132
|
val count = minOf(keys.size, values.size)
|
|
64
|
-
|
|
65
|
-
try {
|
|
133
|
+
inTransaction {
|
|
66
134
|
for (index in 0 until count) {
|
|
67
135
|
db.execSQL(
|
|
68
136
|
"INSERT OR REPLACE INTO kv(key, value) VALUES(?, ?)",
|
|
69
137
|
arrayOf(keys[index], values[index]),
|
|
70
138
|
)
|
|
71
139
|
}
|
|
72
|
-
db.setTransactionSuccessful()
|
|
73
|
-
} finally {
|
|
74
|
-
db.endTransaction()
|
|
75
140
|
}
|
|
76
141
|
}
|
|
77
142
|
|
|
@@ -82,14 +147,10 @@ internal class DiskSqliteStore(
|
|
|
82
147
|
|
|
83
148
|
@Synchronized
|
|
84
149
|
fun removeBatch(keys: Array<String>) {
|
|
85
|
-
|
|
86
|
-
try {
|
|
150
|
+
inTransaction {
|
|
87
151
|
for (key in keys) {
|
|
88
152
|
db.execSQL("DELETE FROM kv WHERE key = ?", arrayOf(key))
|
|
89
153
|
}
|
|
90
|
-
db.setTransactionSuccessful()
|
|
91
|
-
} finally {
|
|
92
|
-
db.endTransaction()
|
|
93
154
|
}
|
|
94
155
|
removeLegacyKeys(keys.asIterable())
|
|
95
156
|
}
|
|
@@ -107,19 +168,12 @@ internal class DiskSqliteStore(
|
|
|
107
168
|
|
|
108
169
|
@Synchronized
|
|
109
170
|
fun getKeysByPrefix(prefix: String): Array<String> {
|
|
110
|
-
val
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (character == '%' || character == '_' || character == '\\') append('\\')
|
|
115
|
-
append(character)
|
|
116
|
-
}
|
|
117
|
-
append('%')
|
|
171
|
+
val cursor = if (hasUnpairedSurrogate(prefix)) {
|
|
172
|
+
db.rawQuery("SELECT key FROM kv WHERE key LIKE ? ESCAPE '\\'", arrayOf(likePattern(prefix)))
|
|
173
|
+
} else {
|
|
174
|
+
db.rawQuery("SELECT key FROM kv WHERE key >= ?1 AND key < (?1 || x'FF')", arrayOf(prefix))
|
|
118
175
|
}
|
|
119
|
-
|
|
120
|
-
"SELECT key FROM kv WHERE key LIKE ? ESCAPE '\\'",
|
|
121
|
-
arrayOf(pattern),
|
|
122
|
-
).use { cursor ->
|
|
176
|
+
cursor.use {
|
|
123
177
|
val keys = ArrayList<String>(cursor.count)
|
|
124
178
|
while (cursor.moveToNext()) {
|
|
125
179
|
val key = cursor.getString(0)
|
|
@@ -156,8 +210,7 @@ internal class DiskSqliteStore(
|
|
|
156
210
|
}
|
|
157
211
|
}
|
|
158
212
|
|
|
159
|
-
|
|
160
|
-
try {
|
|
213
|
+
inTransaction {
|
|
161
214
|
for ((key, value) in legacyPreferences.all) {
|
|
162
215
|
if (value is String) {
|
|
163
216
|
db.execSQL(
|
|
@@ -170,9 +223,25 @@ internal class DiskSqliteStore(
|
|
|
170
223
|
"INSERT OR REPLACE INTO meta(k, v) VALUES(?, ?)",
|
|
171
224
|
arrayOf(PREFS_MIGRATION_KEY, "1"),
|
|
172
225
|
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private inline fun inTransaction(block: () -> Unit) {
|
|
230
|
+
db.beginTransaction()
|
|
231
|
+
var failure: Throwable? = null
|
|
232
|
+
try {
|
|
233
|
+
block()
|
|
173
234
|
db.setTransactionSuccessful()
|
|
235
|
+
} catch (error: Throwable) {
|
|
236
|
+
failure = error
|
|
237
|
+
throw error
|
|
174
238
|
} finally {
|
|
175
|
-
|
|
239
|
+
try {
|
|
240
|
+
db.endTransaction()
|
|
241
|
+
} catch (error: SQLiteException) {
|
|
242
|
+
val primary = failure ?: throw error
|
|
243
|
+
primary.addSuppressed(error)
|
|
244
|
+
}
|
|
176
245
|
}
|
|
177
246
|
}
|
|
178
247
|
|
|
@@ -186,8 +255,52 @@ internal class DiskSqliteStore(
|
|
|
186
255
|
editor.apply()
|
|
187
256
|
}
|
|
188
257
|
|
|
189
|
-
|
|
190
|
-
const val DATABASE_NAME = "nitro-storage-disk.sqlite"
|
|
191
|
-
const val PREFS_MIGRATION_KEY = "prefs_v1"
|
|
258
|
+
companion object {
|
|
259
|
+
private const val DATABASE_NAME = "nitro-storage-disk.sqlite"
|
|
260
|
+
private const val PREFS_MIGRATION_KEY = "prefs_v1"
|
|
261
|
+
private const val VALUE_CHUNK_BYTES = 512 * 1024
|
|
262
|
+
|
|
263
|
+
fun databaseFile(context: Context): File = File(context.filesDir, DATABASE_NAME)
|
|
264
|
+
|
|
265
|
+
fun deleteDatabaseFiles(context: Context) {
|
|
266
|
+
SQLiteDatabase.deleteDatabase(databaseFile(context))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private fun hasUnpairedSurrogate(value: String): Boolean {
|
|
272
|
+
var index = 0
|
|
273
|
+
while (index < value.length) {
|
|
274
|
+
val character = value[index]
|
|
275
|
+
if (Character.isHighSurrogate(character)) {
|
|
276
|
+
if (index + 1 >= value.length || !Character.isLowSurrogate(value[index + 1])) {
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
279
|
+
index += 2
|
|
280
|
+
} else if (Character.isLowSurrogate(character)) {
|
|
281
|
+
return true
|
|
282
|
+
} else {
|
|
283
|
+
index += 1
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private fun likePattern(prefix: String): String {
|
|
290
|
+
return buildString {
|
|
291
|
+
for (character in prefix) {
|
|
292
|
+
if (character == '\u0000') break
|
|
293
|
+
if (character == '%' || character == '_' || character == '\\') append('\\')
|
|
294
|
+
append(character)
|
|
295
|
+
}
|
|
296
|
+
append('%')
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
internal fun synchronousPragmaFor(journalMode: String?): String {
|
|
301
|
+
return if (journalMode.equals("wal", ignoreCase = true)) {
|
|
302
|
+
"PRAGMA synchronous=NORMAL"
|
|
303
|
+
} else {
|
|
304
|
+
"PRAGMA synchronous=FULL"
|
|
192
305
|
}
|
|
193
306
|
}
|