@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.
- package/README.md +67 -0
- package/app.plugin.cjs +135 -0
- package/app.plugin.js +1 -0
- package/dist/api/client.d.ts +67 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +163 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/errors.d.ts +46 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +72 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/index.d.ts +7 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +20 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/retry.d.ts +29 -0
- package/dist/api/retry.d.ts.map +1 -0
- package/dist/api/retry.js +74 -0
- package/dist/api/retry.js.map +1 -0
- package/dist/attachments/FeedbackSheet.d.ts +88 -0
- package/dist/attachments/FeedbackSheet.d.ts.map +1 -0
- package/dist/attachments/FeedbackSheet.js +250 -0
- package/dist/attachments/FeedbackSheet.js.map +1 -0
- package/dist/attachments/index.d.ts +20 -0
- package/dist/attachments/index.d.ts.map +1 -0
- package/dist/attachments/index.js +40 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/components/AttachmentGrid.d.ts +94 -0
- package/dist/components/AttachmentGrid.d.ts.map +1 -0
- package/dist/components/AttachmentGrid.js +132 -0
- package/dist/components/AttachmentGrid.js.map +1 -0
- package/dist/components/AttachmentPicker.d.ts +98 -0
- package/dist/components/AttachmentPicker.d.ts.map +1 -0
- package/dist/components/AttachmentPicker.js +297 -0
- package/dist/components/AttachmentPicker.js.map +1 -0
- package/dist/components/AttachmentPreview.d.ts +78 -0
- package/dist/components/AttachmentPreview.d.ts.map +1 -0
- package/dist/components/AttachmentPreview.js +133 -0
- package/dist/components/AttachmentPreview.js.map +1 -0
- package/dist/components/CategorySelector.d.ts +77 -0
- package/dist/components/CategorySelector.d.ts.map +1 -0
- package/dist/components/CategorySelector.js +117 -0
- package/dist/components/CategorySelector.js.map +1 -0
- package/dist/components/FeedbackForm.d.ts +50 -0
- package/dist/components/FeedbackForm.d.ts.map +1 -0
- package/dist/components/FeedbackForm.js +141 -0
- package/dist/components/FeedbackForm.js.map +1 -0
- package/dist/components/FeedbackSheet.d.ts +75 -0
- package/dist/components/FeedbackSheet.d.ts.map +1 -0
- package/dist/components/FeedbackSheet.js +215 -0
- package/dist/components/FeedbackSheet.js.map +1 -0
- package/dist/components/ThemedButton.d.ts +23 -0
- package/dist/components/ThemedButton.d.ts.map +1 -0
- package/dist/components/ThemedButton.js +77 -0
- package/dist/components/ThemedButton.js.map +1 -0
- package/dist/components/ThemedText.d.ts +16 -0
- package/dist/components/ThemedText.d.ts.map +1 -0
- package/dist/components/ThemedText.js +44 -0
- package/dist/components/ThemedText.js.map +1 -0
- package/dist/components/ThemedTextInput.d.ts +13 -0
- package/dist/components/ThemedTextInput.d.ts.map +1 -0
- package/dist/components/ThemedTextInput.js +76 -0
- package/dist/components/ThemedTextInput.js.map +1 -0
- package/dist/components/UploadStatusOverlay.d.ts +82 -0
- package/dist/components/UploadStatusOverlay.d.ts.map +1 -0
- package/dist/components/UploadStatusOverlay.js +319 -0
- package/dist/components/UploadStatusOverlay.js.map +1 -0
- package/dist/components/index.d.ts +19 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +28 -0
- package/dist/components/index.js.map +1 -0
- package/dist/context/HarkenContext.d.ts +62 -0
- package/dist/context/HarkenContext.d.ts.map +1 -0
- package/dist/context/HarkenContext.js +128 -0
- package/dist/context/HarkenContext.js.map +1 -0
- package/dist/context/index.d.ts +3 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +7 -0
- package/dist/context/index.js.map +1 -0
- package/dist/domain/index.d.ts +3 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +7 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/upload-queue.d.ts +116 -0
- package/dist/domain/upload-queue.d.ts.map +1 -0
- package/dist/domain/upload-queue.js +34 -0
- package/dist/domain/upload-queue.js.map +1 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +16 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAnonymousId.d.ts +28 -0
- package/dist/hooks/useAnonymousId.d.ts.map +1 -0
- package/dist/hooks/useAnonymousId.js +59 -0
- package/dist/hooks/useAnonymousId.js.map +1 -0
- package/dist/hooks/useAttachmentPicker.d.ts +84 -0
- package/dist/hooks/useAttachmentPicker.d.ts.map +1 -0
- package/dist/hooks/useAttachmentPicker.js +181 -0
- package/dist/hooks/useAttachmentPicker.js.map +1 -0
- package/dist/hooks/useAttachmentStatus.d.ts +51 -0
- package/dist/hooks/useAttachmentStatus.d.ts.map +1 -0
- package/dist/hooks/useAttachmentStatus.js +69 -0
- package/dist/hooks/useAttachmentStatus.js.map +1 -0
- package/dist/hooks/useAttachmentUpload.d.ts +101 -0
- package/dist/hooks/useAttachmentUpload.d.ts.map +1 -0
- package/dist/hooks/useAttachmentUpload.js +293 -0
- package/dist/hooks/useAttachmentUpload.js.map +1 -0
- package/dist/hooks/useFeedback.d.ts +55 -0
- package/dist/hooks/useFeedback.d.ts.map +1 -0
- package/dist/hooks/useFeedback.js +96 -0
- package/dist/hooks/useFeedback.js.map +1 -0
- package/dist/hooks/useHarkenContext.d.ts +25 -0
- package/dist/hooks/useHarkenContext.d.ts.map +1 -0
- package/dist/hooks/useHarkenContext.js +35 -0
- package/dist/hooks/useHarkenContext.js.map +1 -0
- package/dist/hooks/useHarkenTheme.d.ts +26 -0
- package/dist/hooks/useHarkenTheme.d.ts.map +1 -0
- package/dist/hooks/useHarkenTheme.js +36 -0
- package/dist/hooks/useHarkenTheme.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +9 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/uploadQueueService.d.ts +193 -0
- package/dist/services/uploadQueueService.d.ts.map +1 -0
- package/dist/services/uploadQueueService.js +623 -0
- package/dist/services/uploadQueueService.js.map +1 -0
- package/dist/services/uploadQueueStorage.d.ts +30 -0
- package/dist/services/uploadQueueStorage.d.ts.map +1 -0
- package/dist/services/uploadQueueStorage.js +77 -0
- package/dist/services/uploadQueueStorage.js.map +1 -0
- package/dist/storage/IdentityStore.d.ts +38 -0
- package/dist/storage/IdentityStore.d.ts.map +1 -0
- package/dist/storage/IdentityStore.js +83 -0
- package/dist/storage/IdentityStore.js.map +1 -0
- package/dist/storage/SecureStoreAdapter.d.ts +28 -0
- package/dist/storage/SecureStoreAdapter.d.ts.map +1 -0
- package/dist/storage/SecureStoreAdapter.js +52 -0
- package/dist/storage/SecureStoreAdapter.js.map +1 -0
- package/dist/storage/defaultStorage.d.ts +20 -0
- package/dist/storage/defaultStorage.d.ts.map +1 -0
- package/dist/storage/defaultStorage.js +131 -0
- package/dist/storage/defaultStorage.js.map +1 -0
- package/dist/storage/index.d.ts +6 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/types.d.ts +32 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +11 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/theme/defaults.d.ts +43 -0
- package/dist/theme/defaults.d.ts.map +1 -0
- package/dist/theme/defaults.js +128 -0
- package/dist/theme/defaults.js.map +1 -0
- package/dist/theme/index.d.ts +3 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +14 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/types.d.ts +136 -0
- package/dist/theme/types.d.ts.map +1 -0
- package/dist/theme/types.js +3 -0
- package/dist/theme/types.js.map +1 -0
- package/dist/types/config.d.ts +100 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +3 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/openapi.d.ts +601 -0
- package/dist/types/openapi.d.ts.map +1 -0
- package/dist/types/openapi.js +7 -0
- package/dist/types/openapi.js.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/uuid.d.ts +10 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/dist/utils/uuid.js +60 -0
- package/dist/utils/uuid.js.map +1 -0
- package/package.json +124 -0
- package/src/@types/expo-file-system-legacy.d.ts +13 -0
- package/src/api/client.ts +250 -0
- package/src/api/errors.ts +84 -0
- package/src/api/index.ts +15 -0
- package/src/api/retry.ts +99 -0
- package/src/attachments/FeedbackSheet.tsx +400 -0
- package/src/attachments/index.ts +70 -0
- package/src/components/AttachmentGrid.tsx +247 -0
- package/src/components/AttachmentPicker.tsx +391 -0
- package/src/components/AttachmentPreview.tsx +210 -0
- package/src/components/CategorySelector.tsx +174 -0
- package/src/components/FeedbackForm.tsx +216 -0
- package/src/components/FeedbackSheet.tsx +321 -0
- package/src/components/ThemedButton.tsx +127 -0
- package/src/components/ThemedText.tsx +65 -0
- package/src/components/ThemedTextInput.tsx +65 -0
- package/src/components/UploadStatusOverlay.tsx +440 -0
- package/src/components/index.ts +39 -0
- package/src/context/HarkenContext.tsx +129 -0
- package/src/context/index.ts +2 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/upload-queue.ts +131 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useAnonymousId.ts +68 -0
- package/src/hooks/useAttachmentPicker.ts +243 -0
- package/src/hooks/useAttachmentStatus.ts +86 -0
- package/src/hooks/useAttachmentUpload.ts +370 -0
- package/src/hooks/useFeedback.ts +139 -0
- package/src/hooks/useHarkenContext.ts +35 -0
- package/src/hooks/useHarkenTheme.ts +36 -0
- package/src/index.ts +168 -0
- package/src/services/index.ts +11 -0
- package/src/services/uploadQueueService.ts +727 -0
- package/src/services/uploadQueueStorage.ts +78 -0
- package/src/storage/IdentityStore.ts +89 -0
- package/src/storage/SecureStoreAdapter.ts +59 -0
- package/src/storage/defaultStorage.ts +109 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/types.ts +34 -0
- package/src/theme/defaults.ts +151 -0
- package/src/theme/index.ts +23 -0
- package/src/theme/types.ts +157 -0
- package/src/types/config.ts +112 -0
- package/src/types/index.ts +10 -0
- package/src/types/openapi.ts +601 -0
- package/src/utils/index.ts +1 -0
- 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();
|