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

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 (235) hide show
  1. package/README.md +67 -0
  2. package/app.plugin.cjs +135 -0
  3. package/app.plugin.js +1 -0
  4. package/dist/api/client.d.ts +67 -0
  5. package/dist/api/client.d.ts.map +1 -0
  6. package/dist/api/client.js +163 -0
  7. package/dist/api/client.js.map +1 -0
  8. package/dist/api/errors.d.ts +46 -0
  9. package/dist/api/errors.d.ts.map +1 -0
  10. package/dist/api/errors.js +72 -0
  11. package/dist/api/errors.js.map +1 -0
  12. package/dist/api/index.d.ts +7 -0
  13. package/dist/api/index.d.ts.map +1 -0
  14. package/dist/api/index.js +20 -0
  15. package/dist/api/index.js.map +1 -0
  16. package/dist/api/retry.d.ts +29 -0
  17. package/dist/api/retry.d.ts.map +1 -0
  18. package/dist/api/retry.js +74 -0
  19. package/dist/api/retry.js.map +1 -0
  20. package/dist/attachments/FeedbackSheet.d.ts +88 -0
  21. package/dist/attachments/FeedbackSheet.d.ts.map +1 -0
  22. package/dist/attachments/FeedbackSheet.js +250 -0
  23. package/dist/attachments/FeedbackSheet.js.map +1 -0
  24. package/dist/attachments/index.d.ts +20 -0
  25. package/dist/attachments/index.d.ts.map +1 -0
  26. package/dist/attachments/index.js +40 -0
  27. package/dist/attachments/index.js.map +1 -0
  28. package/dist/components/AttachmentGrid.d.ts +94 -0
  29. package/dist/components/AttachmentGrid.d.ts.map +1 -0
  30. package/dist/components/AttachmentGrid.js +132 -0
  31. package/dist/components/AttachmentGrid.js.map +1 -0
  32. package/dist/components/AttachmentPicker.d.ts +98 -0
  33. package/dist/components/AttachmentPicker.d.ts.map +1 -0
  34. package/dist/components/AttachmentPicker.js +297 -0
  35. package/dist/components/AttachmentPicker.js.map +1 -0
  36. package/dist/components/AttachmentPreview.d.ts +78 -0
  37. package/dist/components/AttachmentPreview.d.ts.map +1 -0
  38. package/dist/components/AttachmentPreview.js +133 -0
  39. package/dist/components/AttachmentPreview.js.map +1 -0
  40. package/dist/components/CategorySelector.d.ts +77 -0
  41. package/dist/components/CategorySelector.d.ts.map +1 -0
  42. package/dist/components/CategorySelector.js +117 -0
  43. package/dist/components/CategorySelector.js.map +1 -0
  44. package/dist/components/FeedbackForm.d.ts +50 -0
  45. package/dist/components/FeedbackForm.d.ts.map +1 -0
  46. package/dist/components/FeedbackForm.js +141 -0
  47. package/dist/components/FeedbackForm.js.map +1 -0
  48. package/dist/components/FeedbackSheet.d.ts +75 -0
  49. package/dist/components/FeedbackSheet.d.ts.map +1 -0
  50. package/dist/components/FeedbackSheet.js +215 -0
  51. package/dist/components/FeedbackSheet.js.map +1 -0
  52. package/dist/components/ThemedButton.d.ts +23 -0
  53. package/dist/components/ThemedButton.d.ts.map +1 -0
  54. package/dist/components/ThemedButton.js +77 -0
  55. package/dist/components/ThemedButton.js.map +1 -0
  56. package/dist/components/ThemedText.d.ts +16 -0
  57. package/dist/components/ThemedText.d.ts.map +1 -0
  58. package/dist/components/ThemedText.js +44 -0
  59. package/dist/components/ThemedText.js.map +1 -0
  60. package/dist/components/ThemedTextInput.d.ts +13 -0
  61. package/dist/components/ThemedTextInput.d.ts.map +1 -0
  62. package/dist/components/ThemedTextInput.js +76 -0
  63. package/dist/components/ThemedTextInput.js.map +1 -0
  64. package/dist/components/UploadStatusOverlay.d.ts +82 -0
  65. package/dist/components/UploadStatusOverlay.d.ts.map +1 -0
  66. package/dist/components/UploadStatusOverlay.js +319 -0
  67. package/dist/components/UploadStatusOverlay.js.map +1 -0
  68. package/dist/components/index.d.ts +19 -0
  69. package/dist/components/index.d.ts.map +1 -0
  70. package/dist/components/index.js +28 -0
  71. package/dist/components/index.js.map +1 -0
  72. package/dist/context/HarkenContext.d.ts +62 -0
  73. package/dist/context/HarkenContext.d.ts.map +1 -0
  74. package/dist/context/HarkenContext.js +128 -0
  75. package/dist/context/HarkenContext.js.map +1 -0
  76. package/dist/context/index.d.ts +3 -0
  77. package/dist/context/index.d.ts.map +1 -0
  78. package/dist/context/index.js +7 -0
  79. package/dist/context/index.js.map +1 -0
  80. package/dist/domain/index.d.ts +3 -0
  81. package/dist/domain/index.d.ts.map +1 -0
  82. package/dist/domain/index.js +7 -0
  83. package/dist/domain/index.js.map +1 -0
  84. package/dist/domain/upload-queue.d.ts +116 -0
  85. package/dist/domain/upload-queue.d.ts.map +1 -0
  86. package/dist/domain/upload-queue.js +34 -0
  87. package/dist/domain/upload-queue.js.map +1 -0
  88. package/dist/hooks/index.d.ts +6 -0
  89. package/dist/hooks/index.d.ts.map +1 -0
  90. package/dist/hooks/index.js +16 -0
  91. package/dist/hooks/index.js.map +1 -0
  92. package/dist/hooks/useAnonymousId.d.ts +28 -0
  93. package/dist/hooks/useAnonymousId.d.ts.map +1 -0
  94. package/dist/hooks/useAnonymousId.js +59 -0
  95. package/dist/hooks/useAnonymousId.js.map +1 -0
  96. package/dist/hooks/useAttachmentPicker.d.ts +84 -0
  97. package/dist/hooks/useAttachmentPicker.d.ts.map +1 -0
  98. package/dist/hooks/useAttachmentPicker.js +181 -0
  99. package/dist/hooks/useAttachmentPicker.js.map +1 -0
  100. package/dist/hooks/useAttachmentStatus.d.ts +51 -0
  101. package/dist/hooks/useAttachmentStatus.d.ts.map +1 -0
  102. package/dist/hooks/useAttachmentStatus.js +69 -0
  103. package/dist/hooks/useAttachmentStatus.js.map +1 -0
  104. package/dist/hooks/useAttachmentUpload.d.ts +101 -0
  105. package/dist/hooks/useAttachmentUpload.d.ts.map +1 -0
  106. package/dist/hooks/useAttachmentUpload.js +293 -0
  107. package/dist/hooks/useAttachmentUpload.js.map +1 -0
  108. package/dist/hooks/useFeedback.d.ts +55 -0
  109. package/dist/hooks/useFeedback.d.ts.map +1 -0
  110. package/dist/hooks/useFeedback.js +96 -0
  111. package/dist/hooks/useFeedback.js.map +1 -0
  112. package/dist/hooks/useHarkenContext.d.ts +25 -0
  113. package/dist/hooks/useHarkenContext.d.ts.map +1 -0
  114. package/dist/hooks/useHarkenContext.js +35 -0
  115. package/dist/hooks/useHarkenContext.js.map +1 -0
  116. package/dist/hooks/useHarkenTheme.d.ts +26 -0
  117. package/dist/hooks/useHarkenTheme.d.ts.map +1 -0
  118. package/dist/hooks/useHarkenTheme.js +36 -0
  119. package/dist/hooks/useHarkenTheme.js.map +1 -0
  120. package/dist/index.d.ts +49 -0
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +91 -0
  123. package/dist/index.js.map +1 -0
  124. package/dist/services/index.d.ts +4 -0
  125. package/dist/services/index.d.ts.map +1 -0
  126. package/dist/services/index.js +9 -0
  127. package/dist/services/index.js.map +1 -0
  128. package/dist/services/uploadQueueService.d.ts +193 -0
  129. package/dist/services/uploadQueueService.d.ts.map +1 -0
  130. package/dist/services/uploadQueueService.js +623 -0
  131. package/dist/services/uploadQueueService.js.map +1 -0
  132. package/dist/services/uploadQueueStorage.d.ts +30 -0
  133. package/dist/services/uploadQueueStorage.d.ts.map +1 -0
  134. package/dist/services/uploadQueueStorage.js +77 -0
  135. package/dist/services/uploadQueueStorage.js.map +1 -0
  136. package/dist/storage/IdentityStore.d.ts +38 -0
  137. package/dist/storage/IdentityStore.d.ts.map +1 -0
  138. package/dist/storage/IdentityStore.js +83 -0
  139. package/dist/storage/IdentityStore.js.map +1 -0
  140. package/dist/storage/SecureStoreAdapter.d.ts +28 -0
  141. package/dist/storage/SecureStoreAdapter.d.ts.map +1 -0
  142. package/dist/storage/SecureStoreAdapter.js +52 -0
  143. package/dist/storage/SecureStoreAdapter.js.map +1 -0
  144. package/dist/storage/defaultStorage.d.ts +20 -0
  145. package/dist/storage/defaultStorage.d.ts.map +1 -0
  146. package/dist/storage/defaultStorage.js +131 -0
  147. package/dist/storage/defaultStorage.js.map +1 -0
  148. package/dist/storage/index.d.ts +6 -0
  149. package/dist/storage/index.d.ts.map +1 -0
  150. package/dist/storage/index.js +13 -0
  151. package/dist/storage/index.js.map +1 -0
  152. package/dist/storage/types.d.ts +32 -0
  153. package/dist/storage/types.d.ts.map +1 -0
  154. package/dist/storage/types.js +11 -0
  155. package/dist/storage/types.js.map +1 -0
  156. package/dist/theme/defaults.d.ts +43 -0
  157. package/dist/theme/defaults.d.ts.map +1 -0
  158. package/dist/theme/defaults.js +128 -0
  159. package/dist/theme/defaults.js.map +1 -0
  160. package/dist/theme/index.d.ts +3 -0
  161. package/dist/theme/index.d.ts.map +1 -0
  162. package/dist/theme/index.js +14 -0
  163. package/dist/theme/index.js.map +1 -0
  164. package/dist/theme/types.d.ts +136 -0
  165. package/dist/theme/types.d.ts.map +1 -0
  166. package/dist/theme/types.js +3 -0
  167. package/dist/theme/types.js.map +1 -0
  168. package/dist/types/config.d.ts +100 -0
  169. package/dist/types/config.d.ts.map +1 -0
  170. package/dist/types/config.js +3 -0
  171. package/dist/types/config.js.map +1 -0
  172. package/dist/types/index.d.ts +3 -0
  173. package/dist/types/index.d.ts.map +1 -0
  174. package/dist/types/index.js +3 -0
  175. package/dist/types/index.js.map +1 -0
  176. package/dist/types/openapi.d.ts +601 -0
  177. package/dist/types/openapi.d.ts.map +1 -0
  178. package/dist/types/openapi.js +7 -0
  179. package/dist/types/openapi.js.map +1 -0
  180. package/dist/utils/index.d.ts +2 -0
  181. package/dist/utils/index.d.ts.map +1 -0
  182. package/dist/utils/index.js +6 -0
  183. package/dist/utils/index.js.map +1 -0
  184. package/dist/utils/uuid.d.ts +10 -0
  185. package/dist/utils/uuid.d.ts.map +1 -0
  186. package/dist/utils/uuid.js +60 -0
  187. package/dist/utils/uuid.js.map +1 -0
  188. package/package.json +124 -0
  189. package/src/@types/expo-file-system-legacy.d.ts +13 -0
  190. package/src/api/client.ts +250 -0
  191. package/src/api/errors.ts +84 -0
  192. package/src/api/index.ts +15 -0
  193. package/src/api/retry.ts +99 -0
  194. package/src/attachments/FeedbackSheet.tsx +400 -0
  195. package/src/attachments/index.ts +70 -0
  196. package/src/components/AttachmentGrid.tsx +247 -0
  197. package/src/components/AttachmentPicker.tsx +391 -0
  198. package/src/components/AttachmentPreview.tsx +210 -0
  199. package/src/components/CategorySelector.tsx +174 -0
  200. package/src/components/FeedbackForm.tsx +216 -0
  201. package/src/components/FeedbackSheet.tsx +321 -0
  202. package/src/components/ThemedButton.tsx +127 -0
  203. package/src/components/ThemedText.tsx +65 -0
  204. package/src/components/ThemedTextInput.tsx +65 -0
  205. package/src/components/UploadStatusOverlay.tsx +440 -0
  206. package/src/components/index.ts +39 -0
  207. package/src/context/HarkenContext.tsx +129 -0
  208. package/src/context/index.ts +2 -0
  209. package/src/domain/index.ts +12 -0
  210. package/src/domain/upload-queue.ts +131 -0
  211. package/src/hooks/index.ts +10 -0
  212. package/src/hooks/useAnonymousId.ts +68 -0
  213. package/src/hooks/useAttachmentPicker.ts +243 -0
  214. package/src/hooks/useAttachmentStatus.ts +86 -0
  215. package/src/hooks/useAttachmentUpload.ts +370 -0
  216. package/src/hooks/useFeedback.ts +139 -0
  217. package/src/hooks/useHarkenContext.ts +35 -0
  218. package/src/hooks/useHarkenTheme.ts +36 -0
  219. package/src/index.ts +168 -0
  220. package/src/services/index.ts +11 -0
  221. package/src/services/uploadQueueService.ts +727 -0
  222. package/src/services/uploadQueueStorage.ts +78 -0
  223. package/src/storage/IdentityStore.ts +89 -0
  224. package/src/storage/SecureStoreAdapter.ts +59 -0
  225. package/src/storage/defaultStorage.ts +109 -0
  226. package/src/storage/index.ts +5 -0
  227. package/src/storage/types.ts +34 -0
  228. package/src/theme/defaults.ts +151 -0
  229. package/src/theme/index.ts +23 -0
  230. package/src/theme/types.ts +157 -0
  231. package/src/types/config.ts +112 -0
  232. package/src/types/index.ts +10 -0
  233. package/src/types/openapi.ts +601 -0
  234. package/src/utils/index.ts +1 -0
  235. package/src/utils/uuid.ts +77 -0
@@ -0,0 +1,727 @@
1
+ /**
2
+ * Upload Queue Service
3
+ *
4
+ * Singleton service that manages background attachment uploads.
5
+ * Uses expo-file-system/legacy for background upload support.
6
+ *
7
+ * Key design decisions (from feature spec):
8
+ * - D1: Uses expo-file-system/legacy with BACKGROUND session type
9
+ * - D2: Initialized once at app startup in HarkenProvider
10
+ * - D3: Persists queue to AsyncStorage
11
+ * - D4: Exponential backoff (2s base, 60s max, 3 attempts, 1s jitter)
12
+ * - D5: Auto-pause when offline via NetInfo
13
+ * - D6: Real progress tracking from expo-file-system
14
+ */
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';
32
+
33
+ // Callback types for event subscriptions
34
+ type ProgressCallback = (progress: UploadProgress) => void;
35
+ type CompleteCallback = (attachmentId: string) => void;
36
+ type ErrorCallback = (attachmentId: string, error: string) => void;
37
+
38
+ /**
39
+ * Configuration for initializing the upload queue service.
40
+ */
41
+ export interface UploadQueueServiceConfig {
42
+ /** Configured HarkenClient instance */
43
+ client: HarkenClient;
44
+ /** Override retry configuration */
45
+ retryConfig?: Partial<UploadRetryConfig>;
46
+ /** Enable debug logging */
47
+ debug?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Parameters for enqueuing a new upload.
52
+ */
53
+ export interface EnqueueParams {
54
+ /** Local file URI (file://) */
55
+ localUri: string;
56
+ /** MIME type (e.g., 'image/png') */
57
+ mimeType: string;
58
+ /** Original filename */
59
+ fileName: string;
60
+ /** File size in bytes */
61
+ fileSize: number;
62
+ }
63
+
64
+ /**
65
+ * Singleton service for managing background attachment uploads.
66
+ */
67
+ export class UploadQueueService {
68
+ private static instance: UploadQueueService | null = null;
69
+
70
+ private client: HarkenClient | null = null;
71
+ private retryConfig: UploadRetryConfig = DEFAULT_UPLOAD_RETRY_CONFIG;
72
+ private storage = new UploadQueueStorage();
73
+ private debug = false;
74
+
75
+ // Queue state
76
+ private items: Map<string, QueueItem> = new Map();
77
+ private isProcessing = false;
78
+ private isPaused = false;
79
+ private isInitialized = false;
80
+
81
+ // Active upload tasks (for cancellation)
82
+ private activeTasks: Map<string, FileSystem.UploadTask> = new Map();
83
+
84
+ // Event subscribers
85
+ private progressListeners: Set<ProgressCallback> = new Set();
86
+ private completeListeners: Set<CompleteCallback> = new Set();
87
+ private errorListeners: Set<ErrorCallback> = new Set();
88
+
89
+ // Network monitoring
90
+ private networkUnsubscribe: NetInfoSubscription | null = null;
91
+
92
+ // Retry timer - schedules wake-up when next retry is due
93
+ private retryTimerId: ReturnType<typeof setTimeout> | null = null;
94
+
95
+ private constructor() {}
96
+
97
+ /**
98
+ * Get the singleton instance.
99
+ */
100
+ static getInstance(): UploadQueueService {
101
+ if (!UploadQueueService.instance) {
102
+ UploadQueueService.instance = new UploadQueueService();
103
+ }
104
+ return UploadQueueService.instance;
105
+ }
106
+
107
+ /**
108
+ * Initialize the queue service.
109
+ *
110
+ * Must be called once at app startup (in HarkenProvider).
111
+ * This prevents the race condition where uploads complete
112
+ * before callbacks are registered.
113
+ *
114
+ * Supports hot reload by updating the client reference on subsequent calls.
115
+ */
116
+ async initialize(config: UploadQueueServiceConfig): Promise<void> {
117
+ // Always update client reference to support hot reload
118
+ // (React creates new client instance on re-render)
119
+ this.client = config.client;
120
+ this.debug = config.debug ?? false;
121
+
122
+ if (this.isInitialized) {
123
+ this.log('Already initialized, updated client reference');
124
+ return;
125
+ }
126
+
127
+ this.retryConfig = { ...DEFAULT_UPLOAD_RETRY_CONFIG, ...config.retryConfig };
128
+
129
+ // Load persisted queue
130
+ const persistedItems = await this.storage.loadQueue();
131
+ for (const item of persistedItems) {
132
+ // Reset any "uploading" or "confirming" items to "queued"
133
+ // (app was killed mid-upload)
134
+ if (
135
+ item.phase === UploadPhase.UPLOADING ||
136
+ item.phase === UploadPhase.CONFIRMING
137
+ ) {
138
+ item.phase = UploadPhase.QUEUED;
139
+ item.progress = 0;
140
+ }
141
+ this.items.set(item.id, item);
142
+ }
143
+
144
+ // Setup network monitoring
145
+ this.setupNetworkMonitoring();
146
+
147
+ this.isInitialized = true;
148
+ this.log(`Initialized with ${this.items.size} queued items`);
149
+
150
+ // Start processing if we have items
151
+ if (this.items.size > 0) {
152
+ void this.processQueue();
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Check if the service is initialized.
158
+ */
159
+ get initialized(): boolean {
160
+ return this.isInitialized;
161
+ }
162
+
163
+ /**
164
+ * Enqueue a new attachment for upload.
165
+ *
166
+ * This method:
167
+ * 1. Requests a presigned URL from the server
168
+ * 2. Creates a queue item
169
+ * 3. Persists the queue
170
+ * 4. Triggers queue processing
171
+ *
172
+ * Returns immediately with the attachment ID (upload happens in background).
173
+ */
174
+ async enqueue(params: EnqueueParams): Promise<{
175
+ attachmentId: string;
176
+ queueItemId: string;
177
+ }> {
178
+ if (!this.client) {
179
+ throw new Error('UploadQueueService not initialized');
180
+ }
181
+
182
+ // 1. Get presigned URL from server
183
+ const presignResponse = await this.client.createAttachmentUpload({
184
+ filename: params.fileName,
185
+ content_type: params.mimeType,
186
+ size: params.fileSize,
187
+ });
188
+
189
+ // 2. Create queue item
190
+ const queueItem: QueueItem = {
191
+ id: generateUUID(),
192
+ attachmentId: presignResponse.attachment_id,
193
+ localUri: params.localUri,
194
+ uploadUrl: presignResponse.upload_url,
195
+ uploadExpiresAt: presignResponse.upload_expires_at,
196
+ mimeType: params.mimeType,
197
+ fileName: params.fileName,
198
+ fileSize: params.fileSize,
199
+ phase: UploadPhase.QUEUED,
200
+ progress: 0,
201
+ attemptNumber: 0,
202
+ maxAttempts: this.retryConfig.maxAttempts,
203
+ createdAt: new Date().toISOString(),
204
+ };
205
+
206
+ // 3. Add to queue and persist
207
+ this.items.set(queueItem.id, queueItem);
208
+ await this.persistQueue();
209
+
210
+ // 4. Emit initial progress
211
+ this.emitProgress(queueItem);
212
+
213
+ // 5. Trigger queue processing
214
+ void this.processQueue();
215
+
216
+ this.log(`Enqueued ${presignResponse.attachment_id}`);
217
+
218
+ return {
219
+ attachmentId: presignResponse.attachment_id,
220
+ queueItemId: queueItem.id,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Process the upload queue.
226
+ *
227
+ * Processes items sequentially (one at a time) to avoid memory pressure
228
+ * on low-end devices.
229
+ */
230
+ async processQueue(): Promise<void> {
231
+ if (this.isProcessing || this.isPaused) {
232
+ return;
233
+ }
234
+
235
+ // Clear any pending retry timer since we're processing now
236
+ this.clearRetryTimer();
237
+
238
+ this.isProcessing = true;
239
+ this.log('Processing queue...');
240
+
241
+ try {
242
+ while (true) {
243
+ // Find next item to process
244
+ const item = this.getNextQueuedItem();
245
+ if (!item) {
246
+ this.log('No more items to process');
247
+ break;
248
+ }
249
+
250
+ if (this.isPaused) {
251
+ this.log('Queue paused, stopping processing');
252
+ break;
253
+ }
254
+
255
+ await this.processItem(item);
256
+ }
257
+ } finally {
258
+ this.isProcessing = false;
259
+ // Schedule wake-up for any pending retries
260
+ this.scheduleNextRetry();
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Process a single queue item.
266
+ */
267
+ private async processItem(item: QueueItem): Promise<void> {
268
+ this.log(`Processing item ${item.id} (attachment: ${item.attachmentId})`);
269
+
270
+ // Check if URL has expired
271
+ if (new Date(item.uploadExpiresAt) < new Date()) {
272
+ this.log(`URL expired for ${item.attachmentId}`);
273
+ await this.handleItemFailure(item, 'Upload URL expired');
274
+ return;
275
+ }
276
+
277
+ // Check if we need to wait for retry delay
278
+ if (item.scheduledRetryAt) {
279
+ const waitUntil = new Date(item.scheduledRetryAt);
280
+ if (waitUntil > new Date()) {
281
+ const waitMs = waitUntil.getTime() - Date.now();
282
+ this.log(`Waiting ${waitMs}ms before retry for ${item.attachmentId}`);
283
+ await this.sleep(waitMs);
284
+ }
285
+ }
286
+
287
+ // Update state to uploading
288
+ item.phase = UploadPhase.UPLOADING;
289
+ item.startedAt = new Date().toISOString();
290
+ item.attemptNumber += 1;
291
+ await this.persistQueue();
292
+ this.emitProgress(item);
293
+
294
+ try {
295
+ // Perform the upload
296
+ await this.uploadFile(item);
297
+
298
+ // Confirm with server
299
+ item.phase = UploadPhase.CONFIRMING;
300
+ await this.persistQueue();
301
+ this.emitProgress(item);
302
+
303
+ await this.client!.confirmAttachment(item.attachmentId, {
304
+ bytes_uploaded: item.fileSize,
305
+ });
306
+
307
+ // Success!
308
+ item.phase = UploadPhase.COMPLETED;
309
+ item.completedAt = new Date().toISOString();
310
+ item.progress = 1;
311
+ await this.persistQueue();
312
+ this.emitProgress(item);
313
+ this.emitComplete(item.attachmentId);
314
+
315
+ this.log(`Completed upload for ${item.attachmentId}`);
316
+ } catch (error) {
317
+ const errorMessage =
318
+ error instanceof Error ? error.message : 'Unknown error';
319
+ this.log(`Upload failed for ${item.attachmentId}: ${errorMessage}`);
320
+
321
+ if (item.attemptNumber < item.maxAttempts) {
322
+ // Schedule retry with exponential backoff
323
+ const delay = this.calculateRetryDelay(item.attemptNumber);
324
+ item.phase = UploadPhase.QUEUED;
325
+ item.progress = 0;
326
+ item.lastError = errorMessage;
327
+ item.scheduledRetryAt = new Date(Date.now() + delay).toISOString();
328
+ await this.persistQueue();
329
+ this.emitProgress(item);
330
+ this.log(`Scheduled retry in ${delay}ms for ${item.attachmentId}`);
331
+ } else {
332
+ await this.handleItemFailure(item, errorMessage);
333
+ }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Upload file using expo-file-system background upload.
339
+ *
340
+ * Uses FileSystemSessionType.BACKGROUND for true background uploads
341
+ * that continue even when the app is backgrounded.
342
+ */
343
+ private async uploadFile(item: QueueItem): Promise<void> {
344
+ return new Promise((resolve, reject) => {
345
+ const uploadTask = FileSystem.createUploadTask(
346
+ item.uploadUrl,
347
+ item.localUri,
348
+ {
349
+ httpMethod: 'PUT',
350
+ uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
351
+ sessionType: FileSystem.FileSystemSessionType.BACKGROUND,
352
+ headers: {
353
+ 'Content-Type': item.mimeType,
354
+ },
355
+ },
356
+ (progress) => {
357
+ // Real progress from expo-file-system (D6)
358
+ // Guard against NaN if totalBytesExpectedToSend is 0
359
+ const percent =
360
+ progress.totalBytesExpectedToSend > 0
361
+ ? progress.totalBytesSent / progress.totalBytesExpectedToSend
362
+ : 0;
363
+ item.progress = Math.min(1, Math.max(0, percent)); // Clamp to 0-1
364
+ this.emitProgress(item);
365
+ }
366
+ );
367
+
368
+ this.activeTasks.set(item.id, uploadTask);
369
+
370
+ uploadTask
371
+ .uploadAsync()
372
+ .then((result) => {
373
+ this.activeTasks.delete(item.id);
374
+
375
+ if (result && result.status >= 200 && result.status < 300) {
376
+ resolve();
377
+ } else {
378
+ reject(
379
+ new Error(`Upload failed with status ${result?.status ?? 'unknown'}`)
380
+ );
381
+ }
382
+ })
383
+ .catch((error) => {
384
+ this.activeTasks.delete(item.id);
385
+ reject(error);
386
+ });
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Handle permanent item failure (max retries exceeded).
392
+ */
393
+ private async handleItemFailure(item: QueueItem, error: string): Promise<void> {
394
+ item.phase = UploadPhase.FAILED;
395
+ item.lastError = error;
396
+ item.completedAt = new Date().toISOString();
397
+ await this.persistQueue();
398
+ this.emitProgress(item);
399
+ this.emitError(item.attachmentId, error);
400
+
401
+ // Report to server (fire and forget)
402
+ try {
403
+ await this.client!.reportAttachmentFailure(item.attachmentId, error);
404
+ } catch {
405
+ this.log(`Failed to report failure for ${item.attachmentId}`);
406
+ }
407
+
408
+ this.log(`Item ${item.attachmentId} failed: ${error}`);
409
+ }
410
+
411
+ /**
412
+ * Calculate retry delay with exponential backoff and jitter (D4).
413
+ */
414
+ private calculateRetryDelay(attemptNumber: number): number {
415
+ const { baseDelayMs, maxDelayMs, jitterMs } = this.retryConfig;
416
+
417
+ // Exponential: 2s, 4s, 8s, 16s, ...
418
+ const exponential = baseDelayMs * Math.pow(2, attemptNumber - 1);
419
+ const capped = Math.min(exponential, maxDelayMs);
420
+
421
+ // Add random jitter (-jitterMs to +jitterMs)
422
+ const jitter = (Math.random() - 0.5) * 2 * jitterMs;
423
+
424
+ return Math.max(0, capped + jitter);
425
+ }
426
+
427
+ /**
428
+ * Get next item that's ready to process.
429
+ */
430
+ private getNextQueuedItem(): QueueItem | undefined {
431
+ const now = new Date();
432
+
433
+ for (const item of this.items.values()) {
434
+ if (item.phase !== UploadPhase.QUEUED) continue;
435
+
436
+ // Check if past scheduled retry time
437
+ if (item.scheduledRetryAt && new Date(item.scheduledRetryAt) > now) {
438
+ continue;
439
+ }
440
+
441
+ return item;
442
+ }
443
+
444
+ return undefined;
445
+ }
446
+
447
+ // --- Retry Timer ---
448
+
449
+ /**
450
+ * Schedule a timer to wake up processQueue when the next retry is due.
451
+ * This prevents retries from stalling indefinitely.
452
+ */
453
+ private scheduleNextRetry(): void {
454
+ // Don't schedule if paused
455
+ if (this.isPaused) return;
456
+
457
+ // Find the earliest scheduledRetryAt among queued items
458
+ let earliestRetry: Date | null = null;
459
+
460
+ for (const item of this.items.values()) {
461
+ if (item.phase !== UploadPhase.QUEUED) continue;
462
+ if (!item.scheduledRetryAt) continue;
463
+
464
+ const retryTime = new Date(item.scheduledRetryAt);
465
+ if (!earliestRetry || retryTime < earliestRetry) {
466
+ earliestRetry = retryTime;
467
+ }
468
+ }
469
+
470
+ if (!earliestRetry) return;
471
+
472
+ const now = Date.now();
473
+ const delay = Math.max(0, earliestRetry.getTime() - now);
474
+
475
+ this.log(`Scheduling retry timer for ${delay}ms`);
476
+
477
+ this.retryTimerId = setTimeout(() => {
478
+ this.retryTimerId = null;
479
+ void this.processQueue();
480
+ }, delay);
481
+ }
482
+
483
+ /**
484
+ * Clear any pending retry timer.
485
+ */
486
+ private clearRetryTimer(): void {
487
+ if (this.retryTimerId) {
488
+ clearTimeout(this.retryTimerId);
489
+ this.retryTimerId = null;
490
+ }
491
+ }
492
+
493
+ // --- Network Monitoring (D5) ---
494
+
495
+ 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
+ }
513
+ }
514
+ this.activeTasks.clear();
515
+ void this.persistQueue();
516
+ }
517
+ }
518
+ );
519
+ }
520
+
521
+ // --- Event Emitters ---
522
+
523
+ private emitProgress(item: QueueItem): void {
524
+ const progress: UploadProgress = {
525
+ attachmentId: item.attachmentId,
526
+ phase: item.phase,
527
+ progress: item.progress,
528
+ error: item.lastError,
529
+ };
530
+ this.progressListeners.forEach((cb) => cb(progress));
531
+ }
532
+
533
+ private emitComplete(attachmentId: string): void {
534
+ this.completeListeners.forEach((cb) => cb(attachmentId));
535
+ }
536
+
537
+ private emitError(attachmentId: string, error: string): void {
538
+ this.errorListeners.forEach((cb) => cb(attachmentId, error));
539
+ }
540
+
541
+ // --- Public Event Subscriptions ---
542
+
543
+ /**
544
+ * Subscribe to progress updates.
545
+ * @returns Unsubscribe function
546
+ */
547
+ onProgress(callback: ProgressCallback): () => void {
548
+ this.progressListeners.add(callback);
549
+ return () => this.progressListeners.delete(callback);
550
+ }
551
+
552
+ /**
553
+ * Subscribe to upload completions.
554
+ * @returns Unsubscribe function
555
+ */
556
+ onComplete(callback: CompleteCallback): () => void {
557
+ this.completeListeners.add(callback);
558
+ return () => this.completeListeners.delete(callback);
559
+ }
560
+
561
+ /**
562
+ * Subscribe to upload errors.
563
+ * @returns Unsubscribe function
564
+ */
565
+ onError(callback: ErrorCallback): () => void {
566
+ this.errorListeners.add(callback);
567
+ return () => this.errorListeners.delete(callback);
568
+ }
569
+
570
+ // --- Public API ---
571
+
572
+ /**
573
+ * Get current queue status.
574
+ */
575
+ getQueueStatus(): QueueStatus {
576
+ let queued = 0;
577
+ let uploading = 0;
578
+ let completed = 0;
579
+ let failed = 0;
580
+
581
+ for (const item of this.items.values()) {
582
+ switch (item.phase) {
583
+ case UploadPhase.QUEUED:
584
+ queued++;
585
+ break;
586
+ case UploadPhase.UPLOADING:
587
+ case UploadPhase.CONFIRMING:
588
+ uploading++;
589
+ break;
590
+ case UploadPhase.COMPLETED:
591
+ completed++;
592
+ break;
593
+ case UploadPhase.FAILED:
594
+ failed++;
595
+ break;
596
+ }
597
+ }
598
+
599
+ return {
600
+ total: this.items.size,
601
+ queued,
602
+ uploading,
603
+ completed,
604
+ failed,
605
+ isPaused: this.isPaused,
606
+ };
607
+ }
608
+
609
+ /**
610
+ * Get a queue item by attachment ID.
611
+ */
612
+ getItemByAttachmentId(attachmentId: string): QueueItem | undefined {
613
+ for (const item of this.items.values()) {
614
+ if (item.attachmentId === attachmentId) return item;
615
+ }
616
+ return undefined;
617
+ }
618
+
619
+ /**
620
+ * Retry a failed upload.
621
+ */
622
+ async retryItem(attachmentId: string): Promise<void> {
623
+ const item = this.getItemByAttachmentId(attachmentId);
624
+ if (!item || item.phase !== UploadPhase.FAILED) {
625
+ throw new Error('Item not found or not in failed state');
626
+ }
627
+
628
+ item.phase = UploadPhase.QUEUED;
629
+ item.progress = 0;
630
+ item.attemptNumber = 0;
631
+ item.lastError = undefined;
632
+ item.scheduledRetryAt = undefined;
633
+
634
+ await this.persistQueue();
635
+ void this.processQueue();
636
+
637
+ this.log(`Retrying ${attachmentId}`);
638
+ }
639
+
640
+ /**
641
+ * Cancel and remove an item from the queue.
642
+ */
643
+ async cancelItem(attachmentId: string): Promise<void> {
644
+ for (const [id, item] of this.items) {
645
+ if (item.attachmentId === attachmentId) {
646
+ // Cancel active upload if any
647
+ const task = this.activeTasks.get(id);
648
+ if (task) {
649
+ await task.cancelAsync();
650
+ this.activeTasks.delete(id);
651
+ }
652
+
653
+ this.items.delete(id);
654
+ await this.persistQueue();
655
+
656
+ this.log(`Cancelled ${attachmentId}`);
657
+ return;
658
+ }
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Clear all completed items from the queue.
664
+ */
665
+ async clearCompleted(): Promise<void> {
666
+ for (const [id, item] of this.items) {
667
+ if (item.phase === UploadPhase.COMPLETED) {
668
+ this.items.delete(id);
669
+ }
670
+ }
671
+ await this.persistQueue();
672
+ this.log('Cleared completed items');
673
+ }
674
+
675
+ /**
676
+ * Clear all failed items from the queue.
677
+ */
678
+ async clearFailed(): Promise<void> {
679
+ for (const [id, item] of this.items) {
680
+ if (item.phase === UploadPhase.FAILED) {
681
+ this.items.delete(id);
682
+ }
683
+ }
684
+ await this.persistQueue();
685
+ this.log('Cleared failed items');
686
+ }
687
+
688
+ // --- Internal Utilities ---
689
+
690
+ private async persistQueue(): Promise<void> {
691
+ await this.storage.saveQueue(Array.from(this.items.values()));
692
+ }
693
+
694
+ private sleep(ms: number): Promise<void> {
695
+ return new Promise((resolve) => setTimeout(resolve, ms));
696
+ }
697
+
698
+ private log(message: string): void {
699
+ if (this.debug) {
700
+ console.log(`[UploadQueue] ${message}`);
701
+ }
702
+ }
703
+
704
+ // --- Cleanup ---
705
+
706
+ /**
707
+ * Destroy the service instance.
708
+ * Used primarily for testing.
709
+ */
710
+ destroy(): void {
711
+ this.clearRetryTimer();
712
+ this.networkUnsubscribe?.();
713
+ this.activeTasks.forEach((task) => void task.cancelAsync());
714
+ this.activeTasks.clear();
715
+ this.progressListeners.clear();
716
+ this.completeListeners.clear();
717
+ this.errorListeners.clear();
718
+ this.items.clear();
719
+ this.isInitialized = false;
720
+ UploadQueueService.instance = null;
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Singleton instance getter.
726
+ */
727
+ export const uploadQueueService = UploadQueueService.getInstance();