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:
@@ -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
@@ -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) });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user