feat(app): block selecting already-uploaded photos at picker tap (double-tap to force)
方案 A(选图点选拦截):multiImagePickerProvider 经 wechat_assets_picker 10.1.3 官方钩子 AssetPickerConfig.selectPredicate 挂 PickerDuplicateGate—— 点选「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张 强制选择(escape hatch)。 - MultiImagePicker typedef 增参 accountId/albumId(拦截判定范围=当前 目标相册;相册详情/连拍页两处调用点传入); - 判定口径同 checkDuplicatesUnified:本机 TaskStore(同账号+相册+ completed,filename+bytes)∪ 服务器单项预检(bytes;本机命中短路 服务器,点选延迟考量);任一半场异常按该半场无命中,fileSize 失败等 整体异常直接放行——enqueue 弹窗 + init 409 兜底不变; - per-asset 判定缓存(二次点击瞬回)+ 二次点击放行集;取消选择恒放行 且不预热放行集; - AssetEntity 事实适配(PickerAssetFact 测试接缝):fileSize 为 photo_manager 3.12 的 Future<int> 平台通道 getter(entity.dart:778), 惰性求值;title 为 String?(Android 恒有值;iOS 需 needTitle——null 时本机半场跳过,服务器按 bytes 仍可判); - l10n pickerDuplicateBlocked(zh/en);测试替身签名同步 (album_detail/capture_page 页测 + integration_test app_boot), 页测补 (accountId, albumId) 接线断言;gate 五态用例入 enqueue_test。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -22,27 +22,46 @@
|
||||
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
|
||||
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
|
||||
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
|
||||
///
|
||||
/// 方案 A(选图点选拦截):[multiImagePickerProvider] 经
|
||||
/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选
|
||||
/// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张
|
||||
/// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前
|
||||
/// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + init 409
|
||||
/// 兜底不变)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show BuildContext, Localizations;
|
||||
import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar;
|
||||
import 'package:flutter/widgets.dart'
|
||||
show BuildContext, Localizations, Text;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
import '../../core/api_client.dart'
|
||||
show UploadDuplicateHit, apiClientProvider;
|
||||
show ApiClient, UploadDuplicateCheckItem, UploadDuplicateHit,
|
||||
apiClientProvider;
|
||||
import '../../core/notification_permission.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'providers.dart';
|
||||
import 'task_state.dart';
|
||||
import 'task_store.dart';
|
||||
|
||||
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
|
||||
typedef SingleImagePicker = Future<String?> Function();
|
||||
|
||||
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
|
||||
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
|
||||
///
|
||||
/// [accountId]/[albumId] 为点选拦截的判定范围(方案 A:当前目标相册;
|
||||
/// 与入队目标同源)——两处调用点(相册详情/连拍页)均持有,必传。
|
||||
typedef MultiImagePicker = Future<List<String>> Function(
|
||||
BuildContext context, {
|
||||
required String accountId,
|
||||
required String albumId,
|
||||
});
|
||||
|
||||
/// 单图选择 Provider(生产=image_picker;测试 override)。
|
||||
final singleImagePickerProvider = Provider<SingleImagePicker>(
|
||||
@@ -65,9 +84,18 @@ final singleImagePickerProvider = Provider<SingleImagePicker>(
|
||||
/// 该张(null 过滤),不丢弃整批;入队数由调用方 SnackBar 反映实际
|
||||
/// 张数;
|
||||
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
|
||||
/// (非「返回空」);返回 null 仅发生在用户取消。
|
||||
/// (非「返回空」);返回 null 仅发生在用户取消;
|
||||
/// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已
|
||||
/// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。
|
||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
(ref) => (context) async {
|
||||
(ref) => (context, {required accountId, required albumId}) async {
|
||||
// 拦截器按次构造:每次选图会话独立的判定缓存/放行集(不跨会话残留)。
|
||||
final gate = PickerDuplicateGate(
|
||||
store: ref.read(taskStoreProvider),
|
||||
client: ref.read(apiClientProvider),
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
);
|
||||
final locale = Localizations.maybeLocaleOf(context);
|
||||
final assets = await AssetPicker.pickAssets(
|
||||
context,
|
||||
@@ -75,6 +103,14 @@ final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
requestType: RequestType.image,
|
||||
maxAssets: kPickerSoftMaxAssets,
|
||||
textDelegate: assetPickerTextDelegateFromLocale(locale),
|
||||
// 点选拦截(selectPredicate 签名 FutureOr<bool> Function(ctx,
|
||||
// asset, selected)——false 静默不选);AssetEntity →
|
||||
// [PickerAssetFact] 适配(fileSize 惰性求值)在此完成。
|
||||
selectPredicate: (ctx, asset, selected) => gate.decideFact(
|
||||
ctx,
|
||||
(id: asset.id, title: asset.title, fileSize: () => asset.fileSize),
|
||||
selected,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (assets == null) return const <String>[];
|
||||
@@ -91,6 +127,138 @@ final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
|
||||
const int kPickerSoftMaxAssets = 9999;
|
||||
|
||||
/// 拦截器所需的最小资产事实([PickerDuplicateGate] 的测试接缝)。
|
||||
///
|
||||
/// AssetEntity → 此记录的适配在 [multiImagePickerProvider] 内完成:
|
||||
/// - `fileSize`:photo_manager 3.12 为 `Future<int> get fileSize`
|
||||
/// (entity.dart:778——平台通道异步,无同步 length 字段);以惰性闭包
|
||||
/// 传入,缓存命中/取消选择/二次点击路径均不发起查询;
|
||||
/// - `title`:`String?`——iOS 需 FilterOption.needTitle 才非空
|
||||
/// (wechat_assets_picker 默认 filterOptions 仅对 audio 开),Android
|
||||
/// 侧无条件携带(ConvertUtils.kt 无条件 `title to entity.displayName`);
|
||||
/// null → 本机半场跳过(服务器按 bytes 仍可判)。
|
||||
typedef PickerAssetFact = ({
|
||||
String id,
|
||||
String? title,
|
||||
Future<int> Function() fileSize,
|
||||
});
|
||||
|
||||
/// 选图点选拦截器(方案 A):挂 wechat_assets_picker 的
|
||||
/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码
|
||||
/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回
|
||||
/// false 静默不选)。
|
||||
///
|
||||
/// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器
|
||||
/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按
|
||||
/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化
|
||||
/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。
|
||||
/// 本机命中短路服务器预检(点选瞬间延迟考量;与批量 union 不短路不同)。
|
||||
///
|
||||
/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`,
|
||||
/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold
|
||||
/// 之上无 messenger),该 asset.id 记入放行集 → 二次点击 true(强制选择
|
||||
/// 活口)。取消选择(selected=true)恒放行且不预热放行集。per-asset
|
||||
/// 判定缓存避免同一张重复查询。
|
||||
///
|
||||
/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。
|
||||
class PickerDuplicateGate {
|
||||
PickerDuplicateGate({
|
||||
required this.store,
|
||||
required this.client,
|
||||
required this.accountId,
|
||||
required this.albumId,
|
||||
});
|
||||
|
||||
/// 本机历史半场数据源(生产=taskStoreProvider)。
|
||||
final TaskStore store;
|
||||
|
||||
/// 服务器单项预检半场客户端(生产=apiClientProvider)。
|
||||
final ApiClient client;
|
||||
|
||||
/// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。
|
||||
final String accountId;
|
||||
|
||||
/// 判定范围:目标相册(与入队目标一致)。
|
||||
final String albumId;
|
||||
|
||||
/// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。
|
||||
final Map<String, bool> _verdicts = <String, bool>{};
|
||||
|
||||
/// 二次点击放行集(首次拦截时记入——escape hatch)。
|
||||
final Set<String> _forced = <String>{};
|
||||
|
||||
/// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。
|
||||
///
|
||||
/// [selected]=true 表示当前已选中、本次点击为**取消选择** → 恒放行。
|
||||
Future<bool> decideFact(
|
||||
BuildContext ctx,
|
||||
PickerAssetFact asset,
|
||||
bool selected,
|
||||
) async {
|
||||
if (selected) return true;
|
||||
if (_forced.contains(asset.id)) return true; // 二次点击:强制选择。
|
||||
final isDuplicate = _verdicts[asset.id] ?? await _judge(asset);
|
||||
_verdicts[asset.id] = isDuplicate;
|
||||
if (!isDuplicate) return true;
|
||||
_forced.add(asset.id);
|
||||
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false,
|
||||
// 也就不会再有第二次点击)——跳过提示,不跨 async 间隙用 ctx。
|
||||
if (ctx.mounted) _notifyBlocked(ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判定一张是否重复(true=重复):本机 ∪ 服务器,异常降级放行。
|
||||
Future<bool> _judge(PickerAssetFact asset) async {
|
||||
try {
|
||||
// 文件大小:两半场共用,取一次。
|
||||
final bytes = await asset.fileSize();
|
||||
|
||||
// 层 1:本机历史(同账号+相册+completed,filename+bytes)。
|
||||
final title = asset.title;
|
||||
if (title != null) {
|
||||
try {
|
||||
final completed = await store.listByAccount(
|
||||
accountId,
|
||||
albumId: albumId,
|
||||
states: const <UploadTaskState>{UploadTaskState.completed},
|
||||
);
|
||||
final hit = completed.any(
|
||||
(t) => t.filename == title && t.bytes == bytes,
|
||||
);
|
||||
if (hit) return true; // 本机命中短路服务器(点选延迟考量)。
|
||||
} on Object {
|
||||
// 本机查询失败:该半场按无命中,服务器半场仍判。
|
||||
}
|
||||
}
|
||||
|
||||
// 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes;
|
||||
// title null 时传空串占位,判定不受影响)。
|
||||
try {
|
||||
final hits = await client.checkUploadDuplicates(
|
||||
albumId,
|
||||
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
|
||||
);
|
||||
return hits.isNotEmpty;
|
||||
} on Object {
|
||||
// 服务器预检失败:按无命中(放行——预检故障不挡选图)。
|
||||
return false;
|
||||
}
|
||||
} on Object {
|
||||
// fileSize 失败等任意异常:放行。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。
|
||||
void _notifyBlocked(BuildContext ctx) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
|
||||
///
|
||||
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
|
||||
|
||||
Reference in New Issue
Block a user