@harkenapp/sdk-react-native 0.0.1-alpha.1 → 0.0.1-alpha.2

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 (278) hide show
  1. package/README.md +44 -7
  2. package/app.plugin.cjs +12 -17
  3. package/dist/__mocks__/async-storage.d.ts +16 -0
  4. package/dist/__mocks__/async-storage.d.ts.map +1 -0
  5. package/dist/__mocks__/async-storage.js +39 -0
  6. package/dist/__mocks__/async-storage.js.map +1 -0
  7. package/dist/__mocks__/expo-document-picker.d.ts +26 -0
  8. package/dist/__mocks__/expo-document-picker.d.ts.map +1 -0
  9. package/dist/__mocks__/expo-document-picker.js +25 -0
  10. package/dist/__mocks__/expo-document-picker.js.map +1 -0
  11. package/dist/__mocks__/expo-file-system.d.ts +42 -0
  12. package/dist/__mocks__/expo-file-system.d.ts.map +1 -0
  13. package/dist/__mocks__/expo-file-system.js +37 -0
  14. package/dist/__mocks__/expo-file-system.js.map +1 -0
  15. package/dist/__mocks__/expo-image-picker.d.ts +30 -0
  16. package/dist/__mocks__/expo-image-picker.d.ts.map +1 -0
  17. package/dist/__mocks__/expo-image-picker.js +30 -0
  18. package/dist/__mocks__/expo-image-picker.js.map +1 -0
  19. package/dist/__mocks__/expo-secure-store.d.ts +15 -0
  20. package/dist/__mocks__/expo-secure-store.d.ts.map +1 -0
  21. package/dist/__mocks__/expo-secure-store.js +30 -0
  22. package/dist/__mocks__/expo-secure-store.js.map +1 -0
  23. package/dist/__mocks__/react-native.d.ts +73 -0
  24. package/dist/__mocks__/react-native.d.ts.map +1 -0
  25. package/dist/__mocks__/react-native.js +45 -0
  26. package/dist/__mocks__/react-native.js.map +1 -0
  27. package/dist/api/client.d.ts +8 -8
  28. package/dist/api/client.d.ts.map +1 -1
  29. package/dist/api/client.js +17 -19
  30. package/dist/api/client.js.map +1 -1
  31. package/dist/api/client.test.d.ts +2 -0
  32. package/dist/api/client.test.d.ts.map +1 -0
  33. package/dist/api/client.test.js +417 -0
  34. package/dist/api/client.test.js.map +1 -0
  35. package/dist/api/errors.d.ts +3 -3
  36. package/dist/api/errors.d.ts.map +1 -1
  37. package/dist/api/errors.js +3 -3
  38. package/dist/api/errors.js.map +1 -1
  39. package/dist/api/errors.test.d.ts +2 -0
  40. package/dist/api/errors.test.d.ts.map +1 -0
  41. package/dist/api/errors.test.js +155 -0
  42. package/dist/api/errors.test.js.map +1 -0
  43. package/dist/api/index.d.ts +6 -6
  44. package/dist/api/index.d.ts.map +1 -1
  45. package/dist/api/index.js.map +1 -1
  46. package/dist/api/retry.d.ts +1 -1
  47. package/dist/api/retry.d.ts.map +1 -1
  48. package/dist/api/retry.js.map +1 -1
  49. package/dist/api/retry.test.d.ts +2 -0
  50. package/dist/api/retry.test.d.ts.map +1 -0
  51. package/dist/api/retry.test.js +193 -0
  52. package/dist/api/retry.test.js.map +1 -0
  53. package/dist/attachments/FeedbackSheet.d.ts +36 -13
  54. package/dist/attachments/FeedbackSheet.d.ts.map +1 -1
  55. package/dist/attachments/FeedbackSheet.js +50 -30
  56. package/dist/attachments/FeedbackSheet.js.map +1 -1
  57. package/dist/attachments/index.d.ts +2 -2
  58. package/dist/components/AttachmentGrid.d.ts +12 -4
  59. package/dist/components/AttachmentGrid.d.ts.map +1 -1
  60. package/dist/components/AttachmentGrid.js +44 -34
  61. package/dist/components/AttachmentGrid.js.map +1 -1
  62. package/dist/components/AttachmentPicker.d.ts +3 -3
  63. package/dist/components/AttachmentPicker.d.ts.map +1 -1
  64. package/dist/components/AttachmentPicker.js +34 -36
  65. package/dist/components/AttachmentPicker.js.map +1 -1
  66. package/dist/components/AttachmentPreview.d.ts +10 -4
  67. package/dist/components/AttachmentPreview.d.ts.map +1 -1
  68. package/dist/components/AttachmentPreview.js +48 -34
  69. package/dist/components/AttachmentPreview.js.map +1 -1
  70. package/dist/components/CategorySelector.d.ts +3 -3
  71. package/dist/components/CategorySelector.d.ts.map +1 -1
  72. package/dist/components/CategorySelector.js +21 -27
  73. package/dist/components/CategorySelector.js.map +1 -1
  74. package/dist/components/FeedbackForm.d.ts +3 -3
  75. package/dist/components/FeedbackForm.d.ts.map +1 -1
  76. package/dist/components/FeedbackForm.js +7 -8
  77. package/dist/components/FeedbackForm.js.map +1 -1
  78. package/dist/components/FeedbackSheet.d.ts +34 -11
  79. package/dist/components/FeedbackSheet.d.ts.map +1 -1
  80. package/dist/components/FeedbackSheet.js +46 -28
  81. package/dist/components/FeedbackSheet.js.map +1 -1
  82. package/dist/components/ThemedButton.d.ts +16 -5
  83. package/dist/components/ThemedButton.d.ts.map +1 -1
  84. package/dist/components/ThemedButton.js +38 -29
  85. package/dist/components/ThemedButton.js.map +1 -1
  86. package/dist/components/ThemedText.d.ts +3 -3
  87. package/dist/components/ThemedText.d.ts.map +1 -1
  88. package/dist/components/ThemedText.js +1 -1
  89. package/dist/components/ThemedText.js.map +1 -1
  90. package/dist/components/ThemedTextInput.d.ts +11 -2
  91. package/dist/components/ThemedTextInput.d.ts.map +1 -1
  92. package/dist/components/ThemedTextInput.js +19 -9
  93. package/dist/components/ThemedTextInput.js.map +1 -1
  94. package/dist/components/UploadStatusOverlay.d.ts +11 -3
  95. package/dist/components/UploadStatusOverlay.d.ts.map +1 -1
  96. package/dist/components/UploadStatusOverlay.js +59 -76
  97. package/dist/components/UploadStatusOverlay.js.map +1 -1
  98. package/dist/components/index.d.ts +18 -18
  99. package/dist/components/index.d.ts.map +1 -1
  100. package/dist/components/index.js.map +1 -1
  101. package/dist/context/HarkenContext.d.ts +20 -15
  102. package/dist/context/HarkenContext.d.ts.map +1 -1
  103. package/dist/context/HarkenContext.js +20 -17
  104. package/dist/context/HarkenContext.js.map +1 -1
  105. package/dist/context/index.d.ts +2 -2
  106. package/dist/domain/index.d.ts +2 -2
  107. package/dist/domain/index.d.ts.map +1 -1
  108. package/dist/domain/index.js.map +1 -1
  109. package/dist/hooks/index.d.ts +5 -5
  110. package/dist/hooks/useAnonymousId.js +1 -1
  111. package/dist/hooks/useAnonymousId.test.d.ts +2 -0
  112. package/dist/hooks/useAnonymousId.test.d.ts.map +1 -0
  113. package/dist/hooks/useAnonymousId.test.js +154 -0
  114. package/dist/hooks/useAnonymousId.test.js.map +1 -0
  115. package/dist/hooks/useAttachmentPicker.d.ts +3 -3
  116. package/dist/hooks/useAttachmentPicker.js +7 -7
  117. package/dist/hooks/useAttachmentStatus.d.ts +1 -1
  118. package/dist/hooks/useAttachmentStatus.d.ts.map +1 -1
  119. package/dist/hooks/useAttachmentStatus.js.map +1 -1
  120. package/dist/hooks/useAttachmentUpload.d.ts +2 -2
  121. package/dist/hooks/useAttachmentUpload.d.ts.map +1 -1
  122. package/dist/hooks/useAttachmentUpload.js +5 -5
  123. package/dist/hooks/useAttachmentUpload.js.map +1 -1
  124. package/dist/hooks/useAttachmentUpload.test.d.ts +2 -0
  125. package/dist/hooks/useAttachmentUpload.test.d.ts.map +1 -0
  126. package/dist/hooks/useAttachmentUpload.test.js +542 -0
  127. package/dist/hooks/useAttachmentUpload.test.js.map +1 -0
  128. package/dist/hooks/useFeedback.d.ts +4 -4
  129. package/dist/hooks/useFeedback.d.ts.map +1 -1
  130. package/dist/hooks/useFeedback.js +3 -5
  131. package/dist/hooks/useFeedback.js.map +1 -1
  132. package/dist/hooks/useFeedback.test.d.ts +2 -0
  133. package/dist/hooks/useFeedback.test.d.ts.map +1 -0
  134. package/dist/hooks/useFeedback.test.js +299 -0
  135. package/dist/hooks/useFeedback.test.js.map +1 -0
  136. package/dist/hooks/useHarkenContext.d.ts +1 -1
  137. package/dist/hooks/useHarkenContext.js +1 -1
  138. package/dist/hooks/useHarkenTheme.d.ts +27 -3
  139. package/dist/hooks/useHarkenTheme.d.ts.map +1 -1
  140. package/dist/hooks/useHarkenTheme.js +26 -2
  141. package/dist/hooks/useHarkenTheme.js.map +1 -1
  142. package/dist/index.d.ts +28 -28
  143. package/dist/index.d.ts.map +1 -1
  144. package/dist/index.js.map +1 -1
  145. package/dist/services/index.d.ts +3 -3
  146. package/dist/services/index.d.ts.map +1 -1
  147. package/dist/services/index.js.map +1 -1
  148. package/dist/services/uploadQueueService.d.ts +2 -2
  149. package/dist/services/uploadQueueService.d.ts.map +1 -1
  150. package/dist/services/uploadQueueService.js +16 -17
  151. package/dist/services/uploadQueueService.js.map +1 -1
  152. package/dist/services/uploadQueueService.test.d.ts +2 -0
  153. package/dist/services/uploadQueueService.test.d.ts.map +1 -0
  154. package/dist/services/uploadQueueService.test.js +426 -0
  155. package/dist/services/uploadQueueService.test.js.map +1 -0
  156. package/dist/services/uploadQueueStorage.d.ts +1 -1
  157. package/dist/services/uploadQueueStorage.d.ts.map +1 -1
  158. package/dist/services/uploadQueueStorage.js +4 -4
  159. package/dist/services/uploadQueueStorage.js.map +1 -1
  160. package/dist/services/uploadQueueStorage.test.d.ts +2 -0
  161. package/dist/services/uploadQueueStorage.test.d.ts.map +1 -0
  162. package/dist/services/uploadQueueStorage.test.js +200 -0
  163. package/dist/services/uploadQueueStorage.test.js.map +1 -0
  164. package/dist/storage/IdentityStore.d.ts +1 -1
  165. package/dist/storage/IdentityStore.d.ts.map +1 -1
  166. package/dist/storage/IdentityStore.js.map +1 -1
  167. package/dist/storage/IdentityStore.test.d.ts +2 -0
  168. package/dist/storage/IdentityStore.test.d.ts.map +1 -0
  169. package/dist/storage/IdentityStore.test.js +176 -0
  170. package/dist/storage/IdentityStore.test.js.map +1 -0
  171. package/dist/storage/SecureStoreAdapter.d.ts +1 -1
  172. package/dist/storage/SecureStoreAdapter.test.d.ts +2 -0
  173. package/dist/storage/SecureStoreAdapter.test.d.ts.map +1 -0
  174. package/dist/storage/SecureStoreAdapter.test.js +114 -0
  175. package/dist/storage/SecureStoreAdapter.test.js.map +1 -0
  176. package/dist/storage/defaultStorage.d.ts +1 -1
  177. package/dist/storage/defaultStorage.js +4 -4
  178. package/dist/storage/defaultStorage.test.d.ts +2 -0
  179. package/dist/storage/defaultStorage.test.d.ts.map +1 -0
  180. package/dist/storage/defaultStorage.test.js +159 -0
  181. package/dist/storage/defaultStorage.test.js.map +1 -0
  182. package/dist/storage/index.d.ts +5 -5
  183. package/dist/storage/types.js +1 -1
  184. package/dist/theme/defaults.d.ts +14 -3
  185. package/dist/theme/defaults.d.ts.map +1 -1
  186. package/dist/theme/defaults.js +58 -43
  187. package/dist/theme/defaults.js.map +1 -1
  188. package/dist/theme/index.d.ts +3 -2
  189. package/dist/theme/index.d.ts.map +1 -1
  190. package/dist/theme/index.js +4 -1
  191. package/dist/theme/index.js.map +1 -1
  192. package/dist/theme/resolver.d.ts +16 -0
  193. package/dist/theme/resolver.d.ts.map +1 -0
  194. package/dist/theme/resolver.js +375 -0
  195. package/dist/theme/resolver.js.map +1 -0
  196. package/dist/theme/resolver.test.d.ts +2 -0
  197. package/dist/theme/resolver.test.d.ts.map +1 -0
  198. package/dist/theme/resolver.test.js +344 -0
  199. package/dist/theme/resolver.test.js.map +1 -0
  200. package/dist/theme/types.d.ts +378 -5
  201. package/dist/theme/types.d.ts.map +1 -1
  202. package/dist/types/config.d.ts +4 -4
  203. package/dist/types/index.d.ts +2 -2
  204. package/dist/utils/index.d.ts +1 -1
  205. package/dist/utils/uuid.d.ts.map +1 -1
  206. package/dist/utils/uuid.js +4 -5
  207. package/dist/utils/uuid.js.map +1 -1
  208. package/dist/utils/uuid.test.d.ts +2 -0
  209. package/dist/utils/uuid.test.d.ts.map +1 -0
  210. package/dist/utils/uuid.test.js +78 -0
  211. package/dist/utils/uuid.test.js.map +1 -0
  212. package/package.json +21 -13
  213. package/src/@types/expo-file-system-legacy.d.ts +3 -3
  214. package/src/__mocks__/async-storage.ts +46 -0
  215. package/src/__mocks__/expo-document-picker.ts +41 -0
  216. package/src/__mocks__/expo-file-system.ts +62 -0
  217. package/src/__mocks__/expo-image-picker.ts +48 -0
  218. package/src/__mocks__/expo-secure-store.ts +29 -0
  219. package/src/__mocks__/react-native.ts +46 -0
  220. package/src/api/client.test.ts +515 -0
  221. package/src/api/client.ts +45 -64
  222. package/src/api/errors.test.ts +193 -0
  223. package/src/api/errors.ts +7 -11
  224. package/src/api/index.ts +6 -10
  225. package/src/api/retry.test.ts +251 -0
  226. package/src/api/retry.ts +3 -6
  227. package/src/attachments/FeedbackSheet.tsx +100 -80
  228. package/src/attachments/index.ts +2 -2
  229. package/src/components/AttachmentGrid.tsx +54 -45
  230. package/src/components/AttachmentPicker.tsx +43 -54
  231. package/src/components/AttachmentPreview.tsx +51 -47
  232. package/src/components/CategorySelector.tsx +29 -35
  233. package/src/components/FeedbackForm.tsx +23 -35
  234. package/src/components/FeedbackSheet.tsx +89 -68
  235. package/src/components/ThemedButton.tsx +49 -47
  236. package/src/components/ThemedText.tsx +7 -10
  237. package/src/components/ThemedTextInput.tsx +23 -13
  238. package/src/components/UploadStatusOverlay.tsx +66 -89
  239. package/src/components/index.ts +18 -21
  240. package/src/context/HarkenContext.tsx +29 -28
  241. package/src/context/index.ts +2 -2
  242. package/src/domain/index.ts +2 -5
  243. package/src/domain/upload-queue.ts +5 -5
  244. package/src/hooks/index.ts +5 -5
  245. package/src/hooks/useAnonymousId.test.ts +189 -0
  246. package/src/hooks/useAnonymousId.ts +3 -3
  247. package/src/hooks/useAttachmentPicker.ts +12 -12
  248. package/src/hooks/useAttachmentStatus.ts +12 -16
  249. package/src/hooks/useAttachmentUpload.test.ts +632 -0
  250. package/src/hooks/useAttachmentUpload.ts +45 -54
  251. package/src/hooks/useFeedback.test.ts +376 -0
  252. package/src/hooks/useFeedback.ts +12 -14
  253. package/src/hooks/useHarkenContext.ts +4 -4
  254. package/src/hooks/useHarkenTheme.ts +30 -6
  255. package/src/index.ts +28 -52
  256. package/src/services/index.ts +3 -9
  257. package/src/services/uploadQueueService.test.ts +489 -0
  258. package/src/services/uploadQueueService.ts +40 -56
  259. package/src/services/uploadQueueStorage.test.ts +243 -0
  260. package/src/services/uploadQueueStorage.ts +7 -9
  261. package/src/storage/IdentityStore.test.ts +173 -0
  262. package/src/storage/IdentityStore.ts +4 -5
  263. package/src/storage/SecureStoreAdapter.test.ts +147 -0
  264. package/src/storage/SecureStoreAdapter.ts +1 -1
  265. package/src/storage/defaultStorage.test.ts +159 -0
  266. package/src/storage/defaultStorage.ts +6 -6
  267. package/src/storage/index.ts +5 -5
  268. package/src/storage/types.ts +1 -1
  269. package/src/theme/defaults.ts +75 -46
  270. package/src/theme/index.ts +15 -2
  271. package/src/theme/resolver.test.ts +411 -0
  272. package/src/theme/resolver.ts +446 -0
  273. package/src/theme/types.ts +453 -15
  274. package/src/types/config.ts +4 -4
  275. package/src/types/index.ts +2 -2
  276. package/src/utils/index.ts +1 -1
  277. package/src/utils/uuid.test.ts +85 -0
  278. package/src/utils/uuid.ts +4 -7
@@ -13,22 +13,14 @@
13
13
  * - D6: Real progress tracking from expo-file-system
14
14
  */
15
15
 
16
- import * as FileSystem from 'expo-file-system/legacy';
17
- import NetInfo, {
18
- NetInfoState,
19
- NetInfoSubscription,
20
- } from '@react-native-community/netinfo';
21
- import { HarkenClient } from '../api/client';
22
- import { UploadQueueStorage } from './uploadQueueStorage';
23
- import {
24
- QueueItem,
25
- QueueStatus,
26
- UploadPhase,
27
- UploadProgress,
28
- UploadRetryConfig,
29
- DEFAULT_UPLOAD_RETRY_CONFIG,
30
- } from '../domain';
31
- import { generateUUID } from '../utils';
16
+ import * as FileSystem from "expo-file-system/legacy";
17
+ import type { NetInfoState, NetInfoSubscription } from "@react-native-community/netinfo";
18
+ import NetInfo from "@react-native-community/netinfo";
19
+ import type { HarkenClient } from "../api/client";
20
+ import { UploadQueueStorage } from "./uploadQueueStorage";
21
+ import type { QueueItem, QueueStatus, UploadProgress, UploadRetryConfig } from "../domain";
22
+ import { UploadPhase, DEFAULT_UPLOAD_RETRY_CONFIG } from "../domain";
23
+ import { generateUUID } from "../utils";
32
24
 
33
25
  // Callback types for event subscriptions
34
26
  type ProgressCallback = (progress: UploadProgress) => void;
@@ -120,7 +112,7 @@ export class UploadQueueService {
120
112
  this.debug = config.debug ?? false;
121
113
 
122
114
  if (this.isInitialized) {
123
- this.log('Already initialized, updated client reference');
115
+ this.log("Already initialized, updated client reference");
124
116
  return;
125
117
  }
126
118
 
@@ -131,10 +123,7 @@ export class UploadQueueService {
131
123
  for (const item of persistedItems) {
132
124
  // Reset any "uploading" or "confirming" items to "queued"
133
125
  // (app was killed mid-upload)
134
- if (
135
- item.phase === UploadPhase.UPLOADING ||
136
- item.phase === UploadPhase.CONFIRMING
137
- ) {
126
+ if (item.phase === UploadPhase.UPLOADING || item.phase === UploadPhase.CONFIRMING) {
138
127
  item.phase = UploadPhase.QUEUED;
139
128
  item.progress = 0;
140
129
  }
@@ -176,7 +165,7 @@ export class UploadQueueService {
176
165
  queueItemId: string;
177
166
  }> {
178
167
  if (!this.client) {
179
- throw new Error('UploadQueueService not initialized');
168
+ throw new Error("UploadQueueService not initialized");
180
169
  }
181
170
 
182
171
  // 1. Get presigned URL from server
@@ -236,19 +225,19 @@ export class UploadQueueService {
236
225
  this.clearRetryTimer();
237
226
 
238
227
  this.isProcessing = true;
239
- this.log('Processing queue...');
228
+ this.log("Processing queue...");
240
229
 
241
230
  try {
242
231
  while (true) {
243
232
  // Find next item to process
244
233
  const item = this.getNextQueuedItem();
245
234
  if (!item) {
246
- this.log('No more items to process');
235
+ this.log("No more items to process");
247
236
  break;
248
237
  }
249
238
 
250
239
  if (this.isPaused) {
251
- this.log('Queue paused, stopping processing');
240
+ this.log("Queue paused, stopping processing");
252
241
  break;
253
242
  }
254
243
 
@@ -270,7 +259,7 @@ export class UploadQueueService {
270
259
  // Check if URL has expired
271
260
  if (new Date(item.uploadExpiresAt) < new Date()) {
272
261
  this.log(`URL expired for ${item.attachmentId}`);
273
- await this.handleItemFailure(item, 'Upload URL expired');
262
+ await this.handleItemFailure(item, "Upload URL expired");
274
263
  return;
275
264
  }
276
265
 
@@ -314,8 +303,7 @@ export class UploadQueueService {
314
303
 
315
304
  this.log(`Completed upload for ${item.attachmentId}`);
316
305
  } catch (error) {
317
- const errorMessage =
318
- error instanceof Error ? error.message : 'Unknown error';
306
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
319
307
  this.log(`Upload failed for ${item.attachmentId}: ${errorMessage}`);
320
308
 
321
309
  if (item.attemptNumber < item.maxAttempts) {
@@ -346,11 +334,11 @@ export class UploadQueueService {
346
334
  item.uploadUrl,
347
335
  item.localUri,
348
336
  {
349
- httpMethod: 'PUT',
337
+ httpMethod: "PUT",
350
338
  uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
351
339
  sessionType: FileSystem.FileSystemSessionType.BACKGROUND,
352
340
  headers: {
353
- 'Content-Type': item.mimeType,
341
+ "Content-Type": item.mimeType,
354
342
  },
355
343
  },
356
344
  (progress) => {
@@ -375,9 +363,7 @@ export class UploadQueueService {
375
363
  if (result && result.status >= 200 && result.status < 300) {
376
364
  resolve();
377
365
  } else {
378
- reject(
379
- new Error(`Upload failed with status ${result?.status ?? 'unknown'}`)
380
- );
366
+ reject(new Error(`Upload failed with status ${result?.status ?? "unknown"}`));
381
367
  }
382
368
  })
383
369
  .catch((error) => {
@@ -493,29 +479,27 @@ export class UploadQueueService {
493
479
  // --- Network Monitoring (D5) ---
494
480
 
495
481
  private setupNetworkMonitoring(): void {
496
- this.networkUnsubscribe = NetInfo.addEventListener(
497
- (state: NetInfoState) => {
498
- if (state.isConnected && this.isPaused) {
499
- this.log('Network restored, resuming queue');
500
- this.isPaused = false;
501
- void this.processQueue();
502
- } else if (!state.isConnected && !this.isPaused) {
503
- this.log('Network lost, pausing queue');
504
- this.isPaused = true;
505
- // Cancel active uploads - they'll resume when back online
506
- for (const [id, task] of this.activeTasks) {
507
- void task.cancelAsync();
508
- const item = this.items.get(id);
509
- if (item) {
510
- item.phase = UploadPhase.QUEUED;
511
- item.progress = 0;
512
- }
482
+ this.networkUnsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
483
+ if (state.isConnected && this.isPaused) {
484
+ this.log("Network restored, resuming queue");
485
+ this.isPaused = false;
486
+ void this.processQueue();
487
+ } else if (!state.isConnected && !this.isPaused) {
488
+ this.log("Network lost, pausing queue");
489
+ this.isPaused = true;
490
+ // Cancel active uploads - they'll resume when back online
491
+ for (const [id, task] of this.activeTasks) {
492
+ void task.cancelAsync();
493
+ const item = this.items.get(id);
494
+ if (item) {
495
+ item.phase = UploadPhase.QUEUED;
496
+ item.progress = 0;
513
497
  }
514
- this.activeTasks.clear();
515
- void this.persistQueue();
516
498
  }
499
+ this.activeTasks.clear();
500
+ void this.persistQueue();
517
501
  }
518
- );
502
+ });
519
503
  }
520
504
 
521
505
  // --- Event Emitters ---
@@ -622,7 +606,7 @@ export class UploadQueueService {
622
606
  async retryItem(attachmentId: string): Promise<void> {
623
607
  const item = this.getItemByAttachmentId(attachmentId);
624
608
  if (!item || item.phase !== UploadPhase.FAILED) {
625
- throw new Error('Item not found or not in failed state');
609
+ throw new Error("Item not found or not in failed state");
626
610
  }
627
611
 
628
612
  item.phase = UploadPhase.QUEUED;
@@ -669,7 +653,7 @@ export class UploadQueueService {
669
653
  }
670
654
  }
671
655
  await this.persistQueue();
672
- this.log('Cleared completed items');
656
+ this.log("Cleared completed items");
673
657
  }
674
658
 
675
659
  /**
@@ -682,7 +666,7 @@ export class UploadQueueService {
682
666
  }
683
667
  }
684
668
  await this.persistQueue();
685
- this.log('Cleared failed items');
669
+ this.log("Cleared failed items");
686
670
  }
687
671
 
688
672
  // --- Internal Utilities ---
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { UploadQueueStorage } from "./uploadQueueStorage";
3
+ import { UploadPhase } from "../domain";
4
+ import type { QueueItem, PersistedQueue } from "../domain";
5
+ import AsyncStorage from "@react-native-async-storage/async-storage";
6
+
7
+ // Mock AsyncStorage
8
+ vi.mock("@react-native-async-storage/async-storage", () => ({
9
+ default: {
10
+ getItem: vi.fn(),
11
+ setItem: vi.fn(),
12
+ removeItem: vi.fn(),
13
+ },
14
+ }));
15
+
16
+ const STORAGE_KEY = "@harkenapp/upload-queue";
17
+
18
+ function createMockQueueItem(overrides: Partial<QueueItem> = {}): QueueItem {
19
+ return {
20
+ id: "queue_1",
21
+ attachmentId: "att_1",
22
+ localUri: "file:///path/to/file.png",
23
+ uploadUrl: "https://storage.example.com/upload",
24
+ uploadExpiresAt: "2024-01-01T01:00:00Z",
25
+ mimeType: "image/png",
26
+ fileName: "screenshot.png",
27
+ fileSize: 12345,
28
+ phase: UploadPhase.QUEUED,
29
+ progress: 0,
30
+ attemptNumber: 1,
31
+ maxAttempts: 3,
32
+ createdAt: "2024-01-01T00:00:00Z",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ describe("UploadQueueStorage", () => {
38
+ let storage: UploadQueueStorage;
39
+ const mockAsyncStorage = AsyncStorage as unknown as {
40
+ getItem: ReturnType<typeof vi.fn>;
41
+ setItem: ReturnType<typeof vi.fn>;
42
+ removeItem: ReturnType<typeof vi.fn>;
43
+ };
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ storage = new UploadQueueStorage();
48
+ mockAsyncStorage.getItem.mockResolvedValue(null);
49
+ mockAsyncStorage.setItem.mockResolvedValue(undefined);
50
+ mockAsyncStorage.removeItem.mockResolvedValue(undefined);
51
+ });
52
+
53
+ describe("loadQueue", () => {
54
+ it("returns empty array when no queue exists", async () => {
55
+ mockAsyncStorage.getItem.mockResolvedValue(null);
56
+
57
+ const items = await storage.loadQueue();
58
+
59
+ expect(items).toEqual([]);
60
+ expect(mockAsyncStorage.getItem).toHaveBeenCalledWith(STORAGE_KEY);
61
+ });
62
+
63
+ it("returns items from valid persisted queue", async () => {
64
+ const persistedQueue: PersistedQueue = {
65
+ version: 1,
66
+ items: [
67
+ createMockQueueItem({ id: "queue_1", attachmentId: "att_1" }),
68
+ createMockQueueItem({ id: "queue_2", attachmentId: "att_2" }),
69
+ ],
70
+ };
71
+ mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(persistedQueue));
72
+
73
+ const items = await storage.loadQueue();
74
+
75
+ expect(items).toHaveLength(2);
76
+ expect(items[0]!.attachmentId).toBe("att_1");
77
+ expect(items[1]!.attachmentId).toBe("att_2");
78
+ });
79
+
80
+ it("returns empty array for invalid JSON", async () => {
81
+ mockAsyncStorage.getItem.mockResolvedValue("not valid json {{{");
82
+
83
+ const items = await storage.loadQueue();
84
+
85
+ expect(items).toEqual([]);
86
+ });
87
+
88
+ it("returns empty array when storage throws", async () => {
89
+ mockAsyncStorage.getItem.mockRejectedValue(new Error("Storage error"));
90
+
91
+ const items = await storage.loadQueue();
92
+
93
+ expect(items).toEqual([]);
94
+ });
95
+
96
+ it("migrates and returns empty for unknown version", async () => {
97
+ const persistedQueue = {
98
+ version: 999,
99
+ items: [createMockQueueItem()],
100
+ };
101
+ mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(persistedQueue));
102
+
103
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
104
+
105
+ const items = await storage.loadQueue();
106
+
107
+ expect(items).toEqual([]);
108
+ expect(consoleWarn).toHaveBeenCalledWith(
109
+ expect.stringContaining("Unknown queue version 999")
110
+ );
111
+
112
+ consoleWarn.mockRestore();
113
+ });
114
+ });
115
+
116
+ describe("saveQueue", () => {
117
+ it("saves queue items with version", async () => {
118
+ const items = [
119
+ createMockQueueItem({ id: "queue_1" }),
120
+ createMockQueueItem({ id: "queue_2" }),
121
+ ];
122
+
123
+ await storage.saveQueue(items);
124
+
125
+ expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String));
126
+
127
+ const call = mockAsyncStorage.setItem.mock.calls[0];
128
+ expect(call).toBeDefined();
129
+ const savedData = JSON.parse(call![1]) as PersistedQueue;
130
+ expect(savedData.version).toBe(1);
131
+ expect(savedData.items).toHaveLength(2);
132
+ });
133
+
134
+ it("saves empty array correctly", async () => {
135
+ await storage.saveQueue([]);
136
+
137
+ const call = mockAsyncStorage.setItem.mock.calls[0];
138
+ expect(call).toBeDefined();
139
+ const savedData = JSON.parse(call![1]) as PersistedQueue;
140
+ expect(savedData.items).toEqual([]);
141
+ });
142
+
143
+ it("handles storage error gracefully", async () => {
144
+ mockAsyncStorage.setItem.mockRejectedValue(new Error("Storage error"));
145
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
146
+
147
+ // Should not throw
148
+ await expect(storage.saveQueue([createMockQueueItem()])).resolves.not.toThrow();
149
+
150
+ expect(consoleError).toHaveBeenCalled();
151
+ consoleError.mockRestore();
152
+ });
153
+
154
+ it("preserves all queue item fields", async () => {
155
+ const item = createMockQueueItem({
156
+ id: "queue_123",
157
+ attachmentId: "att_456",
158
+ localUri: "file:///custom/path.png",
159
+ phase: UploadPhase.UPLOADING,
160
+ progress: 0.5,
161
+ attemptNumber: 2,
162
+ lastError: "Previous attempt failed",
163
+ startedAt: "2024-01-01T00:00:05Z",
164
+ scheduledRetryAt: "2024-01-01T00:01:00Z",
165
+ });
166
+
167
+ await storage.saveQueue([item]);
168
+
169
+ const call = mockAsyncStorage.setItem.mock.calls[0];
170
+ expect(call).toBeDefined();
171
+ const savedData = JSON.parse(call![1]) as PersistedQueue;
172
+ const savedItem = savedData.items[0]!;
173
+
174
+ expect(savedItem.id).toBe("queue_123");
175
+ expect(savedItem.attachmentId).toBe("att_456");
176
+ expect(savedItem.phase).toBe(UploadPhase.UPLOADING);
177
+ expect(savedItem.progress).toBe(0.5);
178
+ expect(savedItem.lastError).toBe("Previous attempt failed");
179
+ expect(savedItem.startedAt).toBe("2024-01-01T00:00:05Z");
180
+ });
181
+ });
182
+
183
+ describe("clearQueue", () => {
184
+ it("removes queue from storage", async () => {
185
+ await storage.clearQueue();
186
+
187
+ expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
188
+ });
189
+
190
+ it("handles storage error gracefully", async () => {
191
+ mockAsyncStorage.removeItem.mockRejectedValue(new Error("Storage error"));
192
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
193
+
194
+ // Should not throw
195
+ await expect(storage.clearQueue()).resolves.not.toThrow();
196
+
197
+ expect(consoleError).toHaveBeenCalled();
198
+ consoleError.mockRestore();
199
+ });
200
+ });
201
+
202
+ describe("round-trip persistence", () => {
203
+ it("can save and reload queue items", async () => {
204
+ // Set up mock to return saved data on next load
205
+ let savedData: string | null = null;
206
+ mockAsyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
207
+ if (key === STORAGE_KEY) savedData = value;
208
+ });
209
+ mockAsyncStorage.getItem.mockImplementation(async (key: string) => {
210
+ if (key === STORAGE_KEY) return savedData;
211
+ return null;
212
+ });
213
+
214
+ const originalItems = [
215
+ createMockQueueItem({
216
+ id: "queue_1",
217
+ phase: UploadPhase.QUEUED,
218
+ progress: 0,
219
+ }),
220
+ createMockQueueItem({
221
+ id: "queue_2",
222
+ phase: UploadPhase.UPLOADING,
223
+ progress: 0.75,
224
+ }),
225
+ createMockQueueItem({
226
+ id: "queue_3",
227
+ phase: UploadPhase.FAILED,
228
+ progress: 0.3,
229
+ lastError: "Connection timeout",
230
+ }),
231
+ ];
232
+
233
+ await storage.saveQueue(originalItems);
234
+ const loadedItems = await storage.loadQueue();
235
+
236
+ expect(loadedItems).toHaveLength(3);
237
+ expect(loadedItems[0]!.id).toBe("queue_1");
238
+ expect(loadedItems[0]!.phase).toBe(UploadPhase.QUEUED);
239
+ expect(loadedItems[1]!.progress).toBe(0.75);
240
+ expect(loadedItems[2]!.lastError).toBe("Connection timeout");
241
+ });
242
+ });
243
+ });
@@ -4,10 +4,10 @@
4
4
  * Uses AsyncStorage to persist queue state across app restarts.
5
5
  */
6
6
 
7
- import AsyncStorage from '@react-native-async-storage/async-storage';
8
- import type { PersistedQueue, QueueItem } from '../domain';
7
+ import AsyncStorage from "@react-native-async-storage/async-storage";
8
+ import type { PersistedQueue, QueueItem } from "../domain";
9
9
 
10
- const STORAGE_KEY = '@harkenapp/upload-queue';
10
+ const STORAGE_KEY = "@harkenapp/upload-queue";
11
11
  const CURRENT_VERSION = 1;
12
12
 
13
13
  /**
@@ -34,7 +34,7 @@ export class UploadQueueStorage {
34
34
 
35
35
  return parsed.items;
36
36
  } catch (error) {
37
- console.error('[UploadQueueStorage] Failed to load queue:', error);
37
+ console.error("[UploadQueueStorage] Failed to load queue:", error);
38
38
  return [];
39
39
  }
40
40
  }
@@ -50,7 +50,7 @@ export class UploadQueueStorage {
50
50
  };
51
51
  await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(data));
52
52
  } catch (error) {
53
- console.error('[UploadQueueStorage] Failed to save queue:', error);
53
+ console.error("[UploadQueueStorage] Failed to save queue:", error);
54
54
  }
55
55
  }
56
56
 
@@ -61,7 +61,7 @@ export class UploadQueueStorage {
61
61
  try {
62
62
  await AsyncStorage.removeItem(STORAGE_KEY);
63
63
  } catch (error) {
64
- console.error('[UploadQueueStorage] Failed to clear queue:', error);
64
+ console.error("[UploadQueueStorage] Failed to clear queue:", error);
65
65
  }
66
66
  }
67
67
 
@@ -70,9 +70,7 @@ export class UploadQueueStorage {
70
70
  * For now, unknown versions are reset to empty.
71
71
  */
72
72
  private migrateQueue(data: PersistedQueue): QueueItem[] {
73
- console.warn(
74
- `[UploadQueueStorage] Unknown queue version ${data.version}, resetting`
75
- );
73
+ console.warn(`[UploadQueueStorage] Unknown queue version ${data.version}, resetting`);
76
74
  return [];
77
75
  }
78
76
  }
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { IdentityStore } from "./IdentityStore";
3
+ import { STORAGE_KEYS } from "./types";
4
+ import type { SecureStorage } from "./types";
5
+ import * as utils from "../utils";
6
+
7
+ // Mock generateUUID
8
+ vi.mock("../utils", () => ({
9
+ generateUUID: vi.fn(),
10
+ }));
11
+
12
+ const mockGenerateUUID = vi.mocked(utils.generateUUID);
13
+
14
+ function createMockStorage(): SecureStorage & { data: Map<string, string> } {
15
+ const data = new Map<string, string>();
16
+ return {
17
+ data,
18
+ getItem: vi.fn(async (key: string) => data.get(key) ?? null),
19
+ setItem: vi.fn(async (key: string, value: string) => {
20
+ data.set(key, value);
21
+ }),
22
+ deleteItem: vi.fn(async (key: string) => {
23
+ data.delete(key);
24
+ }),
25
+ };
26
+ }
27
+
28
+ describe("IdentityStore", () => {
29
+ let mockStorage: ReturnType<typeof createMockStorage>;
30
+ let identityStore: IdentityStore;
31
+ let uuidCounter: number;
32
+
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ mockStorage = createMockStorage();
36
+ identityStore = new IdentityStore(mockStorage);
37
+
38
+ // Reset UUID counter and set up mock to return predictable values
39
+ uuidCounter = 0;
40
+ mockGenerateUUID.mockImplementation(() => {
41
+ uuidCounter++;
42
+ return `mock-uuid-${uuidCounter}`;
43
+ });
44
+ });
45
+
46
+ describe("getAnonymousId", () => {
47
+ it("generates and stores new ID when none exists", async () => {
48
+ const id = await identityStore.getAnonymousId();
49
+
50
+ expect(id).toBe("mock-uuid-1");
51
+ expect(mockStorage.setItem).toHaveBeenCalledWith(STORAGE_KEYS.ANON_ID, "mock-uuid-1");
52
+ });
53
+
54
+ it("returns existing ID from storage", async () => {
55
+ mockStorage.data.set(STORAGE_KEYS.ANON_ID, "12345678-1234-4123-8123-123456789012");
56
+
57
+ const id = await identityStore.getAnonymousId();
58
+
59
+ expect(id).toBe("12345678-1234-4123-8123-123456789012");
60
+ expect(mockStorage.setItem).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it("caches ID after first retrieval", async () => {
64
+ const id1 = await identityStore.getAnonymousId();
65
+ const id2 = await identityStore.getAnonymousId();
66
+ const id3 = await identityStore.getAnonymousId();
67
+
68
+ expect(id1).toBe(id2);
69
+ expect(id2).toBe(id3);
70
+ // getItem should only be called once (during first retrieval)
71
+ expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it("handles concurrent calls correctly", async () => {
75
+ // Call getAnonymousId multiple times concurrently
76
+ const [id1, id2, id3] = await Promise.all([
77
+ identityStore.getAnonymousId(),
78
+ identityStore.getAnonymousId(),
79
+ identityStore.getAnonymousId(),
80
+ ]);
81
+
82
+ // All should return the same ID
83
+ expect(id1).toBe(id2);
84
+ expect(id2).toBe(id3);
85
+ // Storage should only be accessed once
86
+ expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
87
+ expect(mockStorage.setItem).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it("generates new ID if stored value is invalid UUID", async () => {
91
+ mockStorage.data.set(STORAGE_KEYS.ANON_ID, "invalid-not-a-uuid");
92
+
93
+ const id = await identityStore.getAnonymousId();
94
+
95
+ // Should generate new valid UUID
96
+ expect(id).toBe("mock-uuid-1");
97
+ expect(mockStorage.setItem).toHaveBeenCalledWith(STORAGE_KEYS.ANON_ID, "mock-uuid-1");
98
+ });
99
+
100
+ it("generates fallback ID if storage throws", async () => {
101
+ mockStorage.getItem = vi.fn().mockRejectedValue(new Error("Storage error"));
102
+
103
+ const id = await identityStore.getAnonymousId();
104
+
105
+ // Should return a fallback UUID (not persisted)
106
+ expect(id).toBe("mock-uuid-1");
107
+ expect(mockStorage.setItem).not.toHaveBeenCalled();
108
+ });
109
+ });
110
+
111
+ describe("clearAnonymousId", () => {
112
+ it("clears cached and stored ID", async () => {
113
+ // First, get an ID to populate cache
114
+ await identityStore.getAnonymousId();
115
+
116
+ // Clear it
117
+ await identityStore.clearAnonymousId();
118
+
119
+ expect(mockStorage.deleteItem).toHaveBeenCalledWith(STORAGE_KEYS.ANON_ID);
120
+ });
121
+
122
+ it("generates new ID after clear", async () => {
123
+ const id1 = await identityStore.getAnonymousId();
124
+ expect(id1).toBe("mock-uuid-1");
125
+
126
+ await identityStore.clearAnonymousId();
127
+
128
+ // After clear, getting ID again should generate a new one
129
+ const id2 = await identityStore.getAnonymousId();
130
+ expect(id2).toBe("mock-uuid-2");
131
+
132
+ // IDs should be different
133
+ expect(id1).not.toBe(id2);
134
+ });
135
+ });
136
+
137
+ describe("UUID validation", () => {
138
+ const validUUIDs = [
139
+ "12345678-1234-4123-8123-123456789012",
140
+ "a1b2c3d4-e5f6-4789-abcd-ef0123456789",
141
+ "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE",
142
+ "00000000-0000-4000-8000-000000000000",
143
+ ];
144
+
145
+ const invalidUUIDs = [
146
+ "", // empty
147
+ "not-a-uuid", // random string
148
+ "12345678-1234-1234-1234-123456789012", // version 1, not 4
149
+ "12345678-1234-4123-0123-123456789012", // invalid variant (0)
150
+ "12345678-1234-4123-c123-123456789012", // invalid variant (c)
151
+ "12345678-1234-4123-8123-12345678901", // too short
152
+ "12345678-1234-4123-8123-1234567890123", // too long
153
+ "12345678_1234_4123_8123_123456789012", // wrong separator
154
+ ];
155
+
156
+ validUUIDs.forEach((uuid) => {
157
+ it(`accepts valid UUID: ${uuid}`, async () => {
158
+ mockStorage.data.set(STORAGE_KEYS.ANON_ID, uuid);
159
+ const id = await identityStore.getAnonymousId();
160
+ expect(id).toBe(uuid);
161
+ });
162
+ });
163
+
164
+ invalidUUIDs.forEach((uuid) => {
165
+ it(`rejects invalid UUID: ${uuid || "(empty)"}`, async () => {
166
+ mockStorage.data.set(STORAGE_KEYS.ANON_ID, uuid);
167
+ const id = await identityStore.getAnonymousId();
168
+ // Should generate new UUID instead of using invalid one
169
+ expect(id).not.toBe(uuid);
170
+ });
171
+ });
172
+ });
173
+ });
@@ -1,6 +1,6 @@
1
- import type { SecureStorage } from './types';
2
- import { STORAGE_KEYS } from './types';
3
- import { generateUUID } from '../utils';
1
+ import type { SecureStorage } from "./types";
2
+ import { STORAGE_KEYS } from "./types";
3
+ import { generateUUID } from "../utils";
4
4
 
5
5
  /**
6
6
  * Manages anonymous identity persistence.
@@ -82,8 +82,7 @@ export class IdentityStore {
82
82
  * Validate that a string is a valid UUID v4 format.
83
83
  */
84
84
  private isValidUUID(value: string): boolean {
85
- const uuidRegex =
86
- /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
85
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
87
86
  return uuidRegex.test(value);
88
87
  }
89
88
  }