react-native-nitro-storage 0.12.0 → 0.14.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +107 -15
  3. package/SECURITY.md +2 -2
  4. package/android/build.gradle +5 -0
  5. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +12 -13
  6. package/android/src/main/cpp/JniSize.hpp +21 -0
  7. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +94 -7
  8. package/android/src/main/java/com/nitrostorage/DiskSqliteStore.kt +147 -34
  9. package/cpp/bindings/HybridStorage.cpp +12 -4
  10. package/cpp/core/SqliteDiskStore.cpp +172 -26
  11. package/cpp/core/SqliteDiskStore.hpp +10 -1
  12. package/docs/api-reference.md +60 -54
  13. package/docs/batch-transactions-migrations.md +7 -0
  14. package/docs/native-libraries.md +28 -0
  15. package/docs/qa/agent-device-replay.md +70 -0
  16. package/docs/secure-storage.md +12 -6
  17. package/ios/IOSStorageAdapterCpp.mm +89 -22
  18. package/lib/commonjs/Storage.types.js +4 -2
  19. package/lib/commonjs/Storage.types.js.map +1 -1
  20. package/lib/commonjs/core/durability.js +30 -2
  21. package/lib/commonjs/core/durability.js.map +1 -1
  22. package/lib/commonjs/index.js.map +1 -1
  23. package/lib/commonjs/index.web.js.map +1 -1
  24. package/lib/commonjs/shared.js.map +1 -1
  25. package/lib/commonjs/storage-core.js +63 -9
  26. package/lib/commonjs/storage-core.js.map +1 -1
  27. package/lib/commonjs/testing.js +1 -0
  28. package/lib/commonjs/testing.js.map +1 -1
  29. package/lib/module/Storage.types.js +4 -2
  30. package/lib/module/Storage.types.js.map +1 -1
  31. package/lib/module/core/durability.js +30 -2
  32. package/lib/module/core/durability.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/index.web.js.map +1 -1
  35. package/lib/module/shared.js.map +1 -1
  36. package/lib/module/storage-core.js +63 -9
  37. package/lib/module/storage-core.js.map +1 -1
  38. package/lib/module/testing.js +1 -0
  39. package/lib/module/testing.js.map +1 -1
  40. package/lib/typescript/Storage.types.d.ts +4 -2
  41. package/lib/typescript/Storage.types.d.ts.map +1 -1
  42. package/lib/typescript/core/durability.d.ts +7 -1
  43. package/lib/typescript/core/durability.d.ts.map +1 -1
  44. package/lib/typescript/index.d.ts +4 -2
  45. package/lib/typescript/index.d.ts.map +1 -1
  46. package/lib/typescript/index.web.d.ts +4 -2
  47. package/lib/typescript/index.web.d.ts.map +1 -1
  48. package/lib/typescript/shared.d.ts +8 -1
  49. package/lib/typescript/shared.d.ts.map +1 -1
  50. package/lib/typescript/storage-core.d.ts +6 -3
  51. package/lib/typescript/storage-core.d.ts.map +1 -1
  52. package/lib/typescript/testing.d.ts +7 -4
  53. package/lib/typescript/testing.d.ts.map +1 -1
  54. package/package.json +2 -1
  55. package/src/Storage.types.ts +4 -2
  56. package/src/core/durability.ts +46 -2
  57. package/src/index.ts +4 -0
  58. package/src/index.web.ts +4 -0
  59. package/src/shared.ts +17 -1
  60. package/src/storage-core.ts +87 -13
  61. package/src/testing.ts +5 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,53 @@ 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.14.0] - 2026-10-03
10
+
11
+ ### Breaking changes
12
+
13
+ - Transaction and migration callbacks must finish synchronously. Promise and thenable results now throw `TypeError`, roll back writes made through the transaction context, and leave migration versions unchanged. Context methods reject calls after the callback ends. Migration: finish asynchronous work before entering the callback and keep all context access inside it. TypeScript now rejects direct async callbacks.
14
+
15
+ ### Added
16
+
17
+ - `storage.setScheduledFlushErrorObserver()` reports scheduled Disk and Secure flush failures with their scope and backend adapter error. Failed writes stay queued for retry. Explicit flush calls still throw to their caller, and scheduled failures still propagate when no observer is installed.
18
+
19
+ ### Documentation
20
+
21
+ - `flushSecureWrites()` drains the JavaScript queue; it is not an Android `apply()` durability barrier. Select synchronous secure-write mode before writes that need synchronous native persistence.
22
+ - Biometric policy documentation now distinguishes iOS Keychain prompts from Android authentication when a protected store first opens and its cached keyset on later access.
23
+
24
+ ## [0.13.0] - 2026-10-01
25
+
26
+ ### Breaking changes
27
+
28
+ - **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.
29
+ - **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.
30
+
31
+ ### Added
32
+
33
+ - `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.
34
+ - `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.
35
+
36
+ ### Changed
37
+
38
+ - 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.
39
+ - `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.
40
+
41
+ ### Fixed
42
+
43
+ - `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.
44
+ - 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.
45
+ - Android: Disk values larger than 2 MiB can be read. They could be written but not read.
46
+ - 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.
47
+ - iOS: Disk I/O errors caused by a full device or an exceeded quota carry the `storage_full` code, including while the database opens.
48
+ - iOS: a Disk read of a legacy value that is not yet migrated no longer fails when the Disk database is full.
49
+ - iOS: `clearSecureBiometric` reports an unavailable Keychain with the `keychain_locked` code.
50
+ - iOS: the Disk WAL file is truncated after checkpoints on every SQLite build.
51
+ - iOS: a Disk key that is not valid UTF-8 is rejected before anything is written or removed.
52
+ - `removeByPrefix("", scope)` rejects an invalid scope.
53
+ - Unknown native failures in `hasSecureBiometric`, `setSecureAccessControl`, `setSecureWritesAsync`, and `setKeychainAccessGroup` surface as a `NitroStorage: … failed (unknown error)` error.
54
+ - The Android library declares its unit-test dependencies only inside the repository, so consumer builds do not resolve them.
55
+
9
56
  ## [0.12.0] - 2026-10-01
10
57
 
11
58
  ### 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.12.0
94
+ bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.14.0
95
95
  bunx expo prebuild
96
96
  ```
97
97
 
@@ -491,9 +491,12 @@ explicitly opt into `{ includeSecureValues: true }`.
491
491
 
492
492
  Android secure writes default to synchronous `commit()` durability. Call
493
493
  `storage.setSecureWritesAsync(true)` only when asynchronous `apply()` writes are
494
- acceptable. After opting into async writes, call `storage.flushSecureWrites()`
495
- before a deterministic persistence boundary. A failed secure flush throws and
496
- keeps failed or unattempted queued writes available for retry.
494
+ acceptable. `storage.flushSecureWrites()` drains the JavaScript write queue into
495
+ the native backend; it does not wait for Android `apply()` to reach disk. Keep
496
+ the default synchronous mode, or call `storage.setSecureWritesAsync(false)`
497
+ before the writes that need synchronous native persistence. Changing the mode
498
+ does not make earlier `apply()` calls durable retroactively. A failed explicit
499
+ flush throws and keeps failed or unattempted queued writes available for retry.
497
500
  `storage.clearBiometric()`
498
501
  flushes pending Secure writes before clearing biometric entries and surfaces
499
502
  native clear failures.
@@ -538,6 +541,43 @@ Biometric behaviour differs by platform:
538
541
  before every read that must be gated. Reading a `BiometryOrPasscode` item
539
542
  opens only that store, so a device-credential authentication is enough.
540
543
 
544
+ Scheduled Disk and Secure flush failures can be handled without losing the
545
+ pending writes:
546
+
547
+ ```ts
548
+ import {
549
+ storage,
550
+ type StorageScheduledFlushError,
551
+ } from "react-native-nitro-storage";
552
+
553
+ let lastFlushFailure: StorageScheduledFlushError | undefined;
554
+ storage.setScheduledFlushErrorObserver((failure) => {
555
+ lastFlushFailure = failure;
556
+ });
557
+
558
+ // Call after resolving the failure, such as freeing space or unlocking storage.
559
+ function retryPendingWrites() {
560
+ storage.flushDiskWrites();
561
+ storage.flushSecureWrites();
562
+ lastFlushFailure = undefined;
563
+ }
564
+
565
+ // Remove the observer when its owner is disposed.
566
+ function stopObservingFlushErrors() {
567
+ storage.setScheduledFlushErrorObserver(undefined);
568
+ }
569
+ ```
570
+
571
+ The observer receives the failed scope and the error thrown by the backend
572
+ adapter. Web adapters may wrap the underlying failure in an error with `cause`.
573
+ It replaces
574
+ the uncaught scheduled-flush error only while installed; without an observer,
575
+ the error still propagates. Explicit flush calls always throw to their caller,
576
+ and an observer that throws also propagates its error. Register one observer per
577
+ storage instance and handle retry failures at the calling boundary. The observer
578
+ reports the synchronous backend handoff; it cannot report a later Android
579
+ `apply()` or IndexedDB persistence failure that the backend does not expose.
580
+
541
581
  ## Batch Operations
542
582
 
543
583
  `getBatch()` preserves tuple value types, so IDEs infer each result from the
@@ -666,10 +706,16 @@ migrateFromMMKV(mmkvInstance, themeItem);
666
706
 
667
707
  `runTransaction(scope, callback)` rolls back every write made through the `tx`
668
708
  context if the callback throws, then emits one typed `rollback` batch event.
709
+ Callbacks must be synchronous. A Promise or thenable return throws a `TypeError`
710
+ and rolls back changes made through the context. Every context method becomes
711
+ unavailable when the callback ends, including for a continuation after `await`.
712
+ Complete asynchronous work before calling `runTransaction()` or registering a
713
+ migration; do not retain the context outside its callback.
669
714
 
670
715
  Each migration step runs in its own transaction with its version marker, so a
671
716
  failed step leaves the scope on the last completed version and rerunning
672
717
  `migrateToLatest()` retries deterministically.
718
+ An asynchronous migration is rejected without advancing its version marker.
673
719
 
674
720
  ## Web Backends
675
721
 
@@ -752,17 +798,17 @@ Native and web adapters tag classified failures with stable error codes. Use
752
798
  Errors never swallow the underlying cause silently: the original platform
753
799
  message is preserved on the error for diagnostics.
754
800
 
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 decrypted, 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. |
801
+ | Code | Meaning |
802
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
803
+ | `keychain_locked` | The protected store is locked. Retry after the device unlocks. |
804
+ | `authentication_required` | The item needs user authentication, or the user cancelled the prompt. |
805
+ | `key_invalidated` | The protecting key was invalidated, for example by a biometric enrolment change. |
806
+ | `biometric_unavailable` | The requested biometric level is not available on this device or OS version. |
807
+ | `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. |
808
+ | `storage_compensation_failed` | A multi-step write failed and restoring the previous state also failed. |
809
+ | `unsupported` | The operation is not available on this platform or environment. |
810
+ | `storage_full` | The device or database is out of space, or the web storage quota is exceeded. Free space before retrying. |
811
+ | `invalid_key` | The storage key is empty. Keys must be non-empty strings. |
766
812
 
767
813
  Invalid scopes and non-finite numeric levels are rejected with untagged errors
768
814
  before they reach native storage.
@@ -788,6 +834,44 @@ function saveDraft(draft: string): "saved" | "storage_full" {
788
834
  }
789
835
  ```
790
836
 
837
+ A full device does not block cleanup. `storage.clear(StorageScope.Disk)`
838
+ deletes and recreates the Disk database when a normal delete fails with
839
+ `storage_full`, which frees the space the database used. `remove` and
840
+ `removeBatch` can still fail with `storage_full`, because a delete also writes
841
+ to the database log.
842
+
843
+ A corrupt Disk database is never deleted automatically. Every Disk call fails
844
+ with `storage_corruption` until the app clears Disk storage:
845
+
846
+ ```ts
847
+ import {
848
+ isStorageError,
849
+ storage,
850
+ StorageScope,
851
+ } from "react-native-nitro-storage";
852
+
853
+ function readDraft(): string | undefined {
854
+ try {
855
+ return storage.getString("draft", StorageScope.Disk);
856
+ } catch (error) {
857
+ if (!isStorageError(error, "storage_corruption")) throw error;
858
+ storage.clear(StorageScope.Disk);
859
+ return undefined;
860
+ }
861
+ }
862
+ ```
863
+
864
+ `storage.clear(StorageScope.Disk)` and `storage.clearAll()` are the recovery
865
+ calls. `clear` with `except`, namespace clears, and `removeByPrefix` read keys
866
+ first and fail on a corrupt database. A `clear` batch event with an empty
867
+ `changes` array means every key in that scope was removed and the previous
868
+ values could not be read; treat all keys in the scope as deleted.
869
+
870
+ On Android, Disk values above 512 KiB are read in 512 KiB chunks, and read
871
+ time grows faster than value size (about 30 ms for 5 MiB and 265 ms for 20 MiB
872
+ in a host measurement; devices are slower). Keep single Disk values below about
873
+ 5 MiB.
874
+
791
875
  ## Platform Support
792
876
 
793
877
  | Platform | Status |
@@ -812,6 +896,7 @@ function saveDraft(draft: string): "saved" | "storage_full" {
812
896
  | Native libraries | [docs/native-libraries.md](docs/native-libraries.md) |
813
897
  | Recipes | [docs/recipes.md](docs/recipes.md) |
814
898
  | Benchmarks | [docs/benchmarks.md](docs/benchmarks.md) |
899
+ | Example replay coverage | [docs/qa/agent-device-replay.md](docs/qa/agent-device-replay.md) |
815
900
  | Security policy | [SECURITY.md](SECURITY.md) |
816
901
 
817
902
  ## Troubleshooting
@@ -849,6 +934,13 @@ Nitro, secure storage, or packaging files. GitHub CI does not build the Android
849
934
  or iOS example. The package release path also validates package contents and
850
935
  dry-run publish behavior.
851
936
 
937
+ Run `bun run example:replay:check` to check that replay coverage matches package
938
+ and example sources. After reviewing affected assertions, use
939
+ `bun run example:replay:refresh` to update the source lock. Device execution uses
940
+ `bun run example:replay --platform ios --udid <exact-target>` or
941
+ `--platform android --serial <exact-target>`; see the
942
+ [replay guide](docs/qa/agent-device-replay.md) for prerequisites and coverage limits.
943
+
852
944
  `bun run benchmark` measures only the built web entry with an isolated private
853
945
  localStorage implementation; it is not a native Disk or Secure benchmark. See
854
946
  [docs/benchmarks.md](docs/benchmarks.md) for sampling and interpretation limits.
package/SECURITY.md CHANGED
@@ -6,8 +6,8 @@ Security fixes are shipped for the latest published `0.x` release line.
6
6
 
7
7
  | Version | Supported |
8
8
  | -------- | --------- |
9
- | `0.11.x` | Yes |
10
- | `< 0.11` | No |
9
+ | `0.13.x` | Yes |
10
+ | `< 0.13` | No |
11
11
 
12
12
  ## Reporting a Vulnerability
13
13
 
@@ -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
- if (value.size() > static_cast<size_t>(std::numeric_limits<jsize>::max())) {
17
- throw std::length_error("Storage string exceeds the Java array limit");
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
- auto javaArray = JavaStringArray::newArray(static_cast<jsize>(values.size()));
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(static_cast<jsize>(i), javaValue.get());
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 jsize size = static_cast<jsize>(values->size());
40
+ const size_t size = values->size();
42
41
  parsedValues.reserve(size);
43
- for (jsize i = 0; i < size; ++i) {
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 jsize size = values->size();
55
+ const size_t size = values->size();
57
56
  std::vector<std::string> result;
58
57
  result.reserve(size);
59
- for (jsize i = 0; i < size; ++i) {
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 static_cast<size_t>(method(AndroidStorageAdapterJava::javaClassStatic()));
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 static_cast<size_t>(method(AndroidStorageAdapterJava::javaClassStatic()));
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
- private fun Throwable.hasCause(type: Class<*>): Boolean {
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
- private fun Throwable.storageErrorCode(): String? {
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
- private fun Throwable.wrapStorageException(
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 diskStore: DiskSqliteStore by lazy {
70
- DiskSqliteStore(context, sharedPreferences)
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
- return instance.diskStore.block()
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
- diskOperation("clear") { clear() }
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) ---