Review follow-ups on 0fa23e0 (Task 2), applied as one commit:
- Path-precise dupPaths (#2, user-visible): DuplicateMatch gains
sourcePath (local match fills it from the matched pick; unified
server branch from picks[hit.index].path). Both pages' duplicate
dialogs derive dupPaths from duplicates' sourcePath instead of
basename matching, so same-basename siblings (different dir/bytes)
are no longer mis-flagged — "跳过重复" no longer drops the
non-duplicate sibling. Stale basename-derivation comment removed.
- Single shared stat (#3): files are stat'd once in
checkDuplicatesUnified (_statPicks) and the picks feed both the
local half (extracted _matchLocalHistory; public checkDuplicates
folded — it had no remaining callers) and the server request.
Halves stat IO for large batches (was ~2x).
- Union test (#1): new test/features/uploads/enqueue_test.dart pins
that a file hit by BOTH local and server history yields ONE
DuplicateMatch with the local uploadedAt (server's echoed time does
not override). Merge dedup is now keyed by sourcePath
(putIfAbsent, local first).
Tests: +1 enqueue union test (real-async chain inside one runAsync
window; sync file/dir setup — fake-async body must not await real
IO), +1 album_detail same-basename sibling test (skip-duplicates
enqueues exactly the non-hit sibling, distinguished by bytes since
sourceFilePath becomes the preparer's staging copy).
Gates: flutter analyze clean; full flutter test 429/429 green.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.
- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
(commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
index translated to global; empty list short-circuits (no request);
errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
history; server hits map back to filename via global index into the
stat'd picks; merged, de-duplicated (filename+bytes) list; server
failure (network/permission/any) silently degrades to local-only —
init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
unified entry; dialog/skip/force logic untouched.
Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Review hardening for Task 8 (test-server workflow: testers install older
APKs over a newer DB):
- Old APK opening a v2 database hits drift's downgrade path, which leaves
the force column in place but writes user_version back to 1. Reinstalling
the new APK then re-runs onUpgrade(1,2) and addColumn crashes with
"duplicate column name: force", failing the DB open.
- onUpgrade now checks PRAGMA table_info(upload_tasks) and only adds the
force column when absent; the normal v1 -> v2 path is unchanged.
- Migration test simulates the downgrade-reupgrade cycle on a file-backed
database (v2 open -> user_version forced back to 1 -> re-upgrade),
asserting the open succeeds, legacy rows survive, and the column stays
writable. RED before the guard (SqliteException: duplicate column name).
Co-Authored-By: Claude Code <noreply@anthropic.com>
Task 8: close the gap between server-side duplicate detection (90dcc58)
and the app.
- ApiClient.mapDioException: normalize 409 wire body reusing
VALIDATION_FAILED with message=PHOTO_DUPLICATE to
ErrorCodes.photoDuplicate (message-based; auto-compat if the contract
later adds a dedicated code)
- l10n zh/en errorPhotoDuplicate; error_l10n maps the code (queue page
inherits via fallback)
- UploadEngine._classifyFailure: PHOTO_DUPLICATE joins the no-auto-retry
bucket (retrying the same init body would 409 again); force to skip
dedup travels only through the enqueue path
- UploadTasks drift table +force column (schema v2, addColumn migration;
legacy rows default false); UploadTaskDraft/UploadTask/prepare carry it
- enqueueForUpload +forcePaths; duplicate dialog "全部上传" (capture page
+ album detail page) now returns DuplicateDialogChoice so confirmed
duplicates enqueue with force=true (server adjudicates owner/admin)
- engine init body carries force only when true (absent otherwise)
Co-Authored-By: Claude Code <noreply@anthropic.com>
- MainActivity locked to portrait: the capture page top bar / bottom
controls and the preview aspect swap all assume portrait; landscape
misaligns the preview ratio and control layers.
- Zoom badge now requires details.pointerCount >= 2 on onScaleStart so a
single stationary tap no longer pops a 1.0x badge for 1s; base-zoom
rebasing stays unconditional (single-finger-into-pinch still resumes
from the current level).
- Removed unused CameraPluginSource.currentZoom getter.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Preview now centers at the sensor aspect ratio (letterboxed on the dark
backdrop) instead of FittedBox cover, which cropped ~38% horizontally and
made the preview differ from the full-frame takePicture output.
Zoom capability hangs off CaptureSource as default implementations
(minZoom/maxZoom = 1, setZoom no-op) so existing fakes stay untouched;
CameraPluginSource probes getMin/MaxZoomLevel after initialize (failure
keeps 1/1 and disables the gesture). The page attaches a scale
GestureDetector only when maxZoom > 1: pinch drives setZoom (throttled at
0.05 delta, serialized to prevent out-of-order platform calls), a badge
shows the current level and hides after 1s, double-tap resets to 1.0x,
and camera flip/reopen resets zoom state. _StaticCaptureSource in
album_detail_page_test.dart switches implements -> extends to inherit the
new defaults.
Co-Authored-By: Claude Code <noreply@anthropic.com>
review fixes: drop GIF from pre-sniff (package maps gif→png decode → never-ready hang); PopScope blocks back during processing
Co-Authored-By: Claude Code <noreply@anthropic.com>
Parallelize the enqueueForUpload collect loop (preparer's internal
bounded semaphore from the previous task keeps IO in check). Also two
test hardenings from that task's code review: pin that failed and
thrown prepare operations release their concurrency slot (cap=1,
subsequent prepare still completes), and pin the maxConcurrentPrepares
<1 -> 1 clamp (peak 1, no deadlock).
Co-Authored-By: Claude Code <noreply@anthropic.com>
Reverts the in-host detail modal interaction: click now window.open's the album page (/a/:token?lang=, no /p/ deep link) with noopener — same target for middle-click/right-click via the real <a> href. The loader's open-photo modal code remains but is no longer triggered.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Sequence: drag past iframe edge -> pointercancel (clears press state) -> Chrome still delivers one pre-queued pointerenter (re-sets hover) -> no further boundary events ever reach the iframe -> hover pause stuck forever. Fix: ignore pointerenter within 500ms after a pointercancel (and while pressed); re-set hover on genuine in-frame release so post-click hover behavior is preserved.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Capture redirected pointerup/click to the viewport element so photo <a> click handlers never fired (detail modal stopped opening). Watchdog alone is the deterministic freeze fallback: clears pointer/manual/hover/focus after 2s on lost release. A09 switched to count-based heart assertion (position check unstable under ambient bubbles + serial suite).
Co-Authored-By: Claude Code <noreply@anthropic.com>
setReason('manual', true) fired on >8px drag had no clear path anywhere (wheel/keyboard have 500ms timers; pointer path didn't) — one drag left the reason permanently, stopping auto-scroll until refresh. Now cleared on pointerup, watchdog, and window blur.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The earlier capture+window-listener fix is insufficient: releasing outside the 195x280 window dispatches pointerup in the host document, which no in-iframe listener can observe. Deterministic fallback: 2s watchdog on pointerdown force-clears the pause reason (in-frame releases still cancel it immediately).
Co-Authored-By: Claude Code <noreply@anthropic.com>
Root cause: pointerup/pointercancel bound only on the viewport element; press on a photo then release outside the 195x280 window (or gesture interruption) never cleared the 'pointer' pause reason -> auto-scroll stopped permanently until page refresh.
- pointerdown now setPointerCapture (release guaranteed to return)
- window-level pointerup/pointercancel + blur fallbacks clear the reason
- rAF frame wrapped in try/catch so a single bad frame can no longer break the animation chain
Co-Authored-By: Claude Code <noreply@anthropic.com>