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,89 +0,0 @@
1
- # CLAUDE.md — android/
2
-
3
- Last Updated: 2026-05-27
4
-
5
- ## Android Native Plugin
6
-
7
- Java implementation of native storage backends for Capacitor.
8
-
9
- ### Files
10
-
11
- | File | Path | Purpose |
12
- |------|------|---------|
13
- | `StrataStoragePlugin.java` | `src/main/java/com/stratastorage/` | Main Capacitor plugin entry |
14
- | `SharedPreferencesStorage.java` | `src/main/java/com/strata/storage/` | SharedPreferences general storage |
15
- | `EncryptedStorage.java` | `src/main/java/com/strata/storage/` | EncryptedSharedPreferences secure storage (`androidx.security 1.1.0`) |
16
- | `SQLiteStorage.java` | `src/main/java/com/strata/storage/` | SQLite database storage — multi-store (v2.6.0) |
17
- | `FilesystemStorage.java` | `src/main/java/com/strata/storage/` | File-per-key storage under `Context.getFilesDir()/strata_storage/` (new in v2.6.0) |
18
-
19
- ### Configuration
20
-
21
- - Build: `build.gradle`
22
- - Proguard: `proguard-rules.pro`
23
- - Manifest: `src/main/AndroidManifest.xml`
24
-
25
- **Note**: There are two Java package paths — `com.stratastorage` (plugin) and `com.strata.storage` (implementations).
26
-
27
- ## v2.6.0 Changes
28
-
29
- ### `androidx.security:security-crypto` 1.1.0 (stable)
30
- `EncryptedStorage.java` depends on `androidx.security:security-crypto`. The
31
- dependency was upgraded from `1.1.0-alpha06` to `1.1.0` (stable) in v2.6.0.
32
- The `EncryptedSharedPreferences` / `MasterKey` API is unchanged. The stable
33
- version requires `compileSdkVersion 34` — if Gradle sync fails after this
34
- change, bump `compileSdkVersion` and `targetSdkVersion` in
35
- `android/app/build.gradle` to 34.
36
-
37
- ### SQLite multi-store
38
- `SQLiteStorage.java` now accepts `database` and `table` parameters from
39
- `call.getString("database")` / `call.getString("table")`. Each unique pair
40
- opens a distinct `.db` file in the app's internal storage. Table identifiers
41
- are sanitised to `[A-Za-z0-9_]`. The full `StorageValue` wrapper is serialised
42
- to JSON and stored in a single `value` column; native `get` returns the full
43
- wrapper so TTL, tags, and metadata survive the round-trip. `size(detailed)`
44
- returns per-column byte breakdown.
45
-
46
- ### FilesystemStorage.java (new)
47
- Stores each key as `getFilesDir()/strata_storage/<sanitised-key>.json`. Writes
48
- are atomic: value is written to `strata_storage/.staging/<key>.tmp`, then
49
- renamed into place using `renameTo()`. Staging artifacts are excluded from
50
- `keys()`. No external filesystem permissions are needed (`getFilesDir()` is
51
- app-private). `isAvailable()` returns `true`. `size(detailed)` supported.
52
-
53
- > **Pending on-device verification** — native code is complete and reviewed; see
54
- > `docs/guides/platforms/device-verification.md` for the test matrix.
55
-
56
- ## Rules
57
-
58
- ### Security (IRON-SOLID)
59
- - Sensitive data MUST use `EncryptedStorage` (EncryptedSharedPreferences)
60
- - NEVER store credentials, tokens, or secrets in `SharedPreferencesStorage`
61
- - Follow Android Keystore best practices
62
- - `androidx.security:security-crypto 1.1.0` (stable) is the required version
63
-
64
- ### SQL Safety (IRON-SOLID)
65
- - ALWAYS use parameterized queries in `SQLiteStorage`
66
- - NEVER concatenate user input into SQL strings
67
- - Use `SQLiteDatabase.query()` or prepared statements
68
-
69
- ### Filesystem Safety
70
- - NEVER read from `strata_storage/.staging/` — staging files are transient
71
- - Key sanitisation must be consistent between `set`, `get`, `remove`, and `keys`
72
- - `getFilesDir()` is app-private; no external storage permissions required
73
-
74
- ### Plugin Architecture
75
- - All native methods exposed through `StrataStoragePlugin.java`
76
- - Plugin bridges Capacitor JS calls to Java implementations
77
- - Each storage backend is a separate Java class
78
- - Two package paths: `com.stratastorage` (plugin) and `com.strata.storage` (impl)
79
-
80
- ### Build
81
- - Gradle-based build system
82
- - `compileSdkVersion 34` required for `androidx.security 1.1.0`
83
- - Proguard rules configured for release builds
84
- - Test on Android emulator after changes; follow device-verification guide
85
-
86
- ### Before Modifying
87
- - Understand the Capacitor plugin protocol for Android
88
- - Test on Android emulator after changes
89
- - Verify Gradle build succeeds
@@ -1,58 +0,0 @@
1
- ext {
2
- junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
- androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
4
- androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
5
- androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
6
- }
7
-
8
- buildscript {
9
- repositories {
10
- google()
11
- mavenCentral()
12
- }
13
- dependencies {
14
- classpath 'com.android.tools.build:gradle:8.2.1'
15
- }
16
- }
17
-
18
- apply plugin: 'com.android.library'
19
-
20
- android {
21
- namespace "com.stratastorage"
22
- compileSdkVersion project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
23
- defaultConfig {
24
- minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
25
- targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
26
- versionCode 1
27
- versionName "1.0"
28
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
29
- }
30
- buildTypes {
31
- release {
32
- minifyEnabled false
33
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
34
- }
35
- }
36
- lintOptions {
37
- abortOnError false
38
- }
39
- compileOptions {
40
- sourceCompatibility JavaVersion.VERSION_17
41
- targetCompatibility JavaVersion.VERSION_17
42
- }
43
- }
44
-
45
- repositories {
46
- google()
47
- mavenCentral()
48
- }
49
-
50
- dependencies {
51
- implementation fileTree(dir: 'libs', include: ['*.jar'])
52
- implementation project(':capacitor-android')
53
- implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
54
- implementation 'androidx.security:security-crypto:1.1.0'
55
- testImplementation "junit:junit:$junitVersion"
56
- androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
57
- androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
58
- }
@@ -1,11 +0,0 @@
1
- # Add project specific ProGuard rules here.
2
-
3
- # Keep StrataStorage plugin classes
4
- -keep class com.stratastorage.** { *; }
5
- -keep class com.strata.storage.** { *; }
6
-
7
- # Keep Capacitor classes
8
- -keep class com.getcapacitor.** { *; }
9
-
10
- # Keep security crypto classes for encrypted storage
11
- -keep class androidx.security.crypto.** { *; }
@@ -1,2 +0,0 @@
1
- include ':strata-storage'
2
- project(':strata-storage').projectDir = new File(rootProject.projectDir, '.')
@@ -1,2 +0,0 @@
1
- <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
- </manifest>
@@ -1,218 +0,0 @@
1
- package com.strata.storage;
2
-
3
- import android.content.Context;
4
- import android.content.SharedPreferences;
5
- import android.os.Build;
6
- import android.util.Log;
7
- import androidx.security.crypto.EncryptedSharedPreferences;
8
- import androidx.security.crypto.MasterKey;
9
- import java.security.GeneralSecurityException;
10
- import java.io.IOException;
11
- import java.util.Set;
12
- import java.util.HashSet;
13
- import java.util.Map;
14
- import java.util.List;
15
- import java.util.ArrayList;
16
- import org.json.JSONObject;
17
- import org.json.JSONArray;
18
-
19
- public class EncryptedStorage {
20
- private SharedPreferences encryptedPrefs;
21
- private SharedPreferences.Editor editor;
22
- private static final String DEFAULT_NAME = "StrataSecureStorage";
23
-
24
- public EncryptedStorage(Context context) throws GeneralSecurityException, IOException {
25
- this(context, DEFAULT_NAME);
26
- }
27
-
28
- public EncryptedStorage(Context context, String name) throws GeneralSecurityException, IOException {
29
- String fileName = name != null ? name : DEFAULT_NAME;
30
-
31
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
32
- try {
33
- MasterKey masterKey = new MasterKey.Builder(context)
34
- .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
35
- .build();
36
-
37
- encryptedPrefs = EncryptedSharedPreferences.create(
38
- context,
39
- fileName,
40
- masterKey,
41
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
42
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
43
- );
44
-
45
- editor = encryptedPrefs.edit();
46
- } catch (GeneralSecurityException | IOException e) {
47
- Log.e("StrataStorage", "Failed to initialize encrypted storage", e);
48
- throw e;
49
- }
50
- } else {
51
- Log.w("StrataStorage", "API < 23, using unencrypted SharedPreferences");
52
- // Fallback to regular SharedPreferences for older devices
53
- encryptedPrefs = context.getSharedPreferences(fileName, Context.MODE_PRIVATE);
54
- editor = encryptedPrefs.edit();
55
- }
56
- }
57
-
58
- public boolean set(String key, Object value) {
59
- try {
60
- String stringValue;
61
- if (value instanceof String) {
62
- stringValue = (String) value;
63
- } else {
64
- // Convert complex objects to JSON
65
- if (value instanceof JSONObject) {
66
- stringValue = ((JSONObject) value).toString();
67
- } else {
68
- // Use helper method to convert object to JSON string
69
- try {
70
- stringValue = objectToJsonString(value);
71
- } catch (Exception e) {
72
- // Fallback to string representation
73
- stringValue = value.toString();
74
- }
75
- }
76
- }
77
- editor.putString(key, stringValue);
78
- return editor.commit();
79
- } catch (Exception e) {
80
- Log.e("StrataStorage", "Failed to set value in encrypted storage", e);
81
- return false;
82
- }
83
- }
84
-
85
- public String get(String key) {
86
- return encryptedPrefs.getString(key, null);
87
- }
88
-
89
- public boolean remove(String key) {
90
- editor.remove(key);
91
- return editor.commit();
92
- }
93
-
94
- public boolean clear() {
95
- return clear(null);
96
- }
97
-
98
- public boolean clear(String prefix) {
99
- if (prefix != null) {
100
- // Clear only keys with the given prefix
101
- Set<String> keysToRemove = new HashSet<>();
102
- for (String key : encryptedPrefs.getAll().keySet()) {
103
- if (key.startsWith(prefix) || key.contains(prefix)) {
104
- keysToRemove.add(key);
105
- }
106
- }
107
- for (String key : keysToRemove) {
108
- editor.remove(key);
109
- }
110
- } else {
111
- // Clear all keys
112
- editor.clear();
113
- }
114
- return editor.commit();
115
- }
116
-
117
- public List<String> keys() {
118
- return keys(null);
119
- }
120
-
121
- public List<String> keys(String pattern) {
122
- Set<String> allKeys = encryptedPrefs.getAll().keySet();
123
-
124
- if (pattern == null) {
125
- return new ArrayList<>(allKeys);
126
- }
127
-
128
- // Filter keys by pattern
129
- List<String> filteredKeys = new ArrayList<>();
130
- for (String key : allKeys) {
131
- if (key.startsWith(pattern) || key.contains(pattern)) {
132
- filteredKeys.add(key);
133
- }
134
- }
135
- return filteredKeys;
136
- }
137
-
138
- public boolean has(String key) {
139
- return encryptedPrefs.contains(key);
140
- }
141
-
142
- public SizeInfo size() {
143
- Map<String, ?> all = encryptedPrefs.getAll();
144
- long totalSize = 0;
145
- int count = all.size();
146
-
147
- for (Map.Entry<String, ?> entry : all.entrySet()) {
148
- String key = entry.getKey();
149
- Object value = entry.getValue();
150
-
151
- // Estimate size (key + value in bytes)
152
- totalSize += key.getBytes().length;
153
- if (value != null) {
154
- totalSize += value.toString().getBytes().length;
155
- }
156
- }
157
-
158
- return new SizeInfo(totalSize, count);
159
- }
160
-
161
- /**
162
- * Convert an object to JSON string using reflection
163
- */
164
- private String objectToJsonString(Object obj) throws Exception {
165
- if (obj == null) {
166
- return "null";
167
- }
168
-
169
- if (obj instanceof String || obj instanceof Number || obj instanceof Boolean) {
170
- return obj.toString();
171
- }
172
-
173
- if (obj instanceof Map) {
174
- JSONObject jsonObj = new JSONObject();
175
- Map<?, ?> map = (Map<?, ?>) obj;
176
- for (Map.Entry<?, ?> entry : map.entrySet()) {
177
- String key = entry.getKey().toString();
178
- jsonObj.put(key, entry.getValue());
179
- }
180
- return jsonObj.toString();
181
- }
182
-
183
- if (obj instanceof List || obj.getClass().isArray()) {
184
- JSONArray jsonArray = new JSONArray();
185
- if (obj instanceof List) {
186
- List<?> list = (List<?>) obj;
187
- for (Object item : list) {
188
- jsonArray.put(item);
189
- }
190
- } else {
191
- Object[] array = (Object[]) obj;
192
- for (Object item : array) {
193
- jsonArray.put(item);
194
- }
195
- }
196
- return jsonArray.toString();
197
- }
198
-
199
- // For other objects, create a simple JSON object with their string representation
200
- JSONObject jsonObj = new JSONObject();
201
- jsonObj.put("value", obj.toString());
202
- jsonObj.put("type", obj.getClass().getSimpleName());
203
- return jsonObj.toString();
204
- }
205
-
206
- /**
207
- * Size information class
208
- */
209
- public static class SizeInfo {
210
- public final long total;
211
- public final int count;
212
-
213
- public SizeInfo(long total, int count) {
214
- this.total = total;
215
- this.count = count;
216
- }
217
- }
218
- }
@@ -1,287 +0,0 @@
1
- package com.strata.storage;
2
-
3
- import android.content.Context;
4
- import android.util.Log;
5
- import com.getcapacitor.JSObject;
6
- import java.io.File;
7
- import java.io.FileOutputStream;
8
- import java.io.IOException;
9
- import java.nio.charset.StandardCharsets;
10
- import java.net.URLDecoder;
11
- import java.net.URLEncoder;
12
- import java.util.ArrayList;
13
- import java.util.List;
14
- import java.util.UUID;
15
- import org.json.JSONException;
16
-
17
- /**
18
- * Filesystem-backed storage. One file per key lives under
19
- * {@code <filesDir>/strata_storage}. The file name is a reversible
20
- * URL-encoding of the key (no path separators survive encoding) and the file
21
- * contents are the JSON-serialized FULL wrapper object ({@code value, created,
22
- * updated, expires?, tags?, metadata?}), mirroring the SQLite value-shape
23
- * contract.
24
- *
25
- * <p>Writes are atomic: a temp file is written then renamed over the target.
26
- * All access is synchronized on the instance so overlapping bridge calls are
27
- * safe.
28
- */
29
- public class FilesystemStorage {
30
- private static final String DIR_NAME = "strata_storage";
31
- private static final String ENCODING = "UTF-8";
32
- /**
33
- * Reserved subdirectory (inside the storage dir) that holds in-flight temp
34
- * files for atomic writes. Because it is a directory, the file-only
35
- * enumeration in keys()/size()/clear() skips it automatically, and temp
36
- * names therefore can never collide with an encoded key file in the storage
37
- * dir (e.g. a real key {@code "backup.tmp"}).
38
- */
39
- private static final String STAGING_DIR_NAME = ".strata-staging";
40
-
41
- private final File baseDir;
42
- private final File stagingDir;
43
-
44
- public FilesystemStorage(Context context) {
45
- this.baseDir = new File(context.getFilesDir(), DIR_NAME);
46
- this.stagingDir = new File(baseDir, STAGING_DIR_NAME);
47
- ensureDir();
48
- }
49
-
50
- private synchronized void ensureDir() {
51
- if (!baseDir.exists()) {
52
- // mkdirs() returns false if it already exists due to a race; the
53
- // subsequent exists() check below is the real guard.
54
- baseDir.mkdirs();
55
- }
56
- if (!stagingDir.exists()) {
57
- stagingDir.mkdirs();
58
- }
59
- }
60
-
61
- /** Reversibly encode a key into a path-separator-free file name. */
62
- private static String encodeKey(String key) {
63
- try {
64
- return URLEncoder.encode(key, ENCODING);
65
- } catch (Exception e) {
66
- // UTF-8 is always supported; fall back defensively.
67
- return key.replaceAll("[^A-Za-z0-9_-]", "_");
68
- }
69
- }
70
-
71
- /** Inverse of {@link #encodeKey(String)}. */
72
- private static String decodeKey(String fileName) {
73
- try {
74
- return URLDecoder.decode(fileName, ENCODING);
75
- } catch (Exception e) {
76
- return fileName;
77
- }
78
- }
79
-
80
- private File fileForKey(String key) {
81
- return new File(baseDir, encodeKey(key));
82
- }
83
-
84
- /**
85
- * Read + parse the full wrapper for {@code key}. Missing file → {@code null}.
86
- * Unparseable contents → {@code null} (treated as a miss; never throws).
87
- */
88
- public synchronized JSObject get(String key) {
89
- File file = fileForKey(key);
90
- if (!file.exists() || !file.isFile()) {
91
- return null;
92
- }
93
- try {
94
- String json = readFile(file);
95
- return new JSObject(json);
96
- } catch (JSONException parseError) {
97
- Log.w("StrataStorage", "Unparseable filesystem value for key " + key);
98
- return null;
99
- } catch (IOException ioError) {
100
- Log.e("StrataStorage", "Failed to read filesystem value for key " + key, ioError);
101
- return null;
102
- }
103
- }
104
-
105
- /**
106
- * Atomically persist the full wrapper for {@code key}: write a temp file
107
- * then rename it over the destination.
108
- */
109
- public synchronized boolean set(String key, JSObject wrapper) {
110
- if (wrapper == null) {
111
- return false;
112
- }
113
- ensureDir();
114
- File target = fileForKey(key);
115
- // Temp file lives in the staging subdir (same filesystem → atomic
116
- // rename) with a UUID name, so it can never collide with a key file.
117
- File tmp = new File(stagingDir, UUID.randomUUID().toString() + ".tmp");
118
- FileOutputStream fos = null;
119
- try {
120
- fos = new FileOutputStream(tmp);
121
- fos.write(wrapper.toString().getBytes(StandardCharsets.UTF_8));
122
- fos.flush();
123
- fos.getFD().sync();
124
- fos.close();
125
- fos = null;
126
-
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;
137
- } catch (IOException e) {
138
- Log.e("StrataStorage", "Failed to set filesystem value for key " + key, e);
139
- if (tmp.exists()) {
140
- tmp.delete();
141
- }
142
- return false;
143
- } finally {
144
- if (fos != null) {
145
- try {
146
- fos.close();
147
- } catch (IOException ignored) {
148
- // best-effort close
149
- }
150
- }
151
- }
152
- }
153
-
154
- public synchronized boolean remove(String key) {
155
- File file = fileForKey(key);
156
- if (!file.exists()) {
157
- return false;
158
- }
159
- return file.delete();
160
- }
161
-
162
- /**
163
- * Delete all stored entries. {@code prefix}, when non-null, restricts the
164
- * delete to keys whose (decoded) name starts with the prefix.
165
- */
166
- public synchronized boolean clear(String prefix) {
167
- File[] files = baseDir.listFiles();
168
- if (files == null) {
169
- return true;
170
- }
171
- boolean ok = true;
172
- for (File file : files) {
173
- // Skips the staging subdirectory (and any other non-file entry).
174
- if (!file.isFile()) {
175
- continue;
176
- }
177
- if (prefix == null || decodeKey(file.getName()).startsWith(prefix)) {
178
- if (!file.delete()) {
179
- ok = false;
180
- }
181
- }
182
- }
183
- // On a full clear, also drop any orphaned in-flight temp files.
184
- if (prefix == null) {
185
- File[] temps = stagingDir.listFiles();
186
- if (temps != null) {
187
- for (File t : temps) {
188
- t.delete();
189
- }
190
- }
191
- }
192
- return ok;
193
- }
194
-
195
- public synchronized List<String> keys(String pattern) {
196
- List<String> keys = new ArrayList<>();
197
- File[] files = baseDir.listFiles();
198
- if (files == null) {
199
- return keys;
200
- }
201
- for (File file : files) {
202
- // Skips the staging subdirectory (and any other non-file entry).
203
- if (!file.isFile()) {
204
- continue;
205
- }
206
- String key = decodeKey(file.getName());
207
- if (pattern == null || key.contains(pattern)) {
208
- keys.add(key);
209
- }
210
- }
211
- return keys;
212
- }
213
-
214
- public synchronized boolean has(String key) {
215
- File file = fileForKey(key);
216
- return file.exists() && file.isFile();
217
- }
218
-
219
- /**
220
- * Size information. {@code total} = {@code keys} + {@code values} (matching
221
- * the web adapters' convention), {@code values} = Σ file byte sizes,
222
- * {@code keys} = Σ decoded-key byte lengths, {@code metadata} = Σ length of
223
- * the parsed {@code metadata} field (0 when absent or unparseable),
224
- * {@code count} = number of stored files. The detailed breakdown is only
225
- * surfaced to JS when {@code detailed} is true.
226
- */
227
- public synchronized SQLiteStorage.SizeInfo size(boolean detailed) {
228
- File[] files = baseDir.listFiles();
229
- long valuesBytes = 0;
230
- long keysBytes = 0;
231
- long metadataBytes = 0;
232
- int count = 0;
233
-
234
- if (files != null) {
235
- for (File file : files) {
236
- // Skips the staging subdirectory (and any other non-file entry).
237
- if (!file.isFile()) {
238
- continue;
239
- }
240
- count++;
241
- valuesBytes += file.length();
242
- keysBytes += decodeKey(file.getName()).getBytes(StandardCharsets.UTF_8).length;
243
- if (detailed) {
244
- metadataBytes += metadataByteLength(file);
245
- }
246
- }
247
- }
248
-
249
- if (!detailed) {
250
- return new SQLiteStorage.SizeInfo(keysBytes + valuesBytes, count);
251
- }
252
- return new SQLiteStorage.SizeInfo(keysBytes + valuesBytes, count, keysBytes, valuesBytes, metadataBytes);
253
- }
254
-
255
- /** Parse the file and return the byte length of its {@code metadata} field. */
256
- private long metadataByteLength(File file) {
257
- try {
258
- JSObject wrapper = new JSObject(readFile(file));
259
- Object metadata = wrapper.opt("metadata");
260
- if (metadata != null && metadata != org.json.JSONObject.NULL) {
261
- return metadata.toString().getBytes(StandardCharsets.UTF_8).length;
262
- }
263
- } catch (Exception ignored) {
264
- // Unparseable → contributes 0 metadata bytes.
265
- }
266
- return 0;
267
- }
268
-
269
- private String readFile(File file) throws IOException {
270
- byte[] data = new byte[(int) file.length()];
271
- java.io.FileInputStream fis = null;
272
- try {
273
- fis = new java.io.FileInputStream(file);
274
- int offset = 0;
275
- int read;
276
- while (offset < data.length
277
- && (read = fis.read(data, offset, data.length - offset)) != -1) {
278
- offset += read;
279
- }
280
- } finally {
281
- if (fis != null) {
282
- fis.close();
283
- }
284
- }
285
- return new String(data, StandardCharsets.UTF_8);
286
- }
287
- }