diff --git a/mobile/src/context/ShareContext.tsx b/mobile/src/context/ShareContext.tsx index 5c75cefb..caae315c 100644 --- a/mobile/src/context/ShareContext.tsx +++ b/mobile/src/context/ShareContext.tsx @@ -9,6 +9,7 @@ */ import React, { createContext, useCallback, useContext, useState } from "react"; +import { normalizeFileUri } from "../utils/normalizeUri"; export interface SharedFile { uri: string; @@ -28,28 +29,6 @@ const ShareContext = createContext({ clearPendingFiles: () => {}, }); -/** - * Aggressively normalise a file URI so that the same physical file is - * recognised regardless of how the URI was constructed. - * - * - Decode percent-encoding (`%20` → ` `) - * - Collapse consecutive slashes after the scheme (`file:////` → `file:///`) - * - Strip trailing slashes - */ -function normalizeFileUri(uri: string): string { - let norm: string; - try { - norm = decodeURIComponent(uri); - } catch { - norm = uri; - } - // Collapse multiple slashes after the scheme (e.g. file://// → file:///) - norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/"); - // Strip trailing slash - norm = norm.replace(/\/+$/, ""); - return norm; -} - export function ShareProvider({ children }: { children: React.ReactNode }) { const [pendingFiles, setPendingFiles] = useState([]); diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index d7feac79..07a1b97e 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -28,30 +28,12 @@ import { } from "react-native"; import { useAuth } from "../context/AuthContext"; import { useShare } from "../context/ShareContext"; +import { normalizeFileUri } from "../utils/normalizeUri"; import api from "../services/api"; /** Statuses that indicate processing has finished (no further polling needed). */ const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]); -/** - * Normalise a file URI for deduplication. - * - * - Decode percent-encoding (`%20` → ` `) - * - Collapse consecutive slashes after the scheme (`file:////` → `file:///`) - * - Strip trailing slashes - */ -function normalizeUri(uri: string): string { - let norm: string; - try { - norm = decodeURIComponent(uri); - } catch { - norm = uri; - } - norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/"); - norm = norm.replace(/\/+$/, ""); - return norm; -} - interface UploadItem { id: string; filename: string; @@ -130,7 +112,7 @@ export default function UploadScreen() { // Deduplicate: skip if this exact URI was already uploaded in this session. // This guards against duplicate share-sheet deliveries from iOS where the // Linking handler and +not-found.tsx fire for the same file. - const normUri = normalizeUri(uri); + const normUri = normalizeFileUri(uri); if (uploadedUrisRef.current.has(normUri)) { console.debug("[uploadFile] skipping duplicate URI:", uri); return; diff --git a/mobile/src/utils/normalizeUri.ts b/mobile/src/utils/normalizeUri.ts new file mode 100644 index 00000000..d32e7d7f --- /dev/null +++ b/mobile/src/utils/normalizeUri.ts @@ -0,0 +1,20 @@ +/** + * Normalise a file URI for deduplication. + * + * - Decode percent-encoding (`%20` → ` `) + * - Collapse consecutive slashes after the scheme (`file:////` → `file:///`) + * - Strip trailing slashes + */ +export function normalizeFileUri(uri: string): string { + let norm: string; + try { + norm = decodeURIComponent(uri); + } catch { + norm = uri; + } + // Collapse multiple slashes after the scheme (e.g. file://// → file:///) + norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/"); + // Strip trailing slash + norm = norm.replace(/\/+$/, ""); + return norm; +} diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py index 19ba731b..987b1599 100644 --- a/tests/test_duplicates.py +++ b/tests/test_duplicates.py @@ -3,11 +3,12 @@ Covers: - ``GET /api/duplicates`` — list all exact-duplicate groups - ``GET /api/files/{id}/duplicates`` — per-file exact + near-duplicate info -- ``POST /api/ui-upload`` — exact-duplicate warning in upload response +- ``POST /api/ui-upload`` — exact-duplicate rejection at upload time - ``GET /duplicates`` — duplicate management UI page """ import json +import os from unittest.mock import patch import pytest @@ -395,7 +396,6 @@ class TestUploadDuplicateRejection: # The stored_filename is returned so we can verify cleanup stored = data.get("stored_filename") assert stored is not None - import os from app.config import settings