feat(file-download): 文件下载全量实现 (#14~#18)

- CdnUrlResolver: 相对路径/S3 URL 统一解析为 apiBaseUrl 代理链路 (#14)
- FileDownloadManager: Notifier<Map<url, state>>,四态(idle/progress/done/failed),
  断点续传 + 取消令牌 + 本地缓存(systemTemp/im_file_cache) (#15)
- FileMessageBubble: 三态 UI,文件类型图标/颜色,大小格式化,
  idle/failed 点击触发下载,done 点击回调 open_filex TODO (#16)
- AudioMessageBubble: 语音消息下载框架,静态波形装饰,
  播放 TODO(audioplayers 接入后解开) (#17)
- VideoMessageBubble: 缩略图 Image.network + CdnUrlResolver,
  播放按钮覆盖,上传进度环,video_player 接入 TODO (#18)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-23 20:02:46 +09:00
parent aeeda6f059
commit 83774f5f61
6 changed files with 1096 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import 'package:im_app/core/foundation/config.dart';
/// CDN URL 解析器
///
/// 对应 Gitea issue #14
///
/// 文件/图片/语音/视频的 CDN 路径有三种形态:
/// 1. 相对路径(如 `Files/xxx.pdf`, `Image/xxx.jpg`
/// → 拼接 [AppConfig.apiBaseUrl]
/// 2. 完整 http/https URL非 S3
/// → 直接返回
/// 3. S3 URL含 `.amazonaws.com`
/// → 路由通过 [AppConfig.apiBaseUrl] 代理S3 桶是私有的,直接访问 403
/// → 提取 path 部分拼接 baseUrl
///
/// ## 使用
///
/// ```dart
/// final url = CdnUrlResolver.resolve('Files/report.pdf');
/// // → 'http://gateway.winwayinfo.com/Files/report.pdf'
/// ```
// ignore: avoid_classes_with_only_static_members
class CdnUrlResolver {
CdnUrlResolver._();
/// 将任意格式的 CDN 路径解析为完整可下载 URL
static String resolve(String path) {
if (path.isEmpty) return path;
// S3 直链 → 路由通过 API 代理
if (_isS3Url(path)) {
return _proxyS3(path);
}
// 完整 HTTP URL非 S3→ 直接使用
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 相对路径 → 拼接 baseUrl
final base = AppConfig.apiBaseUrl.endsWith('/')
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
: AppConfig.apiBaseUrl;
final suffix = path.startsWith('/') ? path : '/$path';
return '$base$suffix';
}
/// 判断是否为 S3 URL
static bool _isS3Url(String url) {
return url.contains('.amazonaws.com') ||
url.contains('s3-accelerate') ||
url.contains('s3.amazonaws');
}
/// S3 URL → 通过 apiBaseUrl 代理
///
/// S3 presigned URL 带有 `/bucket-name/path?X-Amz-...` 结构,
/// 取 path 部分(去掉 query string拼接 baseUrl。
static String _proxyS3(String s3Url) {
try {
final uri = Uri.parse(s3Url);
// path 可能是 /bucket/Image/xxx 或 /Image/xxx取最后有意义的段
final path = uri.path;
final base = AppConfig.apiBaseUrl.endsWith('/')
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
: AppConfig.apiBaseUrl;
return '$base$path';
} catch (_) {
return s3Url;
}
}
}

View File

@@ -0,0 +1,183 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/app/di/network_provider.dart';
import 'package:im_app/core/services/cdn_url_resolver.dart';
/// 单文件下载状态
///
/// 对应 iOS `FileDownloadState` enum
sealed class FileDownloadState {
const FileDownloadState();
}
/// 未下载(初始态)
class FileDownloadIdle extends FileDownloadState {
const FileDownloadIdle();
}
/// 下载中0.0 ~ 1.0
class FileDownloadProgress extends FileDownloadState {
final double progress;
const FileDownloadProgress(this.progress);
}
/// 已下载localPath 为本地缓存路径
class FileDownloadDone extends FileDownloadState {
final String localPath;
const FileDownloadDone(this.localPath);
}
/// 下载失败
class FileDownloadFailed extends FileDownloadState {
final String error;
const FileDownloadFailed(this.error);
}
/// 文件下载管理器
///
/// 对应 Gitea issue #15
///
/// 维护 URL → [FileDownloadState] 的映射,通过 [NetworksSdkApi.executeDownload]
/// 执行带进度的文件下载(内部支持断点续传)。
///
/// ## 缓存策略
///
/// 缓存目录:`Directory.systemTemp/im_file_cache/`(同 iOS Library/Caches 语义OS 负责清理)
/// 缓存命名:`{url.hashCode 8位 hex}_{sanitized_filename}`
///
/// ## 幂等性
///
/// [download] 先检查本地缓存,命中则直接更新状态为 [FileDownloadDone],不发起网络请求。
///
/// ## 并发控制
///
/// 同一 URL 下载中时再次调用 [download] 会静默忽略。
///
/// ## 使用
///
/// ```dart
/// // 触发下载
/// ref.read(fileDownloadManagerProvider.notifier).download(
/// url: 'Files/report.pdf',
/// fileName: 'report.pdf',
/// );
///
/// // 读取状态
/// final dlState = ref.watch(fileDownloadManagerProvider.select(
/// (map) => map['Files/report.pdf'],
/// ));
/// ```
class FileDownloadManager extends Notifier<Map<String, FileDownloadState>> {
/// 进行中的取消令牌key = 原始 URL
final _cancelTokens = <String, CancelToken>{};
/// 已主动取消的 URL防止 catch 块将其状态覆盖为 failed
final _cancelledUrls = <String>{};
@override
Map<String, FileDownloadState> build() => {};
// ── 公开 API ────────────────────────────────────────────────────────────────
/// 下载文件(幂等)
///
/// [url] 原始 CDN 路径(相对路径或完整 URL由 [CdnUrlResolver.resolve] 处理。
/// [fileName] 原始文件名(用于缓存命名和 MIME 类型推断)。
///
/// 返回本地缓存路径(已缓存时立即返回,新下载完成后返回)。
Future<String?> download({
required String url,
required String fileName,
}) async {
// 检查本地缓存
final cachePath = await _cachePath(url, fileName);
final file = File(cachePath);
if (file.existsSync() && file.lengthSync() > 0) {
_updateState(url, FileDownloadDone(cachePath));
return cachePath;
}
// 已在下载中,忽略
final current = state[url];
if (current is FileDownloadProgress) return null;
// 开始下载
_updateState(url, const FileDownloadProgress(0));
final cancelToken = CancelToken();
_cancelTokens[url] = cancelToken;
try {
final resolvedUrl = CdnUrlResolver.resolve(url);
final api = ref.read(networkSdkApiProvider);
await api.executeDownload(
url: resolvedUrl,
savePath: cachePath,
cancelToken: cancelToken,
resume: true,
onProgress: (received, total) {
if (total > 0) {
final progress = received / total;
_updateState(url, FileDownloadProgress(progress.clamp(0.0, 1.0)));
}
},
);
_cancelTokens.remove(url);
_updateState(url, FileDownloadDone(cachePath));
return cachePath;
} catch (e) {
_cancelTokens.remove(url);
// 若已被主动取消state 已置为 idle不再覆盖
if (_cancelledUrls.remove(url)) return null;
_updateState(url, FileDownloadFailed(e.toString()));
return null;
}
}
/// 取消下载
void cancelDownload(String url) {
_cancelledUrls.add(url);
_cancelTokens[url]?.cancel('用户取消');
_cancelTokens.remove(url);
_updateState(url, const FileDownloadIdle());
}
/// 获取本地缓存路径(不触发下载)
///
/// 返回 null 表示尚未下载。
Future<String?> localPath(String url, String fileName) async {
final path = await _cachePath(url, fileName);
final file = File(path);
if (file.existsSync() && file.lengthSync() > 0) return path;
return null;
}
// ── 内部辅助 ────────────────────────────────────────────────────────────────
void _updateState(String url, FileDownloadState s) {
state = {...state, url: s};
}
/// 构造缓存文件路径
///
/// 格式:`{tempDir}/im_file_cache/{hash8}_{safeName}`
Future<String> _cachePath(String url, String fileName) async {
final cacheDir = Directory('${Directory.systemTemp.path}/im_file_cache');
if (!cacheDir.existsSync()) {
await cacheDir.create(recursive: true);
}
final hash = url.hashCode.toUnsigned(32).toRadixString(16).padLeft(8, '0');
final safeName = fileName.replaceAll(RegExp(r'[^\w.\-]'), '_');
return '${cacheDir.path}/${hash}_$safeName';
}
}
/// 文件下载管理器 Provider
final fileDownloadManagerProvider =
NotifierProvider<FileDownloadManager, Map<String, FileDownloadState>>(
FileDownloadManager.new,
);