@webority/mobile-ui 0.0.38 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ npm install @webority/mobile-ui
10
10
 
11
11
  No external runtime dependencies beyond `@webority/mobile-core`. Requires
12
12
  `react-native-gesture-handler`, `react-native-reanimated` and `react-native-safe-area-context` as
13
- peers; `react-native-haptic-feedback` is optional. Gradients use React Native's own
13
+ peers, and `expo-file-system`, where `AuthedImage` keeps its downloaded images; `react-native-haptic-feedback` is optional. Gradients use React Native's own
14
14
  `experimental_backgroundImage`, so no gradient library is needed.
15
15
 
16
16
  Full documentation lives in the repository's `docs/` directory.
@@ -82,10 +82,10 @@ const AuthedImage = props => {
82
82
  return;
83
83
  }
84
84
  if (cached && (0, _authedImageCache.isFresh)(cached.meta, ttlMs)) {
85
- void (0, _authedImageCache.touchEntry)(key, cached.meta);
85
+ void (0, _authedImageCache.touchEntry)(key);
86
86
  finish({
87
87
  status: 'ready',
88
- dataUri: cached.dataUri
88
+ fileUri: cached.fileUri
89
89
  });
90
90
  return;
91
91
  }
@@ -103,7 +103,7 @@ const AuthedImage = props => {
103
103
  const result = await entry.promise;
104
104
  finish({
105
105
  status: 'ready',
106
- dataUri: result.dataUri
106
+ fileUri: result.fileUri
107
107
  });
108
108
  } catch (error) {
109
109
  if (cancelled) {
@@ -114,7 +114,7 @@ const AuthedImage = props => {
114
114
  if (cached) {
115
115
  finish({
116
116
  status: 'ready',
117
- dataUri: cached.dataUri
117
+ fileUri: cached.fileUri
118
118
  });
119
119
  return;
120
120
  }
@@ -191,7 +191,7 @@ const AuthedImage = props => {
191
191
  testID: testID,
192
192
  style: style,
193
193
  source: {
194
- uri: state.dataUri
194
+ uri: state.fileUri
195
195
  }
196
196
  });
197
197
  };
@@ -5,11 +5,21 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.touchEntry = exports.setAuthedImageCacheLimit = exports.releaseFetch = exports.readEntry = exports.isFresh = exports.getAuthedImageCacheLimit = exports.deriveCacheKey = exports.clearAuthedImageCache = exports.authedImageCacheStats = exports.acquireFetch = exports.DEFAULT_TTL_MS = void 0;
7
7
  var _mobileCore = require("@webority/mobile-core");
8
+ var _expoFileSystem = require("expo-file-system");
8
9
  var _hashString = require("../../utils/hashString.js");
9
- const KEY_PREFIX = '@webority/authed-image/v1/';
10
- const DATA_PREFIX = `${KEY_PREFIX}d/`;
11
- const META_PREFIX = `${KEY_PREFIX}m/`;
10
+ const INDEX_KEY = '@webority/authed-image/v2/index';
11
+ // Releases before the file cache kept every image as a base64 string in the
12
+ // key-value store, up to the 50 MB ceiling. Nothing reads those rows any more,
13
+ // so they are removed the first time this version finds no index.
14
+ const LEGACY_PREFIX = '@webority/authed-image/v1/';
15
+ const FOLDER = 'authed-images';
12
16
  const DEFAULT_BYTE_CEILING = 50 * 1024 * 1024;
17
+
18
+ // A hit only moves an entry's last access in memory. Writing the index on every
19
+ // hit would put a storage write behind every row of a scrolling list, so a hit
20
+ // persists only when the stored time is this old; least-recently-used order
21
+ // survives a restart to within this window.
22
+ const TOUCH_PERSIST_MS = 60 * 60 * 1000;
13
23
  const DEFAULT_TTL_MS = exports.DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
14
24
  let byteCeiling = DEFAULT_BYTE_CEILING;
15
25
 
@@ -28,120 +38,196 @@ exports.setAuthedImageCacheLimit = setAuthedImageCacheLimit;
28
38
  const getAuthedImageCacheLimit = () => byteCeiling;
29
39
 
30
40
  /**
31
- * Hex only by construction, so it is always a safe storage key segment.
41
+ * Hex only by construction, so it is always a safe file name segment.
32
42
  */
33
43
  exports.getAuthedImageCacheLimit = getAuthedImageCacheLimit;
34
44
  const deriveCacheKey = (uri, override) => (0, _hashString.hashString)(override ?? uri);
45
+ exports.deriveCacheKey = deriveCacheKey;
46
+ const imageFile = fileName => new _expoFileSystem.File(_expoFileSystem.Paths.cache, FOLDER, fileName);
47
+ const toMeta = ({
48
+ fetchedAt,
49
+ bytes,
50
+ contentType,
51
+ lastAccessedAt
52
+ }) => ({
53
+ fetchedAt,
54
+ bytes,
55
+ contentType,
56
+ lastAccessedAt
57
+ });
58
+
59
+ // The image bytes live in files; only this small index goes to the key-value
60
+ // store. A store that fails costs persistence across restarts, never an image:
61
+ // Storage logs and returns false rather than throwing.
62
+ let index = null;
63
+ let indexLoad = null;
64
+ const persistIndex = async entries => {
65
+ if (!(0, _mobileCore.isStorageAvailable)()) {
66
+ return;
67
+ }
68
+ const out = {};
69
+ for (const [cacheKey, entry] of entries) {
70
+ out[cacheKey] = {
71
+ ...entry,
72
+ persistedAccessAt: entry.lastAccessedAt
73
+ };
74
+ }
75
+ if (await _mobileCore.Storage.setItem(INDEX_KEY, JSON.stringify(out))) {
76
+ for (const entry of entries.values()) {
77
+ entry.persistedAccessAt = entry.lastAccessedAt;
78
+ }
79
+ }
80
+ };
81
+ const removeLegacyEntries = async () => {
82
+ const keys = await _mobileCore.Storage.getAllKeys();
83
+ const legacy = keys.filter(key => key.startsWith(LEGACY_PREFIX));
84
+ if (legacy.length > 0) {
85
+ await _mobileCore.Storage.multiRemove(legacy);
86
+ }
87
+ };
35
88
 
36
89
  /**
37
- * The cache is an OPTIMISATION, so a failing store must never stop an image
38
- * rendering. That used to mean guarding on whether a backend existed at all,
39
- * warning once and skipping the cache; under Expo-first the backend ships with
40
- * the library, so "no backend" is no longer a state an app can be in and that
41
- * branch was unreachable.
42
- *
43
- * What remains is a store that exists and FAILS, which the per-operation error
44
- * handling below already covers: each read returns null and each write returns
45
- * false, the image still fetches, and nothing is logged per image.
90
+ * A file the index does not name is unreachable: written just before the app
91
+ * died, or left by an index write that failed. Removing them here, once per
92
+ * session and before anything new is written, keeps the byte ceiling true.
46
93
  */
47
- exports.deriveCacheKey = deriveCacheKey;
48
- const cacheUsable = () => (0, _mobileCore.isStorageAvailable)();
49
- const readEntry = async cacheKey => {
50
- if (!cacheUsable()) {
51
- return null;
94
+ const removeOrphanFiles = entries => {
95
+ try {
96
+ const folder = new _expoFileSystem.Directory(_expoFileSystem.Paths.cache, FOLDER);
97
+ if (!folder.exists) {
98
+ return;
99
+ }
100
+ const known = new Set([...entries.values()].map(entry => imageFile(entry.fileName).uri));
101
+ for (const item of folder.list()) {
102
+ if (!known.has(item.uri)) {
103
+ item.delete();
104
+ }
105
+ }
106
+ } catch (error) {
107
+ _mobileCore.Logger.warn('[AuthedImage] could not tidy the image cache folder', error);
52
108
  }
53
- const values = await _mobileCore.Storage.multiGet([`${DATA_PREFIX}${cacheKey}`, `${META_PREFIX}${cacheKey}`]);
54
- const dataUri = values[`${DATA_PREFIX}${cacheKey}`];
55
- const rawMeta = values[`${META_PREFIX}${cacheKey}`];
56
- if (!dataUri || !rawMeta) {
57
- return null;
109
+ };
110
+ const readStoredIndex = async () => {
111
+ const entries = new Map();
112
+ if (!(0, _mobileCore.isStorageAvailable)()) {
113
+ return entries;
114
+ }
115
+ const raw = await _mobileCore.Storage.getItem(INDEX_KEY);
116
+ if (!raw) {
117
+ await removeLegacyEntries();
118
+ return entries;
58
119
  }
59
120
  try {
60
- return {
61
- dataUri,
62
- meta: JSON.parse(rawMeta)
63
- };
121
+ for (const [cacheKey, entry] of Object.entries(JSON.parse(raw))) {
122
+ entries.set(cacheKey, entry);
123
+ }
64
124
  } catch {
125
+ // An index we cannot parse names no file, so every file becomes an orphan
126
+ // and is removed below: the cache starts empty rather than wrong.
127
+ }
128
+ return entries;
129
+ };
130
+ const loadIndex = () => {
131
+ if (index) {
132
+ return Promise.resolve(index);
133
+ }
134
+ if (!indexLoad) {
135
+ indexLoad = readStoredIndex().then(entries => {
136
+ removeOrphanFiles(entries);
137
+ index = entries;
138
+ return entries;
139
+ }).finally(() => {
140
+ indexLoad = null;
141
+ });
142
+ }
143
+ return indexLoad;
144
+ };
145
+ const deleteFile = fileName => {
146
+ try {
147
+ const file = imageFile(fileName);
148
+ if (file.exists) {
149
+ file.delete();
150
+ }
151
+ } catch (error) {
152
+ _mobileCore.Logger.warn('[AuthedImage] could not delete a cached image file', error);
153
+ }
154
+ };
155
+ const readEntry = async cacheKey => {
156
+ const entries = await loadIndex();
157
+ const entry = entries.get(cacheKey);
158
+ if (!entry) {
65
159
  return null;
66
160
  }
161
+ const file = imageFile(entry.fileName);
162
+ // The cache folder is the OS's to reclaim under storage pressure, so an
163
+ // entry can outlive its file.
164
+ if (!file.exists) {
165
+ entries.delete(cacheKey);
166
+ await persistIndex(entries);
167
+ return null;
168
+ }
169
+ return {
170
+ fileUri: file.uri,
171
+ meta: toMeta(entry)
172
+ };
67
173
  };
68
174
  exports.readEntry = readEntry;
69
175
  const isFresh = (meta, ttlMs, now = Date.now()) => now - meta.fetchedAt < ttlMs;
70
176
  exports.isFresh = isFresh;
71
- const touchEntry = async (cacheKey, meta) => {
72
- if (!cacheUsable()) {
177
+ const touchEntry = async cacheKey => {
178
+ const entries = await loadIndex();
179
+ const entry = entries.get(cacheKey);
180
+ if (!entry) {
73
181
  return;
74
182
  }
75
- await _mobileCore.Storage.setItem(`${META_PREFIX}${cacheKey}`, JSON.stringify({
76
- ...meta,
77
- lastAccessedAt: Date.now()
78
- }));
183
+ entry.lastAccessedAt = Date.now();
184
+ if (entry.lastAccessedAt - entry.persistedAccessAt >= TOUCH_PERSIST_MS) {
185
+ await persistIndex(entries);
186
+ }
79
187
  };
80
188
  exports.touchEntry = touchEntry;
81
- const listMeta = async () => {
82
- if (!cacheUsable()) {
83
- return [];
84
- }
85
- const keys = await _mobileCore.Storage.getAllKeys();
86
- const metaKeys = keys.filter(key => key.startsWith(META_PREFIX));
87
- if (metaKeys.length === 0) {
88
- return [];
89
- }
90
- const values = await _mobileCore.Storage.multiGet(metaKeys);
91
- const out = [];
92
- for (const key of metaKeys) {
93
- const raw = values[key];
94
- if (!raw) {
95
- continue;
96
- }
97
- try {
98
- out.push({
99
- cacheKey: key.slice(META_PREFIX.length),
100
- meta: JSON.parse(raw)
101
- });
102
- } catch {
103
- // A meta entry we cannot parse is unusable; dropping it from the ledger
104
- // lets the next eviction pass remove its orphaned data entry.
105
- }
189
+ const evictIfNeeded = entries => {
190
+ let total = 0;
191
+ for (const entry of entries.values()) {
192
+ total += entry.bytes;
106
193
  }
107
- return out;
108
- };
109
- const evictIfNeeded = async () => {
110
- const entries = await listMeta();
111
- let total = entries.reduce((sum, entry) => sum + entry.meta.bytes, 0);
112
194
  if (total <= byteCeiling) {
113
- return;
195
+ return 0;
114
196
  }
115
- const ordered = [...entries].sort((a, b) => a.meta.lastAccessedAt - b.meta.lastAccessedAt);
116
- const doomed = [];
117
- for (const entry of ordered) {
197
+ const ordered = [...entries].sort(([, a], [, b]) => a.lastAccessedAt - b.lastAccessedAt);
198
+ let evicted = 0;
199
+ for (const [cacheKey, entry] of ordered) {
118
200
  if (total <= byteCeiling) {
119
201
  break;
120
202
  }
121
- doomed.push(`${DATA_PREFIX}${entry.cacheKey}`, `${META_PREFIX}${entry.cacheKey}`);
122
- total -= entry.meta.bytes;
123
- }
124
- if (doomed.length > 0) {
125
- await _mobileCore.Storage.multiRemove(doomed);
126
- _mobileCore.Logger.info(`[AuthedImage] evicted ${doomed.length / 2} cached image(s) over the byte ceiling`);
203
+ entries.delete(cacheKey);
204
+ deleteFile(entry.fileName);
205
+ total -= entry.bytes;
206
+ evicted += 1;
127
207
  }
208
+ return evicted;
128
209
  };
129
210
  const clearAuthedImageCache = async () => {
130
- if (!cacheUsable()) {
131
- return;
211
+ const entries = await loadIndex();
212
+ entries.clear();
213
+ const folder = new _expoFileSystem.Directory(_expoFileSystem.Paths.cache, FOLDER);
214
+ if (folder.exists) {
215
+ folder.delete();
132
216
  }
133
- const keys = await _mobileCore.Storage.getAllKeys();
134
- const ours = keys.filter(key => key.startsWith(KEY_PREFIX));
135
- if (ours.length > 0) {
136
- await _mobileCore.Storage.multiRemove(ours);
217
+ if ((0, _mobileCore.isStorageAvailable)()) {
218
+ await _mobileCore.Storage.removeItem(INDEX_KEY);
137
219
  }
138
220
  };
139
221
  exports.clearAuthedImageCache = clearAuthedImageCache;
140
222
  const authedImageCacheStats = async () => {
141
- const entries = await listMeta();
223
+ const entries = await loadIndex();
224
+ let bytes = 0;
225
+ for (const entry of entries.values()) {
226
+ bytes += entry.bytes;
227
+ }
142
228
  return {
143
- entries: entries.length,
144
- bytes: entries.reduce((sum, entry) => sum + entry.meta.bytes, 0)
229
+ entries: entries.size,
230
+ bytes
145
231
  };
146
232
  };
147
233
  exports.authedImageCacheStats = authedImageCacheStats;
@@ -179,29 +265,48 @@ const fetchAndStore = async (cacheKey, options, signal) => {
179
265
  }
180
266
  throw new Error(`[@webority/mobile-ui] AuthedImage could not load "${options.uri}": the server ` + `answered ${status}. Check the URL and whether the request needs to be signed in ` + '(pass the signed-in client as `client`, or authenticated={false} for public media).');
181
267
  }
182
- const buffer = response.data ?? new ArrayBuffer(0);
268
+ const bytes = new Uint8Array(response.data ?? new ArrayBuffer(0));
183
269
  const rawType = responseHeader(response.headers, 'content-type');
184
270
  const contentType = (rawType ? rawType.split(';')[0].trim() : '') || 'image/jpeg';
185
- const dataUri = `data:${contentType};base64,${(0, _mobileCore.encodeBase64)(buffer)}`;
271
+
272
+ // Loaded before the write, so the orphan sweep in loadIndex can never see
273
+ // this file before its entry exists.
274
+ const entries = await loadIndex();
186
275
  const now = Date.now();
187
- // `bytes` measures the stored data URI, not the decoded image: the ceiling
188
- // exists to bound what sits in storage, and base64 costs about a third more.
189
- const meta = {
276
+ // A new name per fetch: native image caches key on the URI, so rewriting one
277
+ // path in place could keep showing the old picture after a refresh.
278
+ const fileName = `${cacheKey}-${now.toString(36)}`;
279
+ const file = imageFile(fileName);
280
+ try {
281
+ file.create({
282
+ intermediates: true,
283
+ overwrite: true
284
+ });
285
+ file.write(bytes);
286
+ } catch (error) {
287
+ throw new Error(`[@webority/mobile-ui] AuthedImage fetched "${options.uri}" but could not write it to ` + `the app's cache folder: ${error instanceof Error ? error.message : String(error)}. ` + 'The device may be out of storage.');
288
+ }
289
+ const entry = {
190
290
  fetchedAt: now,
191
- bytes: dataUri.length,
291
+ bytes: bytes.byteLength,
192
292
  contentType,
193
- lastAccessedAt: now
293
+ lastAccessedAt: now,
294
+ fileName,
295
+ persistedAccessAt: now
194
296
  };
195
- if (cacheUsable()) {
196
- await _mobileCore.Storage.multiSet({
197
- [`${DATA_PREFIX}${cacheKey}`]: dataUri,
198
- [`${META_PREFIX}${cacheKey}`]: JSON.stringify(meta)
199
- });
200
- await evictIfNeeded();
297
+ const previous = entries.get(cacheKey);
298
+ entries.set(cacheKey, entry);
299
+ if (previous && previous.fileName !== fileName) {
300
+ deleteFile(previous.fileName);
301
+ }
302
+ const evicted = evictIfNeeded(entries);
303
+ await persistIndex(entries);
304
+ if (evicted > 0) {
305
+ _mobileCore.Logger.info(`[AuthedImage] evicted ${evicted} cached image(s) over the byte ceiling`);
201
306
  }
202
307
  return {
203
- dataUri,
204
- meta
308
+ fileUri: file.uri,
309
+ meta: toMeta(entry)
205
310
  };
206
311
  };
207
312
  const inFlight = new Map();
@@ -78,10 +78,10 @@ const AuthedImage = props => {
78
78
  return;
79
79
  }
80
80
  if (cached && isFresh(cached.meta, ttlMs)) {
81
- void touchEntry(key, cached.meta);
81
+ void touchEntry(key);
82
82
  finish({
83
83
  status: 'ready',
84
- dataUri: cached.dataUri
84
+ fileUri: cached.fileUri
85
85
  });
86
86
  return;
87
87
  }
@@ -99,7 +99,7 @@ const AuthedImage = props => {
99
99
  const result = await entry.promise;
100
100
  finish({
101
101
  status: 'ready',
102
- dataUri: result.dataUri
102
+ fileUri: result.fileUri
103
103
  });
104
104
  } catch (error) {
105
105
  if (cancelled) {
@@ -110,7 +110,7 @@ const AuthedImage = props => {
110
110
  if (cached) {
111
111
  finish({
112
112
  status: 'ready',
113
- dataUri: cached.dataUri
113
+ fileUri: cached.fileUri
114
114
  });
115
115
  return;
116
116
  }
@@ -187,7 +187,7 @@ const AuthedImage = props => {
187
187
  testID: testID,
188
188
  style: style,
189
189
  source: {
190
- uri: state.dataUri
190
+ uri: state.fileUri
191
191
  }
192
192
  });
193
193
  };
@@ -1,11 +1,21 @@
1
1
  "use strict";
2
2
 
3
- import { ACCESS_TOKEN_KEY, encodeBase64, getToken, isStorageAvailable, Logger, publicClient, Storage } from '@webority/mobile-core';
3
+ import { ACCESS_TOKEN_KEY, getToken, isStorageAvailable, Logger, publicClient, Storage } from '@webority/mobile-core';
4
+ import { Directory, File, Paths } from 'expo-file-system';
4
5
  import { hashString } from "../../utils/hashString.js";
5
- const KEY_PREFIX = '@webority/authed-image/v1/';
6
- const DATA_PREFIX = `${KEY_PREFIX}d/`;
7
- const META_PREFIX = `${KEY_PREFIX}m/`;
6
+ const INDEX_KEY = '@webority/authed-image/v2/index';
7
+ // Releases before the file cache kept every image as a base64 string in the
8
+ // key-value store, up to the 50 MB ceiling. Nothing reads those rows any more,
9
+ // so they are removed the first time this version finds no index.
10
+ const LEGACY_PREFIX = '@webority/authed-image/v1/';
11
+ const FOLDER = 'authed-images';
8
12
  const DEFAULT_BYTE_CEILING = 50 * 1024 * 1024;
13
+
14
+ // A hit only moves an entry's last access in memory. Writing the index on every
15
+ // hit would put a storage write behind every row of a scrolling list, so a hit
16
+ // persists only when the stored time is this old; least-recently-used order
17
+ // survives a restart to within this window.
18
+ const TOUCH_PERSIST_MS = 60 * 60 * 1000;
9
19
  export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
10
20
  let byteCeiling = DEFAULT_BYTE_CEILING;
11
21
 
@@ -23,114 +33,190 @@ export const setAuthedImageCacheLimit = bytes => {
23
33
  export const getAuthedImageCacheLimit = () => byteCeiling;
24
34
 
25
35
  /**
26
- * Hex only by construction, so it is always a safe storage key segment.
36
+ * Hex only by construction, so it is always a safe file name segment.
27
37
  */
28
38
  export const deriveCacheKey = (uri, override) => hashString(override ?? uri);
39
+ const imageFile = fileName => new File(Paths.cache, FOLDER, fileName);
40
+ const toMeta = ({
41
+ fetchedAt,
42
+ bytes,
43
+ contentType,
44
+ lastAccessedAt
45
+ }) => ({
46
+ fetchedAt,
47
+ bytes,
48
+ contentType,
49
+ lastAccessedAt
50
+ });
51
+
52
+ // The image bytes live in files; only this small index goes to the key-value
53
+ // store. A store that fails costs persistence across restarts, never an image:
54
+ // Storage logs and returns false rather than throwing.
55
+ let index = null;
56
+ let indexLoad = null;
57
+ const persistIndex = async entries => {
58
+ if (!isStorageAvailable()) {
59
+ return;
60
+ }
61
+ const out = {};
62
+ for (const [cacheKey, entry] of entries) {
63
+ out[cacheKey] = {
64
+ ...entry,
65
+ persistedAccessAt: entry.lastAccessedAt
66
+ };
67
+ }
68
+ if (await Storage.setItem(INDEX_KEY, JSON.stringify(out))) {
69
+ for (const entry of entries.values()) {
70
+ entry.persistedAccessAt = entry.lastAccessedAt;
71
+ }
72
+ }
73
+ };
74
+ const removeLegacyEntries = async () => {
75
+ const keys = await Storage.getAllKeys();
76
+ const legacy = keys.filter(key => key.startsWith(LEGACY_PREFIX));
77
+ if (legacy.length > 0) {
78
+ await Storage.multiRemove(legacy);
79
+ }
80
+ };
29
81
 
30
82
  /**
31
- * The cache is an OPTIMISATION, so a failing store must never stop an image
32
- * rendering. That used to mean guarding on whether a backend existed at all,
33
- * warning once and skipping the cache; under Expo-first the backend ships with
34
- * the library, so "no backend" is no longer a state an app can be in and that
35
- * branch was unreachable.
36
- *
37
- * What remains is a store that exists and FAILS, which the per-operation error
38
- * handling below already covers: each read returns null and each write returns
39
- * false, the image still fetches, and nothing is logged per image.
83
+ * A file the index does not name is unreachable: written just before the app
84
+ * died, or left by an index write that failed. Removing them here, once per
85
+ * session and before anything new is written, keeps the byte ceiling true.
40
86
  */
41
- const cacheUsable = () => isStorageAvailable();
42
- export const readEntry = async cacheKey => {
43
- if (!cacheUsable()) {
44
- return null;
87
+ const removeOrphanFiles = entries => {
88
+ try {
89
+ const folder = new Directory(Paths.cache, FOLDER);
90
+ if (!folder.exists) {
91
+ return;
92
+ }
93
+ const known = new Set([...entries.values()].map(entry => imageFile(entry.fileName).uri));
94
+ for (const item of folder.list()) {
95
+ if (!known.has(item.uri)) {
96
+ item.delete();
97
+ }
98
+ }
99
+ } catch (error) {
100
+ Logger.warn('[AuthedImage] could not tidy the image cache folder', error);
45
101
  }
46
- const values = await Storage.multiGet([`${DATA_PREFIX}${cacheKey}`, `${META_PREFIX}${cacheKey}`]);
47
- const dataUri = values[`${DATA_PREFIX}${cacheKey}`];
48
- const rawMeta = values[`${META_PREFIX}${cacheKey}`];
49
- if (!dataUri || !rawMeta) {
50
- return null;
102
+ };
103
+ const readStoredIndex = async () => {
104
+ const entries = new Map();
105
+ if (!isStorageAvailable()) {
106
+ return entries;
107
+ }
108
+ const raw = await Storage.getItem(INDEX_KEY);
109
+ if (!raw) {
110
+ await removeLegacyEntries();
111
+ return entries;
51
112
  }
52
113
  try {
53
- return {
54
- dataUri,
55
- meta: JSON.parse(rawMeta)
56
- };
114
+ for (const [cacheKey, entry] of Object.entries(JSON.parse(raw))) {
115
+ entries.set(cacheKey, entry);
116
+ }
57
117
  } catch {
118
+ // An index we cannot parse names no file, so every file becomes an orphan
119
+ // and is removed below: the cache starts empty rather than wrong.
120
+ }
121
+ return entries;
122
+ };
123
+ const loadIndex = () => {
124
+ if (index) {
125
+ return Promise.resolve(index);
126
+ }
127
+ if (!indexLoad) {
128
+ indexLoad = readStoredIndex().then(entries => {
129
+ removeOrphanFiles(entries);
130
+ index = entries;
131
+ return entries;
132
+ }).finally(() => {
133
+ indexLoad = null;
134
+ });
135
+ }
136
+ return indexLoad;
137
+ };
138
+ const deleteFile = fileName => {
139
+ try {
140
+ const file = imageFile(fileName);
141
+ if (file.exists) {
142
+ file.delete();
143
+ }
144
+ } catch (error) {
145
+ Logger.warn('[AuthedImage] could not delete a cached image file', error);
146
+ }
147
+ };
148
+ export const readEntry = async cacheKey => {
149
+ const entries = await loadIndex();
150
+ const entry = entries.get(cacheKey);
151
+ if (!entry) {
152
+ return null;
153
+ }
154
+ const file = imageFile(entry.fileName);
155
+ // The cache folder is the OS's to reclaim under storage pressure, so an
156
+ // entry can outlive its file.
157
+ if (!file.exists) {
158
+ entries.delete(cacheKey);
159
+ await persistIndex(entries);
58
160
  return null;
59
161
  }
162
+ return {
163
+ fileUri: file.uri,
164
+ meta: toMeta(entry)
165
+ };
60
166
  };
61
167
  export const isFresh = (meta, ttlMs, now = Date.now()) => now - meta.fetchedAt < ttlMs;
62
- export const touchEntry = async (cacheKey, meta) => {
63
- if (!cacheUsable()) {
168
+ export const touchEntry = async cacheKey => {
169
+ const entries = await loadIndex();
170
+ const entry = entries.get(cacheKey);
171
+ if (!entry) {
64
172
  return;
65
173
  }
66
- await Storage.setItem(`${META_PREFIX}${cacheKey}`, JSON.stringify({
67
- ...meta,
68
- lastAccessedAt: Date.now()
69
- }));
70
- };
71
- const listMeta = async () => {
72
- if (!cacheUsable()) {
73
- return [];
74
- }
75
- const keys = await Storage.getAllKeys();
76
- const metaKeys = keys.filter(key => key.startsWith(META_PREFIX));
77
- if (metaKeys.length === 0) {
78
- return [];
79
- }
80
- const values = await Storage.multiGet(metaKeys);
81
- const out = [];
82
- for (const key of metaKeys) {
83
- const raw = values[key];
84
- if (!raw) {
85
- continue;
86
- }
87
- try {
88
- out.push({
89
- cacheKey: key.slice(META_PREFIX.length),
90
- meta: JSON.parse(raw)
91
- });
92
- } catch {
93
- // A meta entry we cannot parse is unusable; dropping it from the ledger
94
- // lets the next eviction pass remove its orphaned data entry.
95
- }
174
+ entry.lastAccessedAt = Date.now();
175
+ if (entry.lastAccessedAt - entry.persistedAccessAt >= TOUCH_PERSIST_MS) {
176
+ await persistIndex(entries);
96
177
  }
97
- return out;
98
178
  };
99
- const evictIfNeeded = async () => {
100
- const entries = await listMeta();
101
- let total = entries.reduce((sum, entry) => sum + entry.meta.bytes, 0);
179
+ const evictIfNeeded = entries => {
180
+ let total = 0;
181
+ for (const entry of entries.values()) {
182
+ total += entry.bytes;
183
+ }
102
184
  if (total <= byteCeiling) {
103
- return;
185
+ return 0;
104
186
  }
105
- const ordered = [...entries].sort((a, b) => a.meta.lastAccessedAt - b.meta.lastAccessedAt);
106
- const doomed = [];
107
- for (const entry of ordered) {
187
+ const ordered = [...entries].sort(([, a], [, b]) => a.lastAccessedAt - b.lastAccessedAt);
188
+ let evicted = 0;
189
+ for (const [cacheKey, entry] of ordered) {
108
190
  if (total <= byteCeiling) {
109
191
  break;
110
192
  }
111
- doomed.push(`${DATA_PREFIX}${entry.cacheKey}`, `${META_PREFIX}${entry.cacheKey}`);
112
- total -= entry.meta.bytes;
113
- }
114
- if (doomed.length > 0) {
115
- await Storage.multiRemove(doomed);
116
- Logger.info(`[AuthedImage] evicted ${doomed.length / 2} cached image(s) over the byte ceiling`);
193
+ entries.delete(cacheKey);
194
+ deleteFile(entry.fileName);
195
+ total -= entry.bytes;
196
+ evicted += 1;
117
197
  }
198
+ return evicted;
118
199
  };
119
200
  export const clearAuthedImageCache = async () => {
120
- if (!cacheUsable()) {
121
- return;
201
+ const entries = await loadIndex();
202
+ entries.clear();
203
+ const folder = new Directory(Paths.cache, FOLDER);
204
+ if (folder.exists) {
205
+ folder.delete();
122
206
  }
123
- const keys = await Storage.getAllKeys();
124
- const ours = keys.filter(key => key.startsWith(KEY_PREFIX));
125
- if (ours.length > 0) {
126
- await Storage.multiRemove(ours);
207
+ if (isStorageAvailable()) {
208
+ await Storage.removeItem(INDEX_KEY);
127
209
  }
128
210
  };
129
211
  export const authedImageCacheStats = async () => {
130
- const entries = await listMeta();
212
+ const entries = await loadIndex();
213
+ let bytes = 0;
214
+ for (const entry of entries.values()) {
215
+ bytes += entry.bytes;
216
+ }
131
217
  return {
132
- entries: entries.length,
133
- bytes: entries.reduce((sum, entry) => sum + entry.meta.bytes, 0)
218
+ entries: entries.size,
219
+ bytes
134
220
  };
135
221
  };
136
222
  const responseHeader = (headers, name) => {
@@ -167,29 +253,48 @@ const fetchAndStore = async (cacheKey, options, signal) => {
167
253
  }
168
254
  throw new Error(`[@webority/mobile-ui] AuthedImage could not load "${options.uri}": the server ` + `answered ${status}. Check the URL and whether the request needs to be signed in ` + '(pass the signed-in client as `client`, or authenticated={false} for public media).');
169
255
  }
170
- const buffer = response.data ?? new ArrayBuffer(0);
256
+ const bytes = new Uint8Array(response.data ?? new ArrayBuffer(0));
171
257
  const rawType = responseHeader(response.headers, 'content-type');
172
258
  const contentType = (rawType ? rawType.split(';')[0].trim() : '') || 'image/jpeg';
173
- const dataUri = `data:${contentType};base64,${encodeBase64(buffer)}`;
259
+
260
+ // Loaded before the write, so the orphan sweep in loadIndex can never see
261
+ // this file before its entry exists.
262
+ const entries = await loadIndex();
174
263
  const now = Date.now();
175
- // `bytes` measures the stored data URI, not the decoded image: the ceiling
176
- // exists to bound what sits in storage, and base64 costs about a third more.
177
- const meta = {
264
+ // A new name per fetch: native image caches key on the URI, so rewriting one
265
+ // path in place could keep showing the old picture after a refresh.
266
+ const fileName = `${cacheKey}-${now.toString(36)}`;
267
+ const file = imageFile(fileName);
268
+ try {
269
+ file.create({
270
+ intermediates: true,
271
+ overwrite: true
272
+ });
273
+ file.write(bytes);
274
+ } catch (error) {
275
+ throw new Error(`[@webority/mobile-ui] AuthedImage fetched "${options.uri}" but could not write it to ` + `the app's cache folder: ${error instanceof Error ? error.message : String(error)}. ` + 'The device may be out of storage.');
276
+ }
277
+ const entry = {
178
278
  fetchedAt: now,
179
- bytes: dataUri.length,
279
+ bytes: bytes.byteLength,
180
280
  contentType,
181
- lastAccessedAt: now
281
+ lastAccessedAt: now,
282
+ fileName,
283
+ persistedAccessAt: now
182
284
  };
183
- if (cacheUsable()) {
184
- await Storage.multiSet({
185
- [`${DATA_PREFIX}${cacheKey}`]: dataUri,
186
- [`${META_PREFIX}${cacheKey}`]: JSON.stringify(meta)
187
- });
188
- await evictIfNeeded();
285
+ const previous = entries.get(cacheKey);
286
+ entries.set(cacheKey, entry);
287
+ if (previous && previous.fileName !== fileName) {
288
+ deleteFile(previous.fileName);
289
+ }
290
+ const evicted = evictIfNeeded(entries);
291
+ await persistIndex(entries);
292
+ if (evicted > 0) {
293
+ Logger.info(`[AuthedImage] evicted ${evicted} cached image(s) over the byte ceiling`);
189
294
  }
190
295
  return {
191
- dataUri,
192
- meta
296
+ fileUri: file.uri,
297
+ meta: toMeta(entry)
193
298
  };
194
299
  };
195
300
  const inFlight = new Map();
@@ -7,7 +7,7 @@ export interface AuthedImageCacheMeta {
7
7
  lastAccessedAt: number;
8
8
  }
9
9
  export interface CachedImage {
10
- dataUri: string;
10
+ fileUri: string;
11
11
  meta: AuthedImageCacheMeta;
12
12
  }
13
13
  /**
@@ -18,12 +18,12 @@ export interface CachedImage {
18
18
  export declare const setAuthedImageCacheLimit: (bytes: number) => void;
19
19
  export declare const getAuthedImageCacheLimit: () => number;
20
20
  /**
21
- * Hex only by construction, so it is always a safe storage key segment.
21
+ * Hex only by construction, so it is always a safe file name segment.
22
22
  */
23
23
  export declare const deriveCacheKey: (uri: string, override?: string) => string;
24
24
  export declare const readEntry: (cacheKey: string) => Promise<CachedImage | null>;
25
25
  export declare const isFresh: (meta: AuthedImageCacheMeta, ttlMs: number, now?: number) => boolean;
26
- export declare const touchEntry: (cacheKey: string, meta: AuthedImageCacheMeta) => Promise<void>;
26
+ export declare const touchEntry: (cacheKey: string) => Promise<void>;
27
27
  export declare const clearAuthedImageCache: () => Promise<void>;
28
28
  export declare const authedImageCacheStats: () => Promise<{
29
29
  entries: number;
@@ -7,7 +7,7 @@ export interface AuthedImageCacheMeta {
7
7
  lastAccessedAt: number;
8
8
  }
9
9
  export interface CachedImage {
10
- dataUri: string;
10
+ fileUri: string;
11
11
  meta: AuthedImageCacheMeta;
12
12
  }
13
13
  /**
@@ -18,12 +18,12 @@ export interface CachedImage {
18
18
  export declare const setAuthedImageCacheLimit: (bytes: number) => void;
19
19
  export declare const getAuthedImageCacheLimit: () => number;
20
20
  /**
21
- * Hex only by construction, so it is always a safe storage key segment.
21
+ * Hex only by construction, so it is always a safe file name segment.
22
22
  */
23
23
  export declare const deriveCacheKey: (uri: string, override?: string) => string;
24
24
  export declare const readEntry: (cacheKey: string) => Promise<CachedImage | null>;
25
25
  export declare const isFresh: (meta: AuthedImageCacheMeta, ttlMs: number, now?: number) => boolean;
26
- export declare const touchEntry: (cacheKey: string, meta: AuthedImageCacheMeta) => Promise<void>;
26
+ export declare const touchEntry: (cacheKey: string) => Promise<void>;
27
27
  export declare const clearAuthedImageCache: () => Promise<void>;
28
28
  export declare const authedImageCacheStats: () => Promise<{
29
29
  entries: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority/mobile-ui",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "description": "Beautiful, animated, accessible React Native components, theme and form engine for Webority projects.",
5
5
  "keywords": [
6
6
  "react-native",
@@ -66,6 +66,7 @@
66
66
  "peerDependencies": {
67
67
  "@webority/mobile-core": ">=0.0.27 <0.1.0",
68
68
  "expo": ">=57.0.0",
69
+ "expo-file-system": ">=57.0.0",
69
70
  "expo-haptics": ">=57.0.0",
70
71
  "react": "^19.1.0",
71
72
  "react-native": ">=0.81.0",