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>