diff --git a/src/hooks/__tests__/use-file-upload.test.ts b/src/hooks/__tests__/use-file-upload.test.ts index 1d23867c..c7573082 100644 --- a/src/hooks/__tests__/use-file-upload.test.ts +++ b/src/hooks/__tests__/use-file-upload.test.ts @@ -236,6 +236,53 @@ describe('useFileUpload', () => { expect(hook().isUploading).toBe(false); }); + it('surfaces Android Capacitor rejection attempts in user-visible alert (preferred mode)', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'catbox'; + vi.mocked(Capacitor.getPlatform).mockReturnValue('android'); + const capacitorRejection = new Error('All providers failed: catbox: timeout') as Error & { + data?: { attempts?: { provider: string; error: string; stage?: string; elapsedMs?: number }[] }; + }; + capacitorRejection.data = { + attempts: [{ provider: 'catbox', error: 'timeout', stage: 'timeout', elapsedMs: 5000 }], + }; + vi.mocked(FileUploader.pickAndUploadMedia).mockRejectedValue(capacitorRejection); + const { onUploadComplete, hook } = mountHook(); + + await act(async () => { + await hook().handleUpload(); + }); + + expect(window.alert).toHaveBeenCalledWith('upload_failed. upload_failed_all_providers: catbox: timeout (stage=timeout, 5000ms)'); + expect(onUploadComplete).not.toHaveBeenCalled(); + expect(hook().isUploading).toBe(false); + }); + + it('surfaces Android rejection with multiple provider attempts in alert', async () => { + uploadModeRef.value = 'random'; + preferredProviderRef.value = 'catbox'; + vi.mocked(Capacitor.getPlatform).mockReturnValue('android'); + const capacitorRejection = new Error('All providers failed') as Error & { + data?: { attempts?: { provider: string; error: string }[] }; + }; + capacitorRejection.data = { + attempts: [ + { provider: 'catbox', error: 'timeout' }, + { provider: 'imgur', error: 'rate limit' }, + ], + }; + vi.mocked(FileUploader.pickAndUploadMedia).mockRejectedValue(capacitorRejection); + const { onUploadComplete, hook } = mountHook(); + + await act(async () => { + await hook().handleUpload(); + }); + + expect(window.alert).toHaveBeenCalledWith('upload_failed. upload_failed_all_providers: catbox: timeout; imgur: rate limit'); + expect(onUploadComplete).not.toHaveBeenCalled(); + expect(hook().isUploading).toBe(false); + }); + it('returns early when uploadMode is none (no upload, no alert)', async () => { uploadModeRef.value = 'none'; vi.mocked(Capacitor.getPlatform).mockReturnValue('android'); diff --git a/src/hooks/use-file-upload.ts b/src/hooks/use-file-upload.ts index 93460cf1..68e51f18 100644 --- a/src/hooks/use-file-upload.ts +++ b/src/hooks/use-file-upload.ts @@ -6,9 +6,66 @@ import { formatAggregatedError, formatPreferredModeError, type ProviderAttempt } import { getProviderOrder } from '../lib/media-hosting/provider-order'; import { orchestrateElectronUpload } from '../lib/media-hosting/upload-orchestrator'; import useMediaHostingStore from '../stores/use-media-hosting-store'; +import type { ProviderId } from '../lib/media-hosting/types'; const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled'; +const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur', 'postimages']; + +/** Raw attempt shape from Android plugin rejection payload */ +interface RawAttempt { + provider?: unknown; + success?: unknown; + error?: unknown; + stage?: unknown; + elapsedMs?: unknown; + matchedSelectors?: unknown; +} + +/** + * Normalizes Android Capacitor rejection payload. The plugin may reject with + * `{ message, code, data: { attempts: [...] } }` where attempts contain + * provider, error, stage, elapsedMs, matchedSelectors. We extract and attach + * attempts to the error so formatAggregatedError can surface them. + */ +function normalizeAndroidRejection(error: unknown): Error & { attempts?: ProviderAttempt[] } { + const err = error as Error & { data?: { attempts?: unknown[] } }; + const raw = err?.data?.attempts; + if (!Array.isArray(raw) || raw.length === 0) return err; + + const attempts: ProviderAttempt[] = raw + .map((a: unknown): ProviderAttempt | null => { + const item = a as RawAttempt; + const provider = item?.provider; + if (typeof provider !== 'string' || !VALID_PROVIDERS.includes(provider as ProviderId)) return null; + const ms = typeof item?.elapsedMs === 'number' ? item.elapsedMs : undefined; + let sel: string[] | undefined; + if (Array.isArray(item?.matchedSelectors)) { + sel = (item.matchedSelectors as unknown[]).filter((s): s is string => typeof s === 'string'); + } else if (typeof item?.matchedSelectors === 'string' && item.matchedSelectors.trim()) { + sel = item.matchedSelectors + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); + } + const attempt: ProviderAttempt = { + provider: provider as ProviderId, + success: Boolean(item?.success), + error: typeof item?.error === 'string' ? item.error : undefined, + stage: typeof item?.stage === 'string' && item.stage ? (item.stage as ProviderAttempt['stage']) : undefined, + elapsedMs: ms, + matchedSelectors: sel?.length ? sel : undefined, + }; + return attempt; + }) + .filter((a): a is ProviderAttempt => a !== null); + + if (attempts.length > 0) { + (err as Error & { attempts?: ProviderAttempt[] }).attempts = attempts; + } + return err as Error & { attempts?: ProviderAttempt[] }; +} + function isElectronRuntime(): boolean { return window.electronApi?.isElectron === true || window.isElectron === true; } @@ -122,7 +179,7 @@ export function useFileUpload(options: UseFileUploadOptions) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return; - const err = error as Error & { attempts?: ProviderAttempt[] }; + const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] }; if (err.attempts && err.attempts.length > 0) { window.alert(formatAggregatedError(err.attempts, t)); } else if (uploadMode === 'preferred') {