import * as FileSystem from 'expo-file-system/legacy'; import * as ImageManipulator from 'expo-image-manipulator'; import type { ShareIntent, ShareIntentFile } from 'expo-share-intent'; const IMAGE_META_KEYS = [ 'og:image', 'og:image:url', 'og:image:secure_url', 'twitter:image', 'twitter:image:src', 'image', ]; const IMAGE_URL_PATTERN = /\.(?:avif|gif|heic|heif|jpe?g|png|webp)(?:$|[?#])/i; const IMAGE_FORMAT_QUERY_PATTERN = /[?&](?:format|fm|auto)=(?:avif|gif|heic|heif|jpe?g|png|webp)(?:&|$)/i; const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; const FETCH_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; const SHARE_IMAGE_MAX_WIDTH = 1280; const SHARE_IMAGE_JPEG_QUALITY = 0.9; export type SharedImageResolution = { uri: string; requiresConfirmation: boolean; }; const normalizeSharedImageUri = (uri: string, baseUrl?: string | null): string | null => { const trimmed = uri.trim(); if (!trimmed) return null; if (/^(data:image|file:|https?:\/\/)/i.test(trimmed)) return trimmed; if (!baseUrl) return null; try { return new URL(trimmed, baseUrl).toString(); } catch { return null; } }; const isLikelyImageResourceUri = (uri: string): boolean => { if (/^(data:image|file:)/i.test(uri)) return true; return IMAGE_URL_PATTERN.test(uri) || IMAGE_FORMAT_QUERY_PATTERN.test(uri); }; const extractDirectImageUri = (value: string | null | undefined): string | null => { if (!value) return null; const normalizedValue = normalizeSharedImageUri(value); if (normalizedValue && isLikelyImageResourceUri(normalizedValue)) { return normalizedValue; } const urls = value.match(URL_PATTERN) || []; for (const url of urls) { const normalizedUri = normalizeSharedImageUri(url); if (normalizedUri && isLikelyImageResourceUri(normalizedUri)) { return normalizedUri; } } return null; }; const extractImageCandidateList = (value: string | null | undefined): string[] => { if (!value) return []; try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.filter((item): item is string => typeof item === 'string'); } } catch { return []; } return []; }; const getDirectSharedImageFileUri = (files: ShareIntentFile[] | null | undefined): string | null => { const sharedImage = files?.find((file) => file.path && file.mimeType?.startsWith('image/')); if (sharedImage?.path) return sharedImage.path; const imageLikeFile = files?.find((file) => file.path && isLikelyImageResourceUri(file.path)); return imageLikeFile?.path ?? null; }; const addUniqueCandidate = (candidates: string[], value: string | null | undefined, baseUrl?: string | null) => { if (!value) return; const normalizedUri = normalizeSharedImageUri(value, baseUrl); if (normalizedUri && !candidates.includes(normalizedUri)) { candidates.push(normalizedUri); } }; export const extractUrlFromText = (text: string | null | undefined): string | null => { if (!text) return null; const match = text.match(/https?:\/\/[^\s"'<>]+/i); return match?.[0]?.replace(/[).,!?]+$/g, '') ?? null; }; const scoreImageCandidate = (uri: string): number => { const lower = uri.toLowerCase(); let score = 0; if (/og:image|twitter:image|display_url|thumbnail_src/.test(lower)) score += 20; if (IMAGE_URL_PATTERN.test(uri) || IMAGE_FORMAT_QUERY_PATTERN.test(uri)) score += 12; if (/(?:^|[?&])(w|width|maxwidth|resize|s)=([1-9]\d{2,4})/i.test(uri)) { const width = Number(uri.match(/(?:^|[?&])(?:w|width|maxwidth|resize|s)=([1-9]\d{2,4})/i)?.[1] ?? 0); score += Math.min(width / 20, 80); } for (const size of uri.match(/\b([1-9]\d{2,4})x([1-9]\d{2,4})\b/g) ?? []) { const [width, height] = size.split('x').map(Number); score += Math.min((width * height) / 20000, 90); } if (/\/media\/|\/photos?\/|\/image\/|\/images\//.test(lower)) score += 10; if (/logo|avatar|icon|sprite|placeholder|blank|tracking|pixel|loader|spinner/.test(lower)) score -= 80; if (/profile_pic|s150x150|150x150|100x100|64x64|32x32/.test(lower)) score -= 60; return score; }; const sortImageCandidates = (candidates: string[]): string[] => [...candidates].sort( (a, b) => scoreImageCandidate(b) - scoreImageCandidate(a), ); export const getSharedImageCandidates = (shareIntent: ShareIntent): string[] => { const candidates: string[] = []; const baseUrl = shareIntent.webUrl || extractUrlFromText(shareIntent.text) || shareIntent.text; for (const key of IMAGE_META_KEYS) { const value = shareIntent.meta?.[key]; if (typeof value === 'string') { addUniqueCandidate(candidates, value, baseUrl); } } for (const candidate of extractImageCandidateList(shareIntent.meta?.['greenlens:imageCandidates'])) { addUniqueCandidate(candidates, candidate, baseUrl); } addUniqueCandidate(candidates, extractDirectImageUri(shareIntent.webUrl)); addUniqueCandidate(candidates, extractDirectImageUri(shareIntent.text)); return sortImageCandidates(candidates); }; export const getSharedImageUri = (shareIntent: ShareIntent): string | null => { const directFileUri = getDirectSharedImageFileUri(shareIntent.files); if (directFileUri) return directFileUri; const [candidate] = getSharedImageCandidates(shareIntent); return candidate ?? null; }; const downloadAndValidateImage = async (imageUrl: string, refererUrl?: string): Promise => { if (/^data:image/i.test(imageUrl)) return imageUrl; if (/^file:/i.test(imageUrl)) return imageUrl; if (!/^https?:\/\//i.test(imageUrl)) return null; const ext = imageUrl.match(/\.(jpe?g|png|webp|gif)(?:$|[?#])/i)?.[1] ?? 'jpg'; const cacheUri = `${FileSystem.cacheDirectory}share-preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; const download = await FileSystem.downloadAsync(imageUrl, cacheUri, { headers: { ...(refererUrl ? { Referer: refererUrl } : {}), 'User-Agent': FETCH_USER_AGENT, Accept: 'image/jpeg,image/png,image/webp,image/gif,*/*;q=0.8', }, }); if (!(download.status >= 200 && download.status < 300)) return null; const contentType = ( (download.headers as Record)?.['content-type'] ?? (download.headers as Record)?.['Content-Type'] ?? '' ).toLowerCase(); if (contentType.startsWith('text/') || /json|html|xml|avif|heic|heif/.test(contentType)) { FileSystem.deleteAsync(download.uri, { idempotent: true }).catch(() => {}); return null; } try { const processed = await ImageManipulator.manipulateAsync( download.uri, [{ resize: { width: SHARE_IMAGE_MAX_WIDTH } }], { compress: SHARE_IMAGE_JPEG_QUALITY, format: ImageManipulator.SaveFormat.JPEG, base64: true }, ); return processed.base64 ? `data:image/jpeg;base64,${processed.base64}` : null; } catch { return null; } finally { FileSystem.deleteAsync(download.uri, { idempotent: true }).catch(() => {}); } }; const extractSrcsetUrls = (srcset: string): string[] => srcset .split(',') .map((item) => item.trim().split(/\s+/)[0]) .filter(Boolean) .reverse(); const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] => { const candidates: string[] = []; const add = (value: string | undefined) => addUniqueCandidate(candidates, value, baseUrl); const metaPatterns = [ /]*?\s+)?property=["']og:image:secure_url["'][^>]*\s+content=["']([^"']+)["']/gi, /]*?\s+)?content=["']([^"']+)["'][^>]*\s+property=["']og:image:secure_url["']/gi, /]*?\s+)?property=["']og:image["'][^>]*\s+content=["']([^"']+)["']/gi, /]*?\s+)?content=["']([^"']+)["'][^>]*\s+property=["']og:image["']/gi, /]*?\s+)?name=["']twitter:image(?::src)?["'][^>]*\s+content=["']([^"']+)["']/gi, /]*?\s+)?content=["']([^"']+)["'][^>]*\s+name=["']twitter:image(?::src)?["']/gi, /]*?\s+)?rel=["']image_src["'][^>]*\s+href=["']([^"']+)["']/gi, ]; for (const pattern of metaPatterns) { for (const match of html.matchAll(pattern)) add(match[1]); } const imageAttributePattern = /<(?:img|source)\b[^>]*(?:src|data-src|data-original|data-lazy-src)=["']([^"']+)["'][^>]*>/gi; for (const match of html.matchAll(imageAttributePattern)) add(match[1]); const srcsetPattern = /<(?:img|source)\b[^>]*srcset=["']([^"']+)["'][^>]*>/gi; for (const match of html.matchAll(srcsetPattern)) { for (const srcsetUrl of extractSrcsetUrls(match[1])) add(srcsetUrl); } const posterPattern = /]*poster=["']([^"']+)["'][^>]*>/gi; for (const match of html.matchAll(posterPattern)) add(match[1]); const jsonImagePatterns = [ /"(?:display_url|thumbnail_src|contentUrl|image)"\s*:\s*"([^"]+)"/gi, /"url"\s*:\s*"([^"]+\.(?:jpe?g|png|webp|gif)(?:\\?[^"]*)?)"/gi, ]; for (const pattern of jsonImagePatterns) { for (const match of html.matchAll(pattern)) { add(match[1]?.replace(/\\u0026/g, '&').replace(/\\/g, '')); } } return sortImageCandidates(candidates); }; export async function fetchOgImageFromUrl(url: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); try { const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'text/html,application/xhtml+xml', 'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', 'User-Agent': FETCH_USER_AGENT, }, }); if (!response.ok) return null; const html = await response.text(); for (const candidate of extractHtmlImageCandidates(html, url).slice(0, 16)) { const validated = await downloadAndValidateImage(candidate, url); if (validated) return validated; } return null; } catch { return null; } finally { clearTimeout(timer); } } export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise => { const directFileUri = getDirectSharedImageFileUri(shareIntent.files); if (directFileUri) { return { uri: directFileUri, requiresConfirmation: false }; } const refererUrl = shareIntent.webUrl || extractUrlFromText(shareIntent.text) || undefined; for (const candidate of getSharedImageCandidates(shareIntent).slice(0, 16)) { if (/^(data:image|file:)/i.test(candidate)) { return { uri: candidate, requiresConfirmation: false }; } const validated = await downloadAndValidateImage(candidate, refererUrl); if (validated) { return { uri: validated, requiresConfirmation: true }; } } if (refererUrl) { const fetchedUri = await fetchOgImageFromUrl(refererUrl); if (fetchedUri) { return { uri: fetchedUri, requiresConfirmation: true }; } } return null; }; export const summarizeShareIntent = (shareIntent: ShareIntent) => ({ type: shareIntent.type, fileCount: shareIntent.files?.length ?? 0, fileMimeTypes: shareIntent.files?.map((file) => file.mimeType), webUrl: shareIntent.webUrl, text: shareIntent.text, metaKeys: shareIntent.meta ? Object.keys(shareIntent.meta) : [], });