strata-storage 2.6.0 → 2.7.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 (130) hide show
  1. package/AI-INTEGRATION-GUIDE.md +97 -10
  2. package/README.md +70 -70
  3. package/android/AGENTS.md +18 -3
  4. package/android/CLAUDE.md +36 -3
  5. package/android/build.gradle +5 -1
  6. package/android/src/main/java/com/strata/storage/EncryptedStorage.java +31 -18
  7. package/android/src/main/java/com/strata/storage/FilesystemStorage.java +62 -10
  8. package/android/src/main/java/com/strata/storage/SQLiteStorage.java +65 -13
  9. package/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +63 -51
  10. package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +80 -11
  11. package/dist/README.md +70 -70
  12. package/dist/adapters/capacitor/FilesystemAdapter.d.ts +2 -2
  13. package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
  14. package/dist/adapters/capacitor/FilesystemAdapter.js +6 -2
  15. package/dist/adapters/capacitor/PreferencesAdapter.d.ts +2 -2
  16. package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
  17. package/dist/adapters/capacitor/PreferencesAdapter.js +6 -2
  18. package/dist/adapters/capacitor/SecureAdapter.d.ts +3 -3
  19. package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
  20. package/dist/adapters/capacitor/SecureAdapter.js +6 -2
  21. package/dist/adapters/capacitor/SqliteAdapter.d.ts +24 -3
  22. package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
  23. package/dist/adapters/capacitor/SqliteAdapter.js +105 -23
  24. package/dist/adapters/capacitor/index.d.ts +4 -4
  25. package/dist/adapters/web/CacheAdapter.d.ts +2 -2
  26. package/dist/adapters/web/CookieAdapter.d.ts +2 -2
  27. package/dist/adapters/web/IndexedDBAdapter.d.ts +10 -3
  28. package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
  29. package/dist/adapters/web/IndexedDBAdapter.js +14 -3
  30. package/dist/adapters/web/LocalStorageAdapter.d.ts +2 -2
  31. package/dist/adapters/web/MemoryAdapter.d.ts +2 -2
  32. package/dist/adapters/web/SessionStorageAdapter.d.ts +10 -10
  33. package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
  34. package/dist/adapters/web/SessionStorageAdapter.js +3 -2
  35. package/dist/adapters/web/URLAdapter.d.ts +2 -2
  36. package/dist/adapters/web/index.d.ts +7 -7
  37. package/dist/capacitor.d.ts +6 -6
  38. package/dist/capacitor.d.ts.map +1 -1
  39. package/dist/capacitor.js +5 -4
  40. package/dist/core/AdapterRegistry.d.ts +1 -1
  41. package/dist/core/BaseAdapter.d.ts +21 -5
  42. package/dist/core/BaseAdapter.d.ts.map +1 -1
  43. package/dist/core/BaseAdapter.js +29 -1
  44. package/dist/core/Strata.d.ts +41 -7
  45. package/dist/core/Strata.d.ts.map +1 -1
  46. package/dist/core/Strata.js +476 -118
  47. package/dist/features/compression/index.d.ts +1 -1
  48. package/dist/features/compression.d.ts +5 -1
  49. package/dist/features/compression.d.ts.map +1 -1
  50. package/dist/features/encryption/index.d.ts +1 -1
  51. package/dist/features/encryption.d.ts +55 -2
  52. package/dist/features/encryption.d.ts.map +1 -1
  53. package/dist/features/encryption.js +139 -26
  54. package/dist/features/migration.d.ts +1 -1
  55. package/dist/features/observer/index.d.ts +1 -1
  56. package/dist/features/observer.d.ts +1 -1
  57. package/dist/features/query/index.d.ts +1 -1
  58. package/dist/features/query.d.ts +1 -1
  59. package/dist/features/query.d.ts.map +1 -1
  60. package/dist/features/query.js +3 -1
  61. package/dist/features/sync/index.d.ts +1 -1
  62. package/dist/features/sync.d.ts +19 -3
  63. package/dist/features/sync.d.ts.map +1 -1
  64. package/dist/features/sync.js +57 -9
  65. package/dist/features/ttl/index.d.ts +1 -1
  66. package/dist/features/ttl.d.ts +2 -2
  67. package/dist/firebase.d.ts +1 -1
  68. package/dist/firebase.d.ts.map +1 -1
  69. package/dist/firebase.js +34 -9
  70. package/dist/index.d.ts +24 -22
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +6 -1
  73. package/dist/integrations/angular/index.d.ts +2 -2
  74. package/dist/integrations/angular/index.d.ts.map +1 -1
  75. package/dist/integrations/angular/index.js +7 -8
  76. package/dist/integrations/react/index.d.ts +3 -3
  77. package/dist/integrations/react/index.d.ts.map +1 -1
  78. package/dist/integrations/react/index.js +16 -4
  79. package/dist/integrations/vue/index.d.ts +2 -2
  80. package/dist/integrations/vue/index.d.ts.map +1 -1
  81. package/dist/integrations/vue/index.js +5 -2
  82. package/dist/package.json +11 -2
  83. package/dist/plugin/definitions.d.ts +29 -3
  84. package/dist/plugin/definitions.d.ts.map +1 -1
  85. package/dist/plugin/index.d.ts +2 -2
  86. package/dist/plugin/index.d.ts.map +1 -1
  87. package/dist/plugin/index.js +3 -0
  88. package/dist/plugin/web.d.ts +11 -2
  89. package/dist/plugin/web.d.ts.map +1 -1
  90. package/dist/plugin/web.js +23 -4
  91. package/dist/types/index.d.ts +50 -20
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/dist/types/index.js +1 -10
  94. package/dist/utils/index.d.ts +5 -0
  95. package/dist/utils/index.d.ts.map +1 -1
  96. package/dist/utils/index.js +7 -0
  97. package/ios/AGENTS.md +14 -3
  98. package/ios/CLAUDE.md +25 -3
  99. package/ios/Plugin/FilesystemStorage.swift +19 -0
  100. package/ios/Plugin/SQLiteStorage.swift +59 -9
  101. package/ios/Plugin/StrataStoragePlugin.m +1 -0
  102. package/ios/Plugin/StrataStoragePlugin.swift +87 -34
  103. package/ios/Plugin/UserDefaultsStorage.swift +47 -46
  104. package/package.json +29 -26
  105. package/scripts/build.js +16 -14
  106. package/scripts/cli.js +1 -1
  107. package/scripts/configure.js +2 -2
  108. package/scripts/postinstall.js +3 -2
  109. package/dist/android/AGENTS.md +0 -51
  110. package/dist/android/CLAUDE.md +0 -89
  111. package/dist/android/build.gradle +0 -58
  112. package/dist/android/proguard-rules.pro +0 -11
  113. package/dist/android/settings.gradle +0 -2
  114. package/dist/android/src/main/AndroidManifest.xml +0 -2
  115. package/dist/android/src/main/java/com/strata/storage/EncryptedStorage.java +0 -218
  116. package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +0 -287
  117. package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +0 -371
  118. package/dist/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +0 -209
  119. package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +0 -616
  120. package/dist/core/StorageStrategy.d.ts +0 -55
  121. package/dist/core/StorageStrategy.d.ts.map +0 -1
  122. package/dist/core/StorageStrategy.js +0 -200
  123. package/dist/ios/AGENTS.md +0 -48
  124. package/dist/ios/CLAUDE.md +0 -84
  125. package/dist/ios/Plugin/FilesystemStorage.swift +0 -218
  126. package/dist/ios/Plugin/KeychainStorage.swift +0 -233
  127. package/dist/ios/Plugin/SQLiteStorage.swift +0 -426
  128. package/dist/ios/Plugin/StrataStoragePlugin.m +0 -23
  129. package/dist/ios/Plugin/StrataStoragePlugin.swift +0 -411
  130. package/dist/ios/Plugin/UserDefaultsStorage.swift +0 -87
@@ -1,6 +1,7 @@
1
1
  package com.strata.storage;
2
2
 
3
3
  import android.content.Context;
4
+ import android.os.Build;
4
5
  import android.util.Log;
5
6
  import com.getcapacitor.JSObject;
6
7
  import java.io.File;
@@ -124,16 +125,9 @@ public class FilesystemStorage {
124
125
  fos.close();
125
126
  fos = null;
126
127
 
127
- // Rename is atomic on the same filesystem. Remove a stale target
128
- // first since File.renameTo can fail when the destination exists.
129
- if (target.exists() && !target.delete()) {
130
- Log.w("StrataStorage", "Could not delete existing file before rename: " + key);
131
- }
132
- if (!tmp.renameTo(target)) {
133
- tmp.delete();
134
- return false;
135
- }
136
- return true;
128
+ // Commit the temp file over the target WITHOUT a delete-then-rename
129
+ // window (which could lose the existing value if the rename failed).
130
+ return commitAtomically(tmp, target, key);
137
131
  } catch (IOException e) {
138
132
  Log.e("StrataStorage", "Failed to set filesystem value for key " + key, e);
139
133
  if (tmp.exists()) {
@@ -151,6 +145,64 @@ public class FilesystemStorage {
151
145
  }
152
146
  }
153
147
 
148
+ /**
149
+ * Commit {@code tmp} over {@code target} without ever leaving the key with no
150
+ * value. The old delete-then-rename approach could lose the existing value
151
+ * if the rename failed after the delete succeeded.
152
+ *
153
+ * <p>Strategy: on API 26+ use an atomic NIO move (REPLACE_EXISTING +
154
+ * ATOMIC_MOVE). Otherwise try a direct {@code renameTo} (which atomically
155
+ * replaces on app-internal ext4/f2fs); only if that fails do we move the old
156
+ * value aside to a backup, swap in the new one, and restore the backup if the
157
+ * final rename fails — so the old value is never destroyed before the new one
158
+ * is committed.
159
+ */
160
+ private boolean commitAtomically(File tmp, File target, String key) {
161
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
162
+ try {
163
+ java.nio.file.Files.move(
164
+ tmp.toPath(),
165
+ target.toPath(),
166
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING,
167
+ java.nio.file.StandardCopyOption.ATOMIC_MOVE);
168
+ return true;
169
+ } catch (Exception nioError) {
170
+ Log.w("StrataStorage", "Atomic NIO move failed, falling back for key " + key, nioError);
171
+ // fall through to the legacy strategy
172
+ }
173
+ }
174
+
175
+ // Direct rename — atomic replace on POSIX/ext4 internal storage.
176
+ if (tmp.renameTo(target)) {
177
+ return true;
178
+ }
179
+
180
+ // Rename-over-existing failed: move the old value aside first.
181
+ File backup = null;
182
+ if (target.exists()) {
183
+ backup = new File(stagingDir, UUID.randomUUID().toString() + ".bak");
184
+ if (!target.renameTo(backup)) {
185
+ // Could not preserve the old value — abort without data loss.
186
+ tmp.delete();
187
+ return false;
188
+ }
189
+ }
190
+
191
+ if (tmp.renameTo(target)) {
192
+ if (backup != null) {
193
+ backup.delete();
194
+ }
195
+ return true;
196
+ }
197
+
198
+ // New value did not land — restore the old value from backup.
199
+ if (backup != null && backup.renameTo(target)) {
200
+ Log.w("StrataStorage", "Restored previous value after failed write for key " + key);
201
+ }
202
+ tmp.delete();
203
+ return false;
204
+ }
205
+
154
206
  public synchronized boolean remove(String key) {
155
207
  File file = fileForKey(key);
156
208
  if (!file.exists()) {
@@ -14,6 +14,7 @@ import java.util.HashSet;
14
14
  import java.util.List;
15
15
  import java.util.Map;
16
16
  import java.util.Set;
17
+ import java.util.regex.Pattern;
17
18
  import org.json.JSONException;
18
19
  import org.json.JSONObject;
19
20
 
@@ -47,6 +48,9 @@ public class SQLiteStorage extends SQLiteOpenHelper {
47
48
  private static final String KEY_TAGS = "tags";
48
49
  private static final String KEY_METADATA = "metadata";
49
50
 
51
+ /** Strict SQL-identifier allow-list for table names (cannot be bound). */
52
+ private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
53
+
50
54
  /** Tables already created in this DB during the process lifetime. */
51
55
  private final Set<String> ensuredTables = new HashSet<>();
52
56
 
@@ -60,16 +64,25 @@ public class SQLiteStorage extends SQLiteOpenHelper {
60
64
  }
61
65
 
62
66
  /**
63
- * Sanitize a table name to a safe SQL identifier ({@code ^[A-Za-z0-9_]+$}).
64
- * Table names cannot be passed as bound parameters in SQLite, so they MUST
65
- * be validated before being interpolated into DDL/DML.
67
+ * Validate a table name as a strict SQL identifier
68
+ * ({@code ^[A-Za-z_][A-Za-z0-9_]*$}). Table names cannot be passed as bound
69
+ * parameters in SQLite, so they MUST be validated before being interpolated
70
+ * into DDL/DML. A null/empty name resolves to the default table; any other
71
+ * non-conforming name is REJECTED ({@link IllegalArgumentException}) rather
72
+ * than silently stripped — the TS adapter applies the same allow-list and
73
+ * rejects before the call ever reaches here, so this is defense-in-depth
74
+ * against a direct-plugin caller.
66
75
  */
67
76
  public static String sanitizeTable(String table) {
68
77
  if (table == null || table.isEmpty()) {
69
78
  return DEFAULT_TABLE;
70
79
  }
71
- String cleaned = table.replaceAll("[^A-Za-z0-9_]", "");
72
- return cleaned.isEmpty() ? DEFAULT_TABLE : cleaned;
80
+ if (!IDENTIFIER.matcher(table).matches()) {
81
+ throw new IllegalArgumentException(
82
+ "Invalid SQLite table name: " + table
83
+ + " (must match ^[A-Za-z_][A-Za-z0-9_]*$)");
84
+ }
85
+ return table;
73
86
  }
74
87
 
75
88
  @Override
@@ -209,17 +222,23 @@ public class SQLiteStorage extends SQLiteOpenHelper {
209
222
 
210
223
  public synchronized List<String> keys(String table, String pattern) {
211
224
  String safeTable = sanitizeTable(table);
225
+ long now = System.currentTimeMillis();
212
226
  List<String> keys = new ArrayList<>();
213
227
  SQLiteDatabase db = getReadableDatabase();
214
228
  ensureTable(db, safeTable);
215
229
  Cursor cursor = null;
230
+ // Exclude expired rows in SQL so the TS adapter does not need a per-key
231
+ // get() to drop them (eliminates the keys() N+1).
232
+ String notExpired = "(" + KEY_EXPIRES + " IS NULL OR " + KEY_EXPIRES + " > ?)";
233
+ String nowArg = String.valueOf(now);
216
234
  try {
217
235
  if (pattern != null) {
218
- cursor = db.query(safeTable, new String[]{KEY_ID}, KEY_ID + " LIKE ?",
219
- new String[]{"%" + pattern + "%"}, null, null, null, null);
236
+ cursor = db.query(safeTable, new String[]{KEY_ID},
237
+ KEY_ID + " LIKE ? AND " + notExpired,
238
+ new String[]{"%" + pattern + "%", nowArg}, null, null, null, null);
220
239
  } else {
221
240
  cursor = db.query(safeTable, new String[]{KEY_ID},
222
- null, null, null, null, null);
241
+ notExpired, new String[]{nowArg}, null, null, null, null);
223
242
  }
224
243
  if (cursor != null && cursor.moveToFirst()) {
225
244
  do {
@@ -251,23 +270,39 @@ public class SQLiteStorage extends SQLiteOpenHelper {
251
270
  }
252
271
 
253
272
  /**
254
- * Enumerate every key in the table. The TS {@code SqliteAdapter} applies the
255
- * real query condition by re-fetching/filtering each key, so this only needs
256
- * to surface candidate keys.
273
+ * Enumerate non-expired rows as {@code { key, value }} where {@code value}
274
+ * is the full {@link StorageValue} wrapper (parsed from the stored JSON).
275
+ * Returning the wrapper here lets the TS {@code SqliteAdapter} run the real
276
+ * query condition in JS from a SINGLE round-trip instead of re-fetching
277
+ * each key (eliminates the query() N+1). Expired rows are filtered in SQL;
278
+ * an unparseable legacy payload surfaces as key-only (the TS side then
279
+ * falls back to {@code get()}, which treats it as a miss).
257
280
  */
258
281
  public synchronized List<Map<String, Object>> query(String table) {
259
282
  String safeTable = sanitizeTable(table);
283
+ long now = System.currentTimeMillis();
260
284
  List<Map<String, Object>> rows = new ArrayList<>();
261
285
  SQLiteDatabase db = getReadableDatabase();
262
286
  ensureTable(db, safeTable);
263
287
  Cursor cursor = null;
264
288
  try {
265
- cursor = db.query(safeTable, new String[]{KEY_ID},
266
- null, null, null, null, null);
289
+ cursor = db.query(safeTable, new String[]{KEY_ID, KEY_VALUE},
290
+ "(" + KEY_EXPIRES + " IS NULL OR " + KEY_EXPIRES + " > ?)",
291
+ new String[]{String.valueOf(now)}, null, null, null, null);
267
292
  if (cursor != null && cursor.moveToFirst()) {
268
293
  do {
269
294
  Map<String, Object> row = new HashMap<>();
270
295
  row.put("key", cursor.getString(0));
296
+ String stored = cursor.getString(1);
297
+ if (stored != null) {
298
+ try {
299
+ row.put("value", new JSObject(stored));
300
+ } catch (JSONException parseError) {
301
+ // Unparseable legacy payload: surface key only.
302
+ Log.w("StrataStorage", "Unparseable SQLite value in query for key "
303
+ + cursor.getString(0));
304
+ }
305
+ }
271
306
  rows.add(row);
272
307
  } while (cursor.moveToNext());
273
308
  }
@@ -279,6 +314,23 @@ public class SQLiteStorage extends SQLiteOpenHelper {
279
314
  }
280
315
  }
281
316
 
317
+ /**
318
+ * Delete every expired row ({@code expires <= now}) in one statement and
319
+ * return the number removed. Because {@link #keys} and {@link #query} now
320
+ * filter expired rows in SQL (no lazy per-key deletion on read), this is
321
+ * what reclaims their physical storage; the TS adapter calls it on its TTL
322
+ * cleanup tick.
323
+ */
324
+ public synchronized int cleanupExpired(String table) {
325
+ String safeTable = sanitizeTable(table);
326
+ long now = System.currentTimeMillis();
327
+ SQLiteDatabase db = getWritableDatabase();
328
+ ensureTable(db, safeTable);
329
+ return db.delete(safeTable,
330
+ KEY_EXPIRES + " IS NOT NULL AND " + KEY_EXPIRES + " <= ?",
331
+ new String[]{String.valueOf(now)});
332
+ }
333
+
282
334
  /**
283
335
  * Size information. {@code total} = Σ(length(key) + length(value)), matching
284
336
  * the web adapters' convention (key bytes are included). When {@code
@@ -3,6 +3,7 @@ package com.strata.storage;
3
3
  import android.content.Context;
4
4
  import android.content.SharedPreferences;
5
5
  import android.util.Log;
6
+ import java.lang.reflect.Array;
6
7
  import java.util.Map;
7
8
  import java.util.Set;
8
9
  import java.util.HashSet;
@@ -13,8 +14,8 @@ import org.json.JSONArray;
13
14
 
14
15
  public class SharedPreferencesStorage {
15
16
  private final SharedPreferences prefs;
16
- private final SharedPreferences.Editor editor;
17
17
  private static final String DEFAULT_NAME = "StrataStorage";
18
+ private static final String KEY_PREFIX = "strata_storage::";
18
19
 
19
20
  public SharedPreferencesStorage(Context context) {
20
21
  this(context, DEFAULT_NAME);
@@ -22,21 +23,30 @@ public class SharedPreferencesStorage {
22
23
 
23
24
  public SharedPreferencesStorage(Context context, String name) {
24
25
  this.prefs = context.getSharedPreferences(name != null ? name : DEFAULT_NAME, Context.MODE_PRIVATE);
25
- this.editor = prefs.edit();
26
26
  }
27
-
28
- public boolean set(String key, Object value) {
27
+
28
+ private String physicalKey(String key) {
29
+ return KEY_PREFIX + key;
30
+ }
31
+
32
+ private String logicalKey(String key) {
33
+ return key.startsWith(KEY_PREFIX) ? key.substring(KEY_PREFIX.length()) : key;
34
+ }
35
+
36
+ public synchronized boolean set(String key, Object value) {
29
37
  try {
38
+ SharedPreferences.Editor editor = prefs.edit();
39
+ String targetKey = physicalKey(key);
30
40
  if (value instanceof String) {
31
- editor.putString(key, (String) value);
41
+ editor.putString(targetKey, (String) value);
32
42
  } else if (value instanceof Integer) {
33
- editor.putInt(key, (Integer) value);
43
+ editor.putInt(targetKey, (Integer) value);
34
44
  } else if (value instanceof Long) {
35
- editor.putLong(key, (Long) value);
45
+ editor.putLong(targetKey, (Long) value);
36
46
  } else if (value instanceof Float) {
37
- editor.putFloat(key, (Float) value);
47
+ editor.putFloat(targetKey, (Float) value);
38
48
  } else if (value instanceof Boolean) {
39
- editor.putBoolean(key, (Boolean) value);
49
+ editor.putBoolean(targetKey, (Boolean) value);
40
50
  } else if (value instanceof Set) {
41
51
  // Safely convert to Set<String>
42
52
  Set<?> rawSet = (Set<?>) value;
@@ -44,7 +54,7 @@ public class SharedPreferencesStorage {
44
54
  for (Object item : rawSet) {
45
55
  stringSet.add(item != null ? item.toString() : "null");
46
56
  }
47
- editor.putStringSet(key, stringSet);
57
+ editor.putStringSet(targetKey, stringSet);
48
58
  } else {
49
59
  // Convert complex objects to JSON
50
60
  String json;
@@ -59,7 +69,7 @@ public class SharedPreferencesStorage {
59
69
  json = value.toString();
60
70
  }
61
71
  }
62
- editor.putString(key, json);
72
+ editor.putString(targetKey, json);
63
73
  }
64
74
  return editor.commit();
65
75
  } catch (Exception e) {
@@ -68,13 +78,14 @@ public class SharedPreferencesStorage {
68
78
  }
69
79
  }
70
80
 
71
- public Object get(String key) {
81
+ public synchronized Object get(String key) {
72
82
  Map<String, ?> all = prefs.getAll();
73
- return all.get(key);
83
+ return all.get(physicalKey(key));
74
84
  }
75
-
76
- public boolean remove(String key) {
77
- editor.remove(key);
85
+
86
+ public synchronized boolean remove(String key) {
87
+ SharedPreferences.Editor editor = prefs.edit();
88
+ editor.remove(physicalKey(key));
78
89
  return editor.commit();
79
90
  }
80
91
 
@@ -82,21 +93,20 @@ public class SharedPreferencesStorage {
82
93
  return clear(null);
83
94
  }
84
95
 
85
- public boolean clear(String prefix) {
86
- if (prefix != null) {
87
- // Clear only keys with the given prefix
88
- Set<String> keysToRemove = new HashSet<>();
89
- for (String key : prefs.getAll().keySet()) {
90
- if (key.startsWith(prefix) || key.contains(prefix)) {
91
- keysToRemove.add(key);
92
- }
96
+ public synchronized boolean clear(String prefix) {
97
+ Set<String> keysToRemove = new HashSet<>();
98
+ for (String key : prefs.getAll().keySet()) {
99
+ if (!key.startsWith(KEY_PREFIX)) {
100
+ continue;
93
101
  }
94
- for (String key : keysToRemove) {
95
- editor.remove(key);
102
+ String logical = logicalKey(key);
103
+ if (prefix == null || logical.startsWith(prefix) || logical.contains(prefix)) {
104
+ keysToRemove.add(key);
96
105
  }
97
- } else {
98
- // Clear all keys
99
- editor.clear();
106
+ }
107
+ SharedPreferences.Editor editor = prefs.edit();
108
+ for (String key : keysToRemove) {
109
+ editor.remove(key);
100
110
  }
101
111
  return editor.commit();
102
112
  }
@@ -106,37 +116,38 @@ public class SharedPreferencesStorage {
106
116
  }
107
117
 
108
118
  public List<String> keys(String pattern) {
109
- Set<String> allKeys = prefs.getAll().keySet();
110
-
111
- if (pattern == null) {
112
- return new ArrayList<>(allKeys);
113
- }
114
-
115
- // Filter keys by pattern
116
- List<String> filteredKeys = new ArrayList<>();
117
- for (String key : allKeys) {
118
- if (key.startsWith(pattern) || key.contains(pattern)) {
119
- filteredKeys.add(key);
119
+ List<String> keys = new ArrayList<>();
120
+ for (String physical : prefs.getAll().keySet()) {
121
+ if (!physical.startsWith(KEY_PREFIX)) {
122
+ continue;
123
+ }
124
+ String key = logicalKey(physical);
125
+ if (pattern == null || key.startsWith(pattern) || key.contains(pattern)) {
126
+ keys.add(key);
120
127
  }
121
128
  }
122
- return filteredKeys;
129
+ return keys;
123
130
  }
124
-
125
- public boolean has(String key) {
126
- return prefs.contains(key);
131
+
132
+ public synchronized boolean has(String key) {
133
+ return prefs.contains(physicalKey(key));
127
134
  }
128
135
 
129
136
  public Map<String, ?> getAll() {
130
137
  return prefs.getAll();
131
138
  }
132
139
 
133
- public SizeInfo size() {
140
+ public synchronized SizeInfo size() {
134
141
  Map<String, ?> all = prefs.getAll();
135
142
  long totalSize = 0;
136
- int count = all.size();
137
-
143
+ int count = 0;
144
+
138
145
  for (Map.Entry<String, ?> entry : all.entrySet()) {
139
- String key = entry.getKey();
146
+ String physical = entry.getKey();
147
+ if (!physical.startsWith(KEY_PREFIX)) {
148
+ continue;
149
+ }
150
+ String key = logicalKey(physical);
140
151
  Object value = entry.getValue();
141
152
 
142
153
  // Estimate size (key + value in bytes)
@@ -144,6 +155,7 @@ public class SharedPreferencesStorage {
144
155
  if (value != null) {
145
156
  totalSize += value.toString().getBytes().length;
146
157
  }
158
+ count++;
147
159
  }
148
160
 
149
161
  return new SizeInfo(totalSize, count);
@@ -179,9 +191,9 @@ public class SharedPreferencesStorage {
179
191
  jsonArray.put(item);
180
192
  }
181
193
  } else {
182
- Object[] array = (Object[]) obj;
183
- for (Object item : array) {
184
- jsonArray.put(item);
194
+ int len = Array.getLength(obj);
195
+ for (int i = 0; i < len; i++) {
196
+ jsonArray.put(Array.get(obj, i));
185
197
  }
186
198
  }
187
199
  return jsonArray.toString();
@@ -12,9 +12,11 @@ import com.strata.storage.FilesystemStorage;
12
12
  import com.strata.storage.SQLiteStorage;
13
13
  import android.util.Log;
14
14
  import org.json.JSONArray;
15
+ import org.json.JSONException;
15
16
  import java.util.HashMap;
16
17
  import java.util.List;
17
18
  import java.util.Map;
19
+ import java.util.regex.Pattern;
18
20
 
19
21
  /**
20
22
  * Main Capacitor plugin for Strata Storage
@@ -32,6 +34,9 @@ public class StrataStoragePlugin extends Plugin {
32
34
  private static final String DEFAULT_DATABASE = "strata_storage";
33
35
  private static final String DEFAULT_TABLE = "storage";
34
36
 
37
+ /** Strict SQL-identifier allow-list for the database file stem. */
38
+ private static final Pattern SQLITE_IDENTIFIER = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
39
+
35
40
  private SharedPreferencesStorage sharedPrefsStorage;
36
41
  private EncryptedStorage encryptedStorage;
37
42
  private FilesystemStorage filesystemStorage;
@@ -61,25 +66,37 @@ public class StrataStoragePlugin extends Plugin {
61
66
  }
62
67
 
63
68
  /**
64
- * Sanitize a database name to a safe file stem ({@code ^[A-Za-z0-9_]+$})
65
- * and return the resolved stem. The ".db" suffix is appended when opening.
69
+ * Validate a database name as a strict SQL identifier
70
+ * ({@code ^[A-Za-z_][A-Za-z0-9_]*$}) and return it as the file stem. The
71
+ * ".db" suffix is appended when opening. A null/empty name resolves to the
72
+ * default; any other non-conforming name is REJECTED rather than silently
73
+ * stripped (the TS adapter applies the same allow-list and rejects first —
74
+ * this is defense-in-depth, and also blocks path separators / traversal).
66
75
  */
67
76
  private static String sanitizeDatabase(String database) {
68
77
  if (database == null || database.isEmpty()) {
69
78
  return DEFAULT_DATABASE;
70
79
  }
71
- String cleaned = database.replaceAll("[^A-Za-z0-9_]", "");
72
- return cleaned.isEmpty() ? DEFAULT_DATABASE : cleaned;
80
+ if (!SQLITE_IDENTIFIER.matcher(database).matches()) {
81
+ throw new IllegalArgumentException("Invalid SQLite database name: " + database);
82
+ }
83
+ return database;
73
84
  }
74
85
 
75
86
  /**
76
87
  * Return (creating + caching on first use) the SQLite helper for the given
77
- * database name. The cache is keyed by the sanitized DB stem so the
78
- * connection is reused across bridge calls. May return {@code null} if the
79
- * helper cannot be constructed.
88
+ * database name. The cache is keyed by the validated DB stem so the
89
+ * connection is reused across bridge calls. Returns {@code null} if the name
90
+ * is invalid or the helper cannot be constructed — callers reject the call.
80
91
  */
81
92
  private synchronized SQLiteStorage getSqliteStore(String database) {
82
- String dbStem = sanitizeDatabase(database);
93
+ final String dbStem;
94
+ try {
95
+ dbStem = sanitizeDatabase(database);
96
+ } catch (IllegalArgumentException e) {
97
+ Log.e("StrataStorage", "Invalid SQLite database name", e);
98
+ return null;
99
+ }
83
100
  SQLiteStorage store = sqliteStores.get(dbStem);
84
101
  if (store == null) {
85
102
  try {
@@ -189,12 +206,32 @@ public class StrataStoragePlugin extends Plugin {
189
206
  JSObject result = new JSObject();
190
207
  if (value == null) {
191
208
  result.put("value", JSObject.NULL);
209
+ } else if (value instanceof String) {
210
+ // preferences/secure persist the full StorageValue wrapper as a JSON
211
+ // string; parse it back to an object so TTL/tags/metadata round-trip
212
+ // (matching the sqlite/filesystem contract). Legacy/plain strings are
213
+ // returned unchanged.
214
+ result.put("value", parseWrapperOrRaw((String) value));
192
215
  } else {
193
216
  result.put("value", value);
194
217
  }
195
218
  call.resolve(result);
196
219
  }
197
220
 
221
+ /** Parse a stored JSON-object wrapper string back into a {@link JSObject};
222
+ * return the raw string for non-JSON-object payloads. */
223
+ private Object parseWrapperOrRaw(String raw) {
224
+ String trimmed = raw.trim();
225
+ if (trimmed.startsWith("{")) {
226
+ try {
227
+ return new JSObject(raw);
228
+ } catch (JSONException ignored) {
229
+ // Not a JSON object — fall through and return the raw string.
230
+ }
231
+ }
232
+ return raw;
233
+ }
234
+
198
235
  /**
199
236
  * Set value in storage.
200
237
  *
@@ -513,9 +550,10 @@ public class StrataStoragePlugin extends Plugin {
513
550
 
514
551
  /**
515
552
  * Query SQLite-backed storage.
516
- * Matches the optional `query` method in the JS contract:
517
- * resolves { results: [{ key }] }. The JS SqliteAdapter applies the real
518
- * condition by re-fetching/filtering, so this enumerates candidate keys.
553
+ * Matches the optional `query` method in the JS contract: resolves
554
+ * { results: [{ key, value }] } where `value` is the full StorageValue
555
+ * wrapper for each non-expired row, so the JS SqliteAdapter can apply the
556
+ * condition in one round-trip without re-fetching each key.
519
557
  */
520
558
  @PluginMethod
521
559
  public void query(PluginCall call) {
@@ -536,6 +574,10 @@ public class StrataStoragePlugin extends Plugin {
536
574
  for (Map<String, Object> row : rows) {
537
575
  JSObject obj = new JSObject();
538
576
  obj.put("key", row.get("key"));
577
+ Object value = row.get("value");
578
+ if (value != null) {
579
+ obj.put("value", value);
580
+ }
539
581
  results.put(obj);
540
582
  }
541
583
  JSObject result = new JSObject();
@@ -546,6 +588,33 @@ public class StrataStoragePlugin extends Plugin {
546
588
  }
547
589
  }
548
590
 
591
+ /**
592
+ * Delete every expired row for a SQLite store in one statement and resolve
593
+ * { removed: <count> }. The JS adapter calls this on its TTL cleanup tick,
594
+ * since keys()/query() now exclude expired rows in SQL (no per-key delete).
595
+ */
596
+ @PluginMethod
597
+ public void cleanupExpired(PluginCall call) {
598
+ String storage = call.getString("storage", "sqlite");
599
+ if (!"sqlite".equals(storage)) {
600
+ call.reject("cleanupExpired is only supported for the 'sqlite' storage type");
601
+ return;
602
+ }
603
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
604
+ if (store == null) {
605
+ call.reject("SQLite storage not available");
606
+ return;
607
+ }
608
+ try {
609
+ int removed = store.cleanupExpired(call.getString("table", DEFAULT_TABLE));
610
+ JSObject result = new JSObject();
611
+ result.put("removed", removed);
612
+ call.resolve(result);
613
+ } catch (Exception e) {
614
+ call.reject("Failed to clean up expired SQLite rows", e);
615
+ }
616
+ }
617
+
549
618
  /**
550
619
  * Android-specific: read an encrypted preference.
551
620
  * Resolves { value: unknown }. Honors an optional `fileName`.