@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 +1 -1
- package/lib/commonjs/components/AuthedImage/AuthedImage.js +5 -5
- package/lib/commonjs/components/AuthedImage/authedImageCache.js +202 -97
- package/lib/module/components/AuthedImage/AuthedImage.js +5 -5
- package/lib/module/components/AuthedImage/authedImageCache.js +202 -97
- package/lib/typescript/commonjs/components/AuthedImage/authedImageCache.d.ts +3 -3
- package/lib/typescript/module/components/AuthedImage/authedImageCache.d.ts +3 -3
- package/package.json +2 -1
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
|
|
85
|
+
void (0, _authedImageCache.touchEntry)(key);
|
|
86
86
|
finish({
|
|
87
87
|
status: 'ready',
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
57
|
-
return
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
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.
|
|
144
|
-
bytes
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
188
|
-
//
|
|
189
|
-
const
|
|
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:
|
|
291
|
+
bytes: bytes.byteLength,
|
|
192
292
|
contentType,
|
|
193
|
-
lastAccessedAt: now
|
|
293
|
+
lastAccessedAt: now,
|
|
294
|
+
fileName,
|
|
295
|
+
persistedAccessAt: now
|
|
194
296
|
};
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
81
|
+
void touchEntry(key);
|
|
82
82
|
finish({
|
|
83
83
|
status: 'ready',
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
50
|
-
return
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
106
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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.
|
|
133
|
-
bytes
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
const
|
|
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:
|
|
279
|
+
bytes: bytes.byteLength,
|
|
180
280
|
contentType,
|
|
181
|
-
lastAccessedAt: now
|
|
281
|
+
lastAccessedAt: now,
|
|
282
|
+
fileName,
|
|
283
|
+
persistedAccessAt: now
|
|
182
284
|
};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|