<

{{title}}

{{file.summary.functions.pct}}%

Functions
{{file.summary.functions.covered}}/{{file.summary.functions.total}}

{{file.summary.branches.pct}}%

Branches
{{file.summary.branches.covered}}/{{file.summary.branches.total}}

{{file.summary.lines.pct}}%

Lines
{{file.summary.lines.covered}}/{{file.summary.lines.total}}
          
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
                
import common from '@ohos.app.ability.common'; 
import webview from '@ohos.web.webview'; 
import request from '@ohos.request'; 
import fs from '@ohos.file.fs'; 
import picker from '@ohos.file.picker'; 
import { OnSdkEvent } from '../model/SignCallback'; 
import { DownloadStartEvent, DownloadFailedEvent, DownloadCompletedEvent } from '../model/SdkEvent'; 
 
/** 
 * 下载处理器。 
 * 
 * 通过 `@ohos.request.downloadFile` 把 http(s) 下载链接落到 app 沙箱缓存目录, 
 * 再调用 `DocumentViewPicker.save` 让用户选择目标位置完成另存。 
 * 
 * 不支持 `blob:` 协议,触发时 emit `DownloadFailed`,由集成方通过 JSBridge 实现。 
 */ 
export class DownloadHandler { 
  /** SDK 缓存子目录名。 */ 
  private static readonly CACHE_SUBDIR: string = 'qian-download'; 
 
  /** 
   * 触发下载。 
   * 
   * @param controller WebviewController(预留参数,本实现未使用) 
   * @param context UIAbility/页面 context 
   * @param url 下载 URL 
   * @param mimeType MIME 类型 
   * @param contentDisposition Content-Disposition 头 
   * @param onEvent 可选的事件回调 
   */ 
  public static async start( 
    controller: webview.WebviewController, 
    context: common.UIAbilityContext, 
    url: string, 
    mimeType: string, 
    contentDisposition: string, 
    onEvent?: OnSdkEvent 
  ): Promise<void> { 
    if (url === null || url === undefined || url.length === 0) { 
      return; 
    } 
 
    if (url.indexOf('blob:') === 0) { 
      DownloadHandler.emitFailed(onEvent, url, 
        'blob 下载暂不支持,请通过 JSBridge 自行实现'); 
      return; 
    } 
 
    // 仅处理 http(s) 
    const lower: string = url.toLowerCase(); 
    if (lower.indexOf('http://') !== 0 && lower.indexOf('https://') !== 0) { 
      DownloadHandler.emitFailed(onEvent, url, '不支持的下载协议'); 
      return; 
    } 
 
    const safeMime: string = mimeType === null || mimeType === undefined ? '' : mimeType; 
    const safeCd: string = contentDisposition === null || contentDisposition === undefined ? '' : contentDisposition; 
 
    if (onEvent !== undefined) { 
      onEvent(new DownloadStartEvent(url, safeMime, safeCd)); 
    } 
 
    const filename: string = DownloadHandler.resolveFilename(url, safeCd); 
    const savePath: string = DownloadHandler.ensureSavePath(context, filename); 
 
    // request.downloadFile 不支持 overwrite,目标已存在会 reject(13400002),先清理 
    try { 
      if (fs.accessSync(savePath)) { 
        fs.unlinkSync(savePath); 
      } 
    } catch (_e) { 
    } 
 
    try { 
      const cfg: request.DownloadConfig = { 
        url: url, 
        filePath: savePath, 
        enableMetered: true, 
        enableRoaming: true, 
        description: filename, 
        title: filename 
      }; 
      const task: request.DownloadTask = await request.downloadFile(context, cfg); 
 
      task.on('complete', (): void => { 
        DownloadHandler.saveToUserPicked(savePath, filename, url, onEvent); 
      }); 
      task.on('fail', (err: number): void => { 
        DownloadHandler.emitFailed(onEvent, url, '下载失败 errCode=' + err.toString()); 
      }); 
    } catch (e) { 
      const reason: string = (e instanceof Error) 
        ? (e.name + ': ' + e.message) 
        : String(e); 
      DownloadHandler.emitFailed(onEvent, url, '下载启动失败: ' + reason); 
    } 
  } 
 
  /** 
   * 计算落盘文件名: Content-Disposition > URL 末段 > 时间戳兜底。 
   */ 
  private static resolveFilename(url: string, contentDisposition: string): string { 
    const fromCd: string = DownloadHandler.parseFilenameFromCd(contentDisposition); 
    if (fromCd.length > 0) { 
      return DownloadHandler.sanitize(fromCd); 
    } 
    const fromUrl: string = DownloadHandler.parseFilenameFromUrl(url); 
    if (fromUrl.length > 0) { 
      return DownloadHandler.sanitize(fromUrl); 
    } 
    return 'download_' + Date.now().toString(); 
  } 
 
  private static parseFilenameFromCd(cd: string): string { 
    if (cd === null || cd === undefined || cd.length === 0) { 
      return ''; 
    } 
    // RFC 5987: filename*=UTF-8''xxxx 
    const starMatch = cd.match(/filename\*\s*=\s*[^']*''([^;]+)/i); 
    if (starMatch !== null && starMatch.length > 1) { 
      try { 
        return decodeURIComponent(starMatch[1].trim()); 
      } catch (_e) { 
      } 
    } 
    // filename="xxxx" 或 filename=xxxx 
    const plain = cd.match(/filename\s*=\s*"?([^";]+)"?/i); 
    if (plain !== null && plain.length > 1) { 
      return plain[1].trim(); 
    } 
    return ''; 
  } 
 
  private static parseFilenameFromUrl(url: string): string { 
    try { 
      const noQuery: string = url.split('?')[0].split('#')[0]; 
      const idx: number = noQuery.lastIndexOf('/'); 
      if (idx < 0 || idx === noQuery.length - 1) { 
        return ''; 
      } 
      return decodeURIComponent(noQuery.substring(idx + 1)); 
    } catch (_e) { 
      return ''; 
    } 
  } 
 
  private static sanitize(name: string): string { 
    // 过滤路径分隔符/非法字符与上跳段,防路径穿越。 
    let n: string = name.replace(/[\\/:*?"<>|]/g, '_').replace(/\.\./g, '_').trim(); 
    // 兜底:清洗后为空 / 纯 "." / 纯 ".." → 时间戳默认名,避免产生指向目录本身的无效路径。 
    if (n.length === 0 || n === '.' || n === '..') { 
      n = 'download_' + Date.now().toString(); 
    } 
    // 限长,防超长名写入失败。 
    if (n.length > 200) { 
      n = n.substring(n.length - 200); 
    } 
    return n; 
  } 
 
  private static ensureSavePath(context: common.UIAbilityContext, filename: string): string { 
    const dir: string = context.cacheDir + '/' + DownloadHandler.CACHE_SUBDIR; 
    try { 
      if (!fs.accessSync(dir)) { 
        fs.mkdirSync(dir, true); 
      } 
    } catch (_e) { 
    } 
    // 落盘二次防御:即便 sanitize 被绕过,文件名仍不得含路径分隔符/上跳段, 
    // 否则用时间戳兜底名,确保写入路径始终落在缓存子目录内(不逃逸)。 
    let safe: string = filename; 
    if (safe.length === 0 || safe.indexOf('/') >= 0 || safe.indexOf('\\') >= 0 
      || safe === '.' || safe === '..') { 
      safe = 'download_' + Date.now().toString(); 
    } 
    return dir + '/' + safe; 
  } 
 
  private static emitFailed(onEvent: OnSdkEvent | undefined, url: string, reason: string): void { 
    if (onEvent !== undefined) { 
      onEvent(new DownloadFailedEvent(url, reason)); 
    } 
  } 
 
  /** 
   * 弹 `DocumentViewPicker.save` 让用户选择目标位置,把缓存文件拷贝过去。 
   * 
   * - 用户选定: 拷贝到目标 uri,清理缓存,emit `DownloadCompleted(url, 目标 uri)` 
   * - 用户取消或 picker 异常: emit `DownloadCompleted(url, 缓存路径)`,缓存保留 
   * - 拷贝失败: emit `DownloadFailed` 
   */ 
  private static async saveToUserPicked( 
    cachePath: string, 
    filename: string, 
    url: string, 
    onEvent: OnSdkEvent | undefined 
  ): Promise<void> { 
    let targetUri: string = ''; 
    try { 
      const documentPicker = new picker.DocumentViewPicker(); 
      const opts: picker.DocumentSaveOptions = new picker.DocumentSaveOptions(); 
      opts.newFileNames = [filename]; 
      const uris: string[] = await documentPicker.save(opts); 
      if (uris !== null && uris !== undefined && uris.length > 0) { 
        targetUri = uris[0]; 
      } 
    } catch (_e) { 
    } 
 
    if (targetUri.length === 0) { 
      if (onEvent !== undefined) { 
        onEvent(new DownloadCompletedEvent(url, cachePath)); 
      } 
      return; 
    } 
 
    let destFd: number = -1; 
    let srcFd: number = -1; 
    try { 
      const destFile = fs.openSync(targetUri, fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC); 
      destFd = destFile.fd; 
      const srcFile = fs.openSync(cachePath, fs.OpenMode.READ_ONLY); 
      srcFd = srcFile.fd; 
      fs.copyFileSync(srcFd, destFd); 
      fs.closeSync(srcFile); 
      srcFd = -1; 
      fs.closeSync(destFile); 
      destFd = -1; 
      try { 
        fs.unlinkSync(cachePath); 
      } catch (_e) { 
      } 
      if (onEvent !== undefined) { 
        onEvent(new DownloadCompletedEvent(url, targetUri)); 
      } 
    } catch (e) { 
      if (srcFd >= 0) { 
        try { fs.closeSync(srcFd); } catch (_e) {} 
      } 
      if (destFd >= 0) { 
        try { fs.closeSync(destFd); } catch (_e) {} 
      } 
      const reason: string = (e instanceof Error) 
        ? (e.name + ': ' + e.message) 
        : String(e); 
      DownloadHandler.emitFailed(onEvent, url, '保存到所选位置失败: ' + reason); 
    } 
  } 
 
  private constructor() {} 
} 

              
Code coverage generated by bjc at {{date}}