fix(mobile): fix file sharing deep-link conflicts and add MIME type inference

- Skip known deep-link paths (qr-login, callback) in makeUrlHandler
  to prevent docuelevate://qr-login URLs from being treated as shared
  files and creating phantom upload errors
- Infer MIME type from file extension for files shared via iOS Share
  Sheet / "Open In…" so the server receives correct Content-Type
  instead of application/octet-stream
- Default login screen server URL to https://app.docuelevate.org

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-19 11:55:45 +00:00
parent 136631762b
commit 1559686f90
4 changed files with 104 additions and 21 deletions
+39 -1
View File
@@ -31,6 +31,44 @@ import { useShare } from "../src/context/ShareContext";
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Common MIME type mappings for file extensions.
* Used to infer the MIME type of files shared via iOS "Open In…" so the
* server receives a correct Content-Type instead of application/octet-stream.
*/
const EXT_TO_MIME: Record<string, string> = {
pdf: "application/pdf",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
webp: "image/webp",
heic: "image/heic",
heif: "image/heif",
txt: "text/plain",
csv: "text/csv",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
rtf: "application/rtf",
html: "text/html",
xml: "application/xml",
json: "application/json",
zip: "application/zip",
};
/** Infer MIME type from a filename's extension, or undefined if unknown. */
function mimeTypeFromFilename(filename: string): string | undefined {
const ext = filename.split(".").pop()?.toLowerCase();
return ext ? EXT_TO_MIME[ext] : undefined;
}
/** /**
* First path-segment names that identify iOS/Android sandbox filesystem paths. * First path-segment names that identify iOS/Android sandbox filesystem paths.
* These can never be expo-router route-group names, so their presence is a * These can never be expo-router route-group names, so their presence is a
@@ -120,7 +158,7 @@ export default function NotFoundScreen() {
// file:// URI so the upload logic can read the file. // file:// URI so the upload logic can read the file.
const fileUri = `file://${pathname}`; const fileUri = `file://${pathname}`;
const filename = filenameFromPath(pathname); const filename = filenameFromPath(pathname);
addPendingFile({ uri: fileUri, filename }); addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
router.replace("/(tabs)/"); router.replace("/(tabs)/");
} else { } else {
// Truly unknown in-app route fall back to the root redirect. // Truly unknown in-app route fall back to the root redirect.
+57 -3
View File
@@ -31,6 +31,13 @@ import { ShareProvider, useShare } from "../src/context/ShareContext";
/** The custom URL scheme registered in app.json. */ /** The custom URL scheme registered in app.json. */
const APP_SCHEME_PREFIX = "docuelevate://"; const APP_SCHEME_PREFIX = "docuelevate://";
/**
* Known deep-link path prefixes that should NOT be treated as shared files.
* These are in-app deep-link routes handled by their respective screens
* (e.g. QR login, OAuth callback).
*/
const DEEP_LINK_PATHS = ["qr-login", "callback"];
/** Extract a display filename from a file:// or content:// URI. */ /** Extract a display filename from a file:// or content:// URI. */
function filenameFromUri(uri: string): string { function filenameFromUri(uri: string): string {
try { try {
@@ -43,6 +50,44 @@ function filenameFromUri(uri: string): string {
} }
} }
/**
* Common MIME type mappings for file extensions.
* Used to infer the MIME type of files shared via the Share Sheet / "Open In…"
* so the server receives a correct Content-Type instead of application/octet-stream.
*/
const EXT_TO_MIME: Record<string, string> = {
pdf: "application/pdf",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
webp: "image/webp",
heic: "image/heic",
heif: "image/heif",
txt: "text/plain",
csv: "text/csv",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
rtf: "application/rtf",
html: "text/html",
xml: "application/xml",
json: "application/json",
zip: "application/zip",
};
/** Infer MIME type from a filename's extension, or undefined if unknown. */
function mimeTypeFromFilename(filename: string): string | undefined {
const ext = filename.split(".").pop()?.toLowerCase();
return ext ? EXT_TO_MIME[ext] : undefined;
}
/** /**
* Build a Linking URL handler that forwards incoming file:// / content:// * Build a Linking URL handler that forwards incoming file:// / content://
* URLs to ShareContext. Extracted as a module-level factory so the handler * URLs to ShareContext. Extracted as a module-level factory so the handler
@@ -59,7 +104,7 @@ function filenameFromUri(uri: string): string {
* Both this handler and `+not-found.tsx` call `addPendingFile`; * Both this handler and `+not-found.tsx` call `addPendingFile`;
* `ShareContext` deduplicates by URI so the file is only uploaded once. * `ShareContext` deduplicates by URI so the file is only uploaded once.
*/ */
function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) { function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mimeType?: string }) => void) {
return ({ url }: { url: string }) => { return ({ url }: { url: string }) => {
let fileUri = url; let fileUri = url;
@@ -68,13 +113,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) =
// (expo-router groups always start with "("). // (expo-router groups always start with "(").
if (url.startsWith(APP_SCHEME_PREFIX)) { if (url.startsWith(APP_SCHEME_PREFIX)) {
const path = url.slice(APP_SCHEME_PREFIX.length); const path = url.slice(APP_SCHEME_PREFIX.length);
if (path.length > 0 && !path.startsWith("(")) {
// Skip known in-app deep-link paths (e.g. qr-login, callback).
// These are handled by their respective screens, not the share flow.
const pathBase = path.split("?")[0].replace(/^\/+/, "");
if (DEEP_LINK_PATHS.includes(pathBase) || path.startsWith("(")) {
return;
}
if (path.length > 0) {
fileUri = "file:///" + path.replace(/^\/+/, ""); fileUri = "file:///" + path.replace(/^\/+/, "");
} }
} }
if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return; if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return;
addPendingFile({ uri: fileUri, filename: filenameFromUri(fileUri) }); const filename = filenameFromUri(fileUri);
addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
}; };
} }
+1 -1
View File
@@ -28,7 +28,7 @@ import { useAuth } from "../context/AuthContext";
export default function LoginScreen() { export default function LoginScreen() {
const { signIn, signInWithQR } = useAuth(); const { signIn, signInWithQR } = useAuth();
const router = useRouter(); const router = useRouter();
const [serverUrl, setServerUrl] = useState(""); const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [qrLoading, setQrLoading] = useState(false); const [qrLoading, setQrLoading] = useState(false);
+7 -16
View File
@@ -16,9 +16,12 @@ import {
} from "react-native"; } from "react-native";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth(); const { user, signOut, baseUrl } = useAuth();
const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
const appVersion = Constants.expoConfig?.version ?? "1.0.0"; const appVersion = Constants.expoConfig?.version ?? "1.0.0";
function handleSignOut() { function handleSignOut() {
@@ -33,10 +36,6 @@ export default function ProfileScreen() {
} }
function handleDeleteAccount() { function handleDeleteAccount() {
if (!baseUrl) {
Alert.alert("Not Connected", "Cannot reach server. Please sign in again.");
return;
}
Alert.alert( Alert.alert(
"Delete Account", "Delete Account",
"This will permanently delete your account and all associated data. This action cannot be undone.", "This will permanently delete your account and all associated data. This action cannot be undone.",
@@ -46,7 +45,7 @@ export default function ProfileScreen() {
text: "Delete Account", text: "Delete Account",
style: "destructive", style: "destructive",
onPress: () => { onPress: () => {
Linking.openURL(`${baseUrl}/account/delete`); Linking.openURL(`${effectiveBaseUrl}/account/delete`);
}, },
}, },
] ]
@@ -54,19 +53,11 @@ export default function ProfileScreen() {
} }
function openPrivacyPolicy() { function openPrivacyPolicy() {
if (!baseUrl) { Linking.openURL(`${effectiveBaseUrl}/privacy`);
Alert.alert("Not Connected", "Cannot reach server. Please sign in again.");
return;
}
Linking.openURL(`${baseUrl}/privacy`);
} }
function openTermsOfService() { function openTermsOfService() {
if (!baseUrl) { Linking.openURL(`${effectiveBaseUrl}/terms`);
Alert.alert("Not Connected", "Cannot reach server. Please sign in again.");
return;
}
Linking.openURL(`${baseUrl}/terms`);
} }
if (!user) { if (!user) {
@@ -105,7 +96,7 @@ export default function ProfileScreen() {
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text> <Text style={styles.rowLabel}>Server</Text>
<Text style={styles.rowValue} numberOfLines={1}> <Text style={styles.rowValue} numberOfLines={1}>
{baseUrl || ""} {effectiveBaseUrl}
</Text> </Text>
</View> </View>
<View style={styles.row}> <View style={styles.row}>