react-native-nitro-storage 0.10.2 → 0.10.4

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 (42) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +38 -8
  3. package/android/CMakeLists.txt +2 -0
  4. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +47 -29
  5. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +12 -25
  6. package/android/src/main/java/com/nitrostorage/DiskSqliteStore.kt +174 -0
  7. package/cpp/core/SqliteDiskStore.cpp +416 -0
  8. package/cpp/core/SqliteDiskStore.hpp +69 -0
  9. package/docs/api-reference.md +4 -1
  10. package/docs/benchmarks.md +37 -0
  11. package/docs/native-libraries.md +30 -0
  12. package/docs/secure-storage.md +5 -0
  13. package/ios/IOSStorageAdapterCpp.mm +146 -52
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js.map +1 -1
  16. package/lib/commonjs/shared.js.map +1 -1
  17. package/lib/commonjs/storage-core.js +57 -11
  18. package/lib/commonjs/storage-core.js.map +1 -1
  19. package/lib/commonjs/testing.js.map +1 -1
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/index.web.js.map +1 -1
  22. package/lib/module/shared.js.map +1 -1
  23. package/lib/module/storage-core.js +57 -11
  24. package/lib/module/storage-core.js.map +1 -1
  25. package/lib/module/testing.js.map +1 -1
  26. package/lib/typescript/index.d.ts +2 -1
  27. package/lib/typescript/index.d.ts.map +1 -1
  28. package/lib/typescript/index.web.d.ts +2 -1
  29. package/lib/typescript/index.web.d.ts.map +1 -1
  30. package/lib/typescript/shared.d.ts +6 -0
  31. package/lib/typescript/shared.d.ts.map +1 -1
  32. package/lib/typescript/storage-core.d.ts +2 -1
  33. package/lib/typescript/storage-core.d.ts.map +1 -1
  34. package/lib/typescript/testing.d.ts +3 -1
  35. package/lib/typescript/testing.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/react-native-nitro-storage.podspec +2 -0
  38. package/src/index.ts +1 -0
  39. package/src/index.web.ts +1 -0
  40. package/src/shared.ts +7 -0
  41. package/src/storage-core.ts +65 -14
  42. package/src/testing.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,51 @@ 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.10.4] - 2026-09-27
10
+
11
+ ### Breaking changes
12
+
13
+ - None.
14
+
15
+ ### Fixed
16
+
17
+ - Disk prefix queries and namespace removal now distinguish ASCII case, so clearing `user` does not remove `User` keys.
18
+ - Android Disk and Secure storage preserve embedded NUL characters in keys and values across scalar and batch operations, preventing truncated-key collisions.
19
+ - iOS Secure storage preserves embedded NUL keys and values, and legacy Disk migration no longer aliases a shorter host-app defaults key when a key contains NUL.
20
+ - Memory prefix reads return the original raw string, including values beginning with the reserved internal prefix.
21
+ - Raw enumeration, export, and set items preserve arbitrary keys such as `__proto__`, `constructor`, and `toString` as own properties.
22
+
23
+ ## [0.10.3] - 2026-09-18
24
+
25
+ ### Breaking changes
26
+
27
+ - None.
28
+
29
+ ### Added
30
+
31
+ - `storage.getCacheMetrics()` reports live Disk/Secure raw-cache hits, misses,
32
+ entries, and estimated bytes. The cache stays unbounded; reset zeros the
33
+ counters only.
34
+ - Example integrity, keychain, and Disk/Secure stress labs at
35
+ `nitrostorage://e2e-integrity`, `nitrostorage://e2e-keychain`, and
36
+ `nitrostorage://e2e-stress`.
37
+ - Native Disk now uses SQLite WAL (`SqliteDiskStore` on iOS, `DiskSqliteStore`
38
+ on Android). Existing UserDefaults suite keys and `NitroStorage`
39
+ SharedPreferences values are imported once. See
40
+ [docs/native-libraries.md](docs/native-libraries.md).
41
+
42
+ ### Changed
43
+
44
+ - The example Expo pin follows SDK 57.0.24 (`expo-doctor` / `expo install --check`).
45
+ React Native stays `0.86.3`.
46
+ - The example iOS host uses a `SceneDelegate` so the app can present a window on
47
+ iOS 27 physical devices.
48
+
49
+ ### Fixed
50
+
51
+ - Android Disk no longer crashes on first open when enabling SQLite WAL:
52
+ `PRAGMA journal_mode=WAL` is applied with `rawQuery` instead of `execSQL`.
53
+
9
54
  ## [0.10.2] - 2026-09-10
10
55
 
11
56
  ### Breaking changes
package/README.md CHANGED
@@ -35,6 +35,7 @@ pagination, conflict resolution, or remote synchronization.
35
35
  - [Legacy Key Migration And Secure Resilience](#legacy-key-migration-and-secure-resilience)
36
36
  - [React Hooks](#react-hooks)
37
37
  - [Storage Scopes](#storage-scopes)
38
+ - [Prefix Queries](#prefix-queries)
38
39
  - [Secure Storage](#secure-storage)
39
40
  - [Batch Operations](#batch-operations)
40
41
  - [Events And Observability](#events-and-observability)
@@ -68,7 +69,7 @@ Nitro peer requirement: `react-native-nitro-modules >=0.37.0 <0.38.0`.
68
69
  The package gate uses React Native `0.86.3` and the Strict TypeScript API.
69
70
  `check:ci` also compiles the public source against React Native `0.87.0`'s
70
71
  Strict TypeScript API; this does not change the runtime baseline. The Expo
71
- example uses Expo SDK `57.0.21`, React Native
72
+ example uses Expo SDK `57.0.25`, React Native
72
73
  `0.86.3`, React `19.2.3`, and Nitro Modules `0.37.1`, which is the React Native
73
74
  version supported by that Expo SDK. Do not override Expo's React Native version.
74
75
 
@@ -77,7 +78,7 @@ before installing this package, then rebuild the native app so the generated
77
78
  Nitro bindings and native runtime use the same major-minor version:
78
79
 
79
80
  ```sh
80
- bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.10.2
81
+ bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.10.4
81
82
  bunx expo prebuild
82
83
  ```
83
84
 
@@ -387,11 +388,31 @@ const tokenActions = useStorageActions(tokenItem); // { set, merge, reset, remov
387
388
 
388
389
  ## Storage Scopes
389
390
 
390
- | Scope | Backing store | Use it for |
391
- | --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------- |
392
- | `StorageScope.Memory` | In-process memory | Session-only state, fast counters, and render-time caches. |
393
- | `StorageScope.Disk` | UserDefaults on iOS, SharedPreferences on Android, web | Preferences, feature flags, onboarding state, and non-secret persisted data. |
394
- | `StorageScope.Secure` | Keychain on iOS, Android Keystore-backed preferences | Refresh tokens, credentials, API tokens, and biometric-protected values. |
391
+ | Scope | Backing store | Use it for |
392
+ | --------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
393
+ | `StorageScope.Memory` | In-process memory | Session-only state, fast counters, and render-time caches. |
394
+ | `StorageScope.Disk` | SQLite WAL on iOS/Android (imports UserDefaults / SharedPreferences once); configured web backend | Preferences, feature flags, onboarding state, and non-secret persisted data. |
395
+ | `StorageScope.Secure` | Keychain on iOS, Android Keystore-backed preferences | Refresh tokens, credentials, API tokens, and biometric-protected values. |
396
+
397
+ ## Prefix Queries
398
+
399
+ Prefix queries use literal, case-sensitive matching on every backend. `User::`
400
+ and `user::` are separate namespaces; `%`, `_`, and `\\` are literal characters.
401
+ Raw enumeration returns the original strings and preserves arbitrary keys as own
402
+ properties on an ordinary object, including `__proto__`.
403
+
404
+ Disk and Secure strings preserve embedded NUL characters in keys and values,
405
+ including batch operations. Earlier versions could truncate strings at Android
406
+ JNI or iOS Foundation boundaries; this fix cannot reconstruct previously lost
407
+ suffixes. iOS legacy Disk migration also preserves full keys without aliasing
408
+ shorter host-app defaults keys.
409
+
410
+ ```ts
411
+ storage.setString("User::theme", "dark", StorageScope.Disk);
412
+ storage.setString("user::theme", "light", StorageScope.Disk);
413
+ const settings = storage.getByPrefix("User::", StorageScope.Disk);
414
+ // settings["User::theme"] === "dark"; no lowercase namespace entries.
415
+ ```
395
416
 
396
417
  ## Secure Storage
397
418
 
@@ -520,13 +541,21 @@ storage.setMetricsObserver((event) => {
520
541
 
521
542
  const metrics = storage.getMetricsSnapshot();
522
543
  const scopedMetrics = storage.getScopedMetricsSnapshot();
544
+ const cacheMetrics = storage.getCacheMetrics();
523
545
  storage.resetMetrics();
524
546
  unsubscribe();
525
547
  ```
526
548
 
527
549
  `getMetricsSnapshot()` aggregates each operation across scopes for backward
528
550
  compatibility. `getScopedMetricsSnapshot()` adds the numeric scope suffix for
529
- per-scope analysis, for example `item:set:1`.
551
+ per-scope analysis, for example `item:set:1`. `getCacheMetrics()` reports live
552
+ Disk/Secure raw-cache hits, misses, entries, and estimated bytes. The cache is
553
+ unbounded; `resetMetrics()` zeros the hit/miss counters and leaves entries in
554
+ place.
555
+
556
+ The example app includes hidden integrity, keychain, and Disk/Secure stress
557
+ labs at `nitrostorage://e2e-integrity`, `nitrostorage://e2e-keychain`, and
558
+ `nitrostorage://e2e-stress`.
530
559
 
531
560
  Secure event observer values are redacted by default. Pass
532
561
  `{ redactSecureValues: false }` only in trusted debug tooling where raw values
@@ -674,6 +703,7 @@ the error for diagnostics.
674
703
  | Web backends | [docs/web-backends.md](docs/web-backends.md) |
675
704
  | Batch, transactions, and migrations | [docs/batch-transactions-migrations.md](docs/batch-transactions-migrations.md) |
676
705
  | MMKV migration | [docs/mmkv-migration.md](docs/mmkv-migration.md) |
706
+ | Native libraries | [docs/native-libraries.md](docs/native-libraries.md) |
677
707
  | Recipes | [docs/recipes.md](docs/recipes.md) |
678
708
  | Benchmarks | [docs/benchmarks.md](docs/benchmarks.md) |
679
709
  | Security policy | [SECURITY.md](SECURITY.md) |
@@ -13,6 +13,8 @@ file(GLOB SOURCES
13
13
  )
14
14
  # Unit/integration C++ tests define `main()` and must never be linked into the Android shared library.
15
15
  list(FILTER SOURCES EXCLUDE REGEX ".*/[^/]*Test\\.cpp$")
16
+ # iOS/macOS Disk SQLite lives in this translation unit; Android uses DiskSqliteStore.kt.
17
+ list(FILTER SOURCES EXCLUDE REGEX ".*/SqliteDiskStore\\.cpp$")
16
18
 
17
19
  # 2. Create the library target
18
20
  add_library(
@@ -1,5 +1,8 @@
1
1
  #include "AndroidStorageAdapterCpp.hpp"
2
2
 
3
+ #include <limits>
4
+ #include <stdexcept>
5
+
3
6
  namespace NitroStorage {
4
7
 
5
8
  using namespace facebook::jni;
@@ -7,10 +10,25 @@ using JavaStringArray = JArrayClass<jstring>;
7
10
 
8
11
  namespace {
9
12
 
13
+ local_ref<JString> toJavaString(const std::string& value) {
14
+ // fbjni's std::string overload uses c_str(), which truncates embedded NUL.
15
+ 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);
21
+ bytes->setRegion(0, size, reinterpret_cast<const jbyte*>(value.data()));
22
+ static auto constructor = JString::javaClassStatic()->getConstructor<
23
+ jstring(jbyteArray, jstring)>();
24
+ auto charset = make_jstring("UTF-8");
25
+ return JString::javaClassStatic()->newObject(constructor, bytes.get(), charset.get());
26
+ }
27
+
10
28
  local_ref<JavaStringArray> toJavaStringArray(const std::vector<std::string>& values) {
11
29
  auto javaArray = JavaStringArray::newArray(static_cast<jsize>(values.size()));
12
30
  for (size_t i = 0; i < values.size(); ++i) {
13
- auto javaValue = make_jstring(values[i]);
31
+ auto javaValue = toJavaString(values[i]);
14
32
  javaArray->setElement(static_cast<jsize>(i), javaValue.get());
15
33
  }
16
34
  return javaArray;
@@ -61,25 +79,25 @@ AndroidStorageAdapterCpp::~AndroidStorageAdapterCpp() = default;
61
79
  // --- Disk ---
62
80
 
63
81
  void AndroidStorageAdapterCpp::setDisk(const std::string& key, const std::string& value) {
64
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string, std::string)>("setDisk");
65
- method(AndroidStorageAdapterJava::javaClassStatic(), key, value);
82
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>, alias_ref<JString>)>("setDisk");
83
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key), toJavaString(value));
66
84
  }
67
85
 
68
86
  std::optional<std::string> AndroidStorageAdapterCpp::getDisk(const std::string& key) {
69
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(std::string)>("getDisk");
70
- auto result = method(AndroidStorageAdapterJava::javaClassStatic(), key);
87
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(alias_ref<JString>)>("getDisk");
88
+ auto result = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
71
89
  if (!result) return std::nullopt;
72
90
  return result->toStdString();
73
91
  }
74
92
 
75
93
  void AndroidStorageAdapterCpp::deleteDisk(const std::string& key) {
76
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string)>("deleteDisk");
77
- method(AndroidStorageAdapterJava::javaClassStatic(), key);
94
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>)>("deleteDisk");
95
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
78
96
  }
79
97
 
80
98
  bool AndroidStorageAdapterCpp::hasDisk(const std::string& key) {
81
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(std::string)>("hasDisk");
82
- return method(AndroidStorageAdapterJava::javaClassStatic(), key);
99
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(alias_ref<JString>)>("hasDisk");
100
+ return method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
83
101
  }
84
102
 
85
103
  std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysDisk() {
@@ -92,9 +110,9 @@ std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysDisk() {
92
110
 
93
111
  std::vector<std::string> AndroidStorageAdapterCpp::getKeysByPrefixDisk(const std::string& prefix) {
94
112
  static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<
95
- local_ref<JavaStringArray>(std::string)
113
+ local_ref<JavaStringArray>(alias_ref<JString>)
96
114
  >("getKeysByPrefixDisk");
97
- auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), prefix);
115
+ auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(prefix));
98
116
  return fromJavaStringArray(keys);
99
117
  }
100
118
 
@@ -145,25 +163,25 @@ void AndroidStorageAdapterCpp::clearDisk() {
145
163
  // --- Secure ---
146
164
 
147
165
  void AndroidStorageAdapterCpp::setSecure(const std::string& key, const std::string& value) {
148
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string, std::string)>("setSecure");
149
- method(AndroidStorageAdapterJava::javaClassStatic(), key, value);
166
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>, alias_ref<JString>)>("setSecure");
167
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key), toJavaString(value));
150
168
  }
151
169
 
152
170
  std::optional<std::string> AndroidStorageAdapterCpp::getSecure(const std::string& key) {
153
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(std::string)>("getSecure");
154
- auto result = method(AndroidStorageAdapterJava::javaClassStatic(), key);
171
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(alias_ref<JString>)>("getSecure");
172
+ auto result = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
155
173
  if (!result) return std::nullopt;
156
174
  return result->toStdString();
157
175
  }
158
176
 
159
177
  void AndroidStorageAdapterCpp::deleteSecure(const std::string& key) {
160
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string)>("deleteSecure");
161
- method(AndroidStorageAdapterJava::javaClassStatic(), key);
178
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>)>("deleteSecure");
179
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
162
180
  }
163
181
 
164
182
  bool AndroidStorageAdapterCpp::hasSecure(const std::string& key) {
165
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(std::string)>("hasSecure");
166
- return method(AndroidStorageAdapterJava::javaClassStatic(), key);
183
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(alias_ref<JString>)>("hasSecure");
184
+ return method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
167
185
  }
168
186
 
169
187
  std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysSecure() {
@@ -176,9 +194,9 @@ std::vector<std::string> AndroidStorageAdapterCpp::getAllKeysSecure() {
176
194
 
177
195
  std::vector<std::string> AndroidStorageAdapterCpp::getKeysByPrefixSecure(const std::string& prefix) {
178
196
  static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<
179
- local_ref<JavaStringArray>(std::string)
197
+ local_ref<JavaStringArray>(alias_ref<JString>)
180
198
  >("getKeysByPrefixSecure");
181
- auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), prefix);
199
+ auto keys = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(prefix));
182
200
  return fromJavaStringArray(keys);
183
201
  }
184
202
 
@@ -242,25 +260,25 @@ void AndroidStorageAdapterCpp::setSecureBiometric(const std::string& key, const
242
260
  }
243
261
 
244
262
  void AndroidStorageAdapterCpp::setSecureBiometricWithLevel(const std::string& key, const std::string& value, int level) {
245
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string, std::string, jint)>("setSecureBiometricWithLevel");
246
- method(AndroidStorageAdapterJava::javaClassStatic(), key, value, level);
263
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>, alias_ref<JString>, jint)>("setSecureBiometricWithLevel");
264
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key), toJavaString(value), level);
247
265
  }
248
266
 
249
267
  std::optional<std::string> AndroidStorageAdapterCpp::getSecureBiometric(const std::string& key) {
250
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(std::string)>("getSecureBiometric");
251
- auto result = method(AndroidStorageAdapterJava::javaClassStatic(), key);
268
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jstring(alias_ref<JString>)>("getSecureBiometric");
269
+ auto result = method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
252
270
  if (!result) return std::nullopt;
253
271
  return result->toStdString();
254
272
  }
255
273
 
256
274
  void AndroidStorageAdapterCpp::deleteSecureBiometric(const std::string& key) {
257
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(std::string)>("deleteSecureBiometric");
258
- method(AndroidStorageAdapterJava::javaClassStatic(), key);
275
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<void(alias_ref<JString>)>("deleteSecureBiometric");
276
+ method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
259
277
  }
260
278
 
261
279
  bool AndroidStorageAdapterCpp::hasSecureBiometric(const std::string& key) {
262
- static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(std::string)>("hasSecureBiometric");
263
- return method(AndroidStorageAdapterJava::javaClassStatic(), key);
280
+ static auto method = AndroidStorageAdapterJava::javaClassStatic()->getStaticMethod<jboolean(alias_ref<JString>)>("hasSecureBiometric");
281
+ return method(AndroidStorageAdapterJava::javaClassStatic(), toJavaString(key));
264
282
  }
265
283
 
266
284
  void AndroidStorageAdapterCpp::clearSecureBiometric() {
@@ -61,6 +61,7 @@ private fun Throwable.wrapStorageException(
61
61
  class AndroidStorageAdapter private constructor(private val context: Context) {
62
62
  private val sharedPreferences: SharedPreferences =
63
63
  context.getSharedPreferences("NitroStorage", Context.MODE_PRIVATE)
64
+ private val diskStore: DiskSqliteStore = DiskSqliteStore(context, sharedPreferences)
64
65
 
65
66
  private val masterKeyAlias = "${context.packageName}.nitro_storage.master_key"
66
67
 
@@ -519,71 +520,57 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
519
520
 
520
521
  @JvmStatic
521
522
  fun setDisk(key: String, value: String) {
522
- getInstanceOrThrow().sharedPreferences.edit().putString(key, value).apply()
523
+ getInstanceOrThrow().diskStore.set(key, value)
523
524
  }
524
525
 
525
526
  @JvmStatic
526
527
  fun setDiskBatch(keys: Array<String>, values: Array<String>) {
527
- val editor = getInstanceOrThrow().sharedPreferences.edit()
528
- val count = minOf(keys.size, values.size)
529
- for (index in 0 until count) {
530
- editor.putString(keys[index], values[index])
531
- }
532
- editor.apply()
528
+ getInstanceOrThrow().diskStore.setBatch(keys, values)
533
529
  }
534
530
 
535
531
  @JvmStatic
536
532
  fun getDisk(key: String): String? {
537
- return getInstanceOrThrow().sharedPreferences.getString(key, null)
533
+ return getInstanceOrThrow().diskStore.get(key)
538
534
  }
539
535
 
540
536
  @JvmStatic
541
537
  fun getDiskBatch(keys: Array<String>): Array<String?> {
542
- val prefs = getInstanceOrThrow().sharedPreferences
543
- return Array(keys.size) { index ->
544
- prefs.getString(keys[index], null)
545
- }
538
+ return getInstanceOrThrow().diskStore.getBatch(keys)
546
539
  }
547
540
 
548
541
  @JvmStatic
549
542
  fun deleteDisk(key: String) {
550
- getInstanceOrThrow().sharedPreferences.edit().remove(key).apply()
543
+ getInstanceOrThrow().diskStore.remove(key)
551
544
  }
552
545
 
553
546
  @JvmStatic
554
547
  fun deleteDiskBatch(keys: Array<String>) {
555
- val editor = getInstanceOrThrow().sharedPreferences.edit()
556
- for (key in keys) {
557
- editor.remove(key)
558
- }
559
- editor.apply()
548
+ getInstanceOrThrow().diskStore.removeBatch(keys)
560
549
  }
561
550
 
562
551
  @JvmStatic
563
552
  fun hasDisk(key: String): Boolean {
564
- return getInstanceOrThrow().sharedPreferences.contains(key)
553
+ return getInstanceOrThrow().diskStore.has(key)
565
554
  }
566
555
 
567
556
  @JvmStatic
568
557
  fun getAllKeysDisk(): Array<String> {
569
- return getInstanceOrThrow().sharedPreferences.all.keys.toTypedArray()
558
+ return getInstanceOrThrow().diskStore.getAllKeys()
570
559
  }
571
560
 
572
561
  @JvmStatic
573
562
  fun getKeysByPrefixDisk(prefix: String): Array<String> {
574
- return getInstanceOrThrow().sharedPreferences.all.keys
575
- .filter { it.startsWith(prefix) }
576
- .toTypedArray()
563
+ return getInstanceOrThrow().diskStore.getKeysByPrefix(prefix)
577
564
  }
578
565
 
579
566
  @JvmStatic
580
567
  fun sizeDisk(): Int {
581
- return getInstanceOrThrow().sharedPreferences.all.size
568
+ return getInstanceOrThrow().diskStore.size()
582
569
  }
583
570
 
584
571
  @JvmStatic
585
572
  fun clearDisk() {
586
- getInstanceOrThrow().sharedPreferences.edit().clear().apply()
573
+ getInstanceOrThrow().diskStore.clear()
587
574
  }
588
575
 
589
576
  // --- Secure (async apply by default, sync commit when requested) ---
@@ -0,0 +1,174 @@
1
+ package com.nitrostorage
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.database.sqlite.SQLiteDatabase
6
+ import java.io.File
7
+
8
+ internal class DiskSqliteStore(
9
+ context: Context,
10
+ private val legacyPreferences: SharedPreferences,
11
+ ) {
12
+ private val db: SQLiteDatabase
13
+
14
+ init {
15
+ val file = File(context.filesDir, DATABASE_NAME)
16
+ db = SQLiteDatabase.openOrCreateDatabase(file, null)
17
+ // PRAGMA journal_mode returns a row; Android forbids result-bearing SQL on execSQL.
18
+ db.rawQuery("PRAGMA journal_mode=WAL", null).close()
19
+ db.execSQL("PRAGMA synchronous=NORMAL")
20
+ db.execSQL(
21
+ "CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)",
22
+ )
23
+ db.execSQL(
24
+ "CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY NOT NULL, v TEXT NOT NULL)",
25
+ )
26
+ migrateLegacyPreferences()
27
+ }
28
+
29
+ @Synchronized
30
+ fun set(key: String, value: String) {
31
+ db.execSQL(
32
+ "INSERT OR REPLACE INTO kv(key, value) VALUES(?, ?)",
33
+ arrayOf(key, value),
34
+ )
35
+ }
36
+
37
+ @Synchronized
38
+ fun get(key: String): String? {
39
+ db.rawQuery("SELECT value FROM kv WHERE key = ? LIMIT 1", arrayOf(key)).use { cursor ->
40
+ return if (cursor.moveToFirst()) cursor.getString(0) else null
41
+ }
42
+ }
43
+
44
+ @Synchronized
45
+ fun remove(key: String) {
46
+ db.execSQL("DELETE FROM kv WHERE key = ?", arrayOf(key))
47
+ }
48
+
49
+ @Synchronized
50
+ fun has(key: String): Boolean {
51
+ db.rawQuery("SELECT 1 FROM kv WHERE key = ? LIMIT 1", arrayOf(key)).use { cursor ->
52
+ return cursor.moveToFirst()
53
+ }
54
+ }
55
+
56
+ @Synchronized
57
+ fun setBatch(keys: Array<String>, values: Array<String>) {
58
+ val count = minOf(keys.size, values.size)
59
+ db.beginTransaction()
60
+ try {
61
+ for (index in 0 until count) {
62
+ db.execSQL(
63
+ "INSERT OR REPLACE INTO kv(key, value) VALUES(?, ?)",
64
+ arrayOf(keys[index], values[index]),
65
+ )
66
+ }
67
+ db.setTransactionSuccessful()
68
+ } finally {
69
+ db.endTransaction()
70
+ }
71
+ }
72
+
73
+ @Synchronized
74
+ fun getBatch(keys: Array<String>): Array<String?> {
75
+ return Array(keys.size) { index -> get(keys[index]) }
76
+ }
77
+
78
+ @Synchronized
79
+ fun removeBatch(keys: Array<String>) {
80
+ db.beginTransaction()
81
+ try {
82
+ for (key in keys) {
83
+ db.execSQL("DELETE FROM kv WHERE key = ?", arrayOf(key))
84
+ }
85
+ db.setTransactionSuccessful()
86
+ } finally {
87
+ db.endTransaction()
88
+ }
89
+ }
90
+
91
+ @Synchronized
92
+ fun getAllKeys(): Array<String> {
93
+ db.rawQuery("SELECT key FROM kv", null).use { cursor ->
94
+ val keys = ArrayList<String>(cursor.count)
95
+ while (cursor.moveToNext()) {
96
+ keys.add(cursor.getString(0))
97
+ }
98
+ return keys.toTypedArray()
99
+ }
100
+ }
101
+
102
+ @Synchronized
103
+ fun getKeysByPrefix(prefix: String): Array<String> {
104
+ val pattern = buildString {
105
+ for (character in prefix) {
106
+ // LIKE stops at NUL; startsWith below checks the complete prefix.
107
+ if (character == '\u0000') break
108
+ if (character == '%' || character == '_' || character == '\\') append('\\')
109
+ append(character)
110
+ }
111
+ append('%')
112
+ }
113
+ db.rawQuery(
114
+ "SELECT key FROM kv WHERE key LIKE ? ESCAPE '\\'",
115
+ arrayOf(pattern),
116
+ ).use { cursor ->
117
+ val keys = ArrayList<String>(cursor.count)
118
+ while (cursor.moveToNext()) {
119
+ val key = cursor.getString(0)
120
+ if (key.startsWith(prefix)) {
121
+ keys.add(key)
122
+ }
123
+ }
124
+ return keys.toTypedArray()
125
+ }
126
+ }
127
+
128
+ @Synchronized
129
+ fun size(): Int {
130
+ db.rawQuery("SELECT COUNT(*) FROM kv", null).use { cursor ->
131
+ return if (cursor.moveToFirst()) cursor.getInt(0) else 0
132
+ }
133
+ }
134
+
135
+ @Synchronized
136
+ fun clear() {
137
+ db.execSQL("DELETE FROM kv")
138
+ }
139
+
140
+ private fun migrateLegacyPreferences() {
141
+ db.rawQuery(
142
+ "SELECT v FROM meta WHERE k = ? LIMIT 1",
143
+ arrayOf(PREFS_MIGRATION_KEY),
144
+ ).use { cursor ->
145
+ if (cursor.moveToFirst() && cursor.getString(0) == "1") {
146
+ return
147
+ }
148
+ }
149
+
150
+ db.beginTransaction()
151
+ try {
152
+ for ((key, value) in legacyPreferences.all) {
153
+ if (value is String) {
154
+ db.execSQL(
155
+ "INSERT OR IGNORE INTO kv(key, value) VALUES(?, ?)",
156
+ arrayOf(key, value),
157
+ )
158
+ }
159
+ }
160
+ db.execSQL(
161
+ "INSERT OR REPLACE INTO meta(k, v) VALUES(?, ?)",
162
+ arrayOf(PREFS_MIGRATION_KEY, "1"),
163
+ )
164
+ db.setTransactionSuccessful()
165
+ } finally {
166
+ db.endTransaction()
167
+ }
168
+ }
169
+
170
+ private companion object {
171
+ const val DATABASE_NAME = "nitro-storage-disk.sqlite"
172
+ const val PREFS_MIGRATION_KEY = "prefs_v1"
173
+ }
174
+ }