import { Platform } from 'react-native' import RNFetchBlob from 'react-native-blob-util' import type { XWebViewDownloadProgress, XWebViewDownloadRequest, XWebViewDownloadResult, } from '@xuqm/rn-common' function parseContentDispositionFilename(header: string): string | null { const rfc5987 = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i) if (rfc5987?.[1]) { try { return decodeURIComponent(rfc5987[1].replace(/['"]/g, '')) } catch {} } const quoted = header.match(/filename="([^"]+)"/i) if (quoted?.[1]) return quoted[1].trim() const plain = header.match(/filename=([^;\s"]+)/i) if (plain?.[1]) return plain[1].trim() return null } function filenameFromUrl(url: string): string { try { const { pathname } = new URL(url) const parts = pathname.split('/').filter(Boolean) return parts[parts.length - 1] ? decodeURIComponent(parts[parts.length - 1]) : 'download' } catch { const path = url.split('?')[0] const parts = path.split('/').filter(Boolean) return parts[parts.length - 1] || 'download' } } export async function fetchDownloadInfo( url: string, hintFilename?: string, ): Promise { try { const res = await fetch(url, { method: 'HEAD' }) const disposition = res.headers.get('content-disposition') ?? '' const mimeType = res.headers.get('content-type')?.split(';')[0].trim() || undefined const length = res.headers.get('content-length') const fileSize = length ? parseInt(length, 10) : undefined const suggestedFilename = (hintFilename && hintFilename.trim()) || parseContentDispositionFilename(disposition) || filenameFromUrl(url) return { url, suggestedFilename, mimeType, fileSize } } catch { return { url, suggestedFilename: (hintFilename && hintFilename.trim()) || filenameFromUrl(url), } } } async function resolveFilePath( dir: string, filename: string, conflict: 'rename' | 'overwrite', ): Promise { const full = `${dir}/${filename}` if (conflict === 'overwrite') return full const exists = await RNFetchBlob.fs.exists(full) if (!exists) return full const dot = filename.lastIndexOf('.') const base = dot >= 0 ? filename.slice(0, dot) : filename const ext = dot >= 0 ? filename.slice(dot) : '' let n = 1 let candidate: string do { candidate = `${dir}/${base}(${n})${ext}` n++ } while (await RNFetchBlob.fs.exists(candidate)) return candidate } export async function saveBase64File( base64: string, filename: string, savePath: string | undefined, conflict: 'rename' | 'overwrite', ): Promise { const { dirs } = RNFetchBlob.fs const dir = savePath ?? (Platform.OS === 'android' ? (dirs.DownloadDir ?? dirs.DocumentDir) : dirs.DocumentDir) const filePath = await resolveFilePath(dir, filename, conflict) await RNFetchBlob.fs.writeFile(filePath, base64, 'base64') return filePath } export type DownloadHandle = { cancel: () => void } export function startDownload( url: string, filename: string, savePath: string | undefined, conflict: 'rename' | 'overwrite', onProgress: (p: XWebViewDownloadProgress) => void, onComplete: (r: XWebViewDownloadResult) => void, onError: (error: string) => void, ): DownloadHandle { const { dirs } = RNFetchBlob.fs const dir = savePath ?? (Platform.OS === 'android' ? (dirs.DownloadDir ?? dirs.DocumentDir) : dirs.DocumentDir) let cancelled = false let cancelFn = () => { cancelled = true } resolveFilePath(dir, filename, conflict) .then(filePath => { if (cancelled) return const task = RNFetchBlob.config({ path: filePath }).fetch('GET', url) cancelFn = () => task.cancel() task.progress({ interval: 300 }, (received: number, total: number) => { const recv = Number(received) const tot = Number(total) onProgress({ url, filename, received: recv, total: tot, percentage: tot > 0 ? Math.round((recv / tot) * 100) : -1, }) }) task .then((res: { path: () => string }) => { onComplete({ url, filename, filePath: res.path(), fileSize: 0 }) }) .catch((err: Error) => { if (!String(err?.message ?? '').toLowerCase().includes('cancel')) { onError(err?.message ?? String(err)) } }) }) .catch(err => onError(String(err))) return { cancel: () => cancelFn() } }