startDownload method

Future<void> startDownload()

开始或恢复下载

断点续传策略:

  1. 文件名包含 version+buildVersion,版本变化自动重新下载
  2. 检测本地文件大小,若超出预期则删除重来
  3. 服务端不支持 Range(返回200而非206)时,自动清理本地文件从头写入

Implementation

Future<void> startDownload() async {
  _assertAndroid();

  if (_updateInfo?.latestVersion == null) {
    _emit(UpgraderEventType.downloadError, '更新信息为空,无法开始下载');
    statusNotifier.value = DownloadStatus.error;
    return;
  }

  if (statusNotifier.value == DownloadStatus.downloading) {
    _emit(UpgraderEventType.log, '下载已在进行中,跳过重复请求');
    return;
  }

  statusNotifier.value = DownloadStatus.downloading;
  _cancelToken = CancelToken();

  final latest = _updateInfo!.latestVersion!;

  try {
    final tempDir = await getTemporaryDirectory();
    // 文件名包含 version + buildVersion,避免版本切换时复用旧文件
    _savePath = '${tempDir.path}/app-v${latest.version}-${latest.buildVersion}.apk';

    _emit(UpgraderEventType.downloadStart, '准备下载', {
      'savePath': _savePath,
      'downloadUrl': latest.downloadUrl,
      'apkSize': latest.apkSize,
      'version': latest.version,
      'buildVersion': latest.buildVersion,
    });

    // 清理其他版本的残留文件
    await _cleanOldApkFiles(tempDir, _savePath!);

    final file = File(_savePath!);
    int existingLength = 0;

    if (await file.exists()) {
      existingLength = await file.length();

      _emit(UpgraderEventType.downloadResume, '检测到本地已有文件', {
        'existingBytes': existingLength,
        'expectedBytes': latest.apkSize,
      });

      // 本地文件大小超出或等于预期大小
      if (latest.apkSize > 0 && existingLength >= latest.apkSize) {
        if (existingLength == latest.apkSize) {
          _emit(UpgraderEventType.log, '文件大小匹配,直接进入校验');
          await _onDownloadCompleted();
          return;
        }
        // 大小超出,说明文件损坏,删除重来
        _emit(UpgraderEventType.downloadConflict, '本地文件大小异常,删除重下', {
          'existingBytes': existingLength,
          'expectedBytes': latest.apkSize,
        });
        await file.delete();
        existingLength = 0;
      }
    }

    // 执行下载
    final useRange = existingLength > 0;

    // 构建请求头:OSS 签名 + 断点续传 Range
    final Map<String, String> downloadHeaders = {};

    if (_ossConfig != null && !_ossConfig!.isPublicRead) {
      downloadHeaders.addAll(
        OssSigner.generateHeaders(
          config: _ossConfig!,
          downloadUrl: latest.downloadUrl,
        ),
      );
    }

    if (useRange) {
      downloadHeaders['Range'] = 'bytes=$existingLength-';
    }

    await _dio.download(
      latest.downloadUrl,
      _savePath,
      cancelToken: _cancelToken,
      // 有续传偏移时用 append 模式,否则用 write(覆盖)
      fileAccessMode: useRange ? FileAccessMode.append : FileAccessMode.write,
      onReceiveProgress: (received, total) {
        final currentTotal = existingLength + received;
        final totalSize = latest.apkSize > 0 ? latest.apkSize : (total + existingLength);

        if (totalSize > 0) {
          progressNotifier.value = (currentTotal / totalSize).clamp(0.0, 1.0);
        }
        _emit(UpgraderEventType.downloadProgress, '下载进度', {
          'received': received,
          'total': total,
          'currentTotal': currentTotal,
          'totalSize': totalSize,
          'progress': totalSize > 0
              ? (currentTotal / totalSize).clamp(0.0, 1.0)
              : null,
        });
      },
      options: Options(
        headers: downloadHeaders.isEmpty ? null : downloadHeaders,
        validateStatus:
            useRange ? (status) => status == 200 || status == 206 : null,
      ),
      deleteOnError: false,
    );

    // 下载请求完成后,检查文件一致性
    // 如果我们请求了 Range 但服务端返回了完整文件(200),
    // dio.download 在 append 模式下会把完整文件追加到已有内容后面
    // 这里通过大小检测来兜底
    if (latest.apkSize > 0) {
      final actualSize = await file.length();
      if (actualSize != latest.apkSize) {
        _emit(UpgraderEventType.downloadConflict, '文件大小不匹配,重新下载', {
          'actualBytes': actualSize,
          'expectedBytes': latest.apkSize,
        });
        await file.delete();
        // 递归重试一次(此时本地文件已删除,不会再走续传)
        statusNotifier.value = DownloadStatus.none;
        await startDownload();
        return;
      }
    }

    await _onDownloadCompleted();
  } on DioException catch (e) {
    if (CancelToken.isCancel(e)) {
      statusNotifier.value = DownloadStatus.paused;
      _emit(UpgraderEventType.downloadPaused, '下载已暂停', {
        'savePath': _savePath,
        'progress': progressNotifier.value,
      });
    } else if (e.response?.statusCode == 416) {
      // 416 Range Not Satisfiable:本地文件 offset 无效
      _emit(UpgraderEventType.downloadRetry, '收到 416,清理本地文件后重试', {
        'statusCode': 416,
      });
      if (_savePath != null) {
        final file = File(_savePath!);
        if (await file.exists()) await file.delete();
      }
      statusNotifier.value = DownloadStatus.none;
      await startDownload(); // 重试
    } else {
      statusNotifier.value = DownloadStatus.error;
      _emit(UpgraderEventType.downloadError, '下载出错', {
        'error': e.toString(),
        'message': e.message,
        'statusCode': e.response?.statusCode,
      });
      _errorHandler?.call(e);
    }
  } catch (e) {
    statusNotifier.value = DownloadStatus.error;
    _emit(UpgraderEventType.downloadError, '下载时发生未知错误', {
      'error': e.toString(),
    });
    _errorHandler?.call(e);
  }
}