Share funktion

This commit is contained in:
2026-07-16 17:13:37 +02:00
parent 52c9dfb472
commit c7c1864fc6
9 changed files with 311 additions and 27 deletions

View File

@@ -1,3 +1,4 @@
import { Image } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as ImageManipulator from 'expo-image-manipulator';
import type { ShareIntent, ShareIntentFile } from 'expo-share-intent';
@@ -16,14 +17,40 @@ 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;
// Profilbilder (150x150), Logos und Tracking-Pixel aussortieren, die die Score-Heuristik durchrutschen.
const SHARE_IMAGE_MIN_DIMENSION = 320;
const LOGIN_WALL_PATTERN = /accounts\/login|LoginAndSignupPage|loginForm|not-logged-in/i;
export type SharedImageFailureReason = 'login_wall' | 'no_image';
export type SharedImageResolution = {
uri: string;
requiresConfirmation: boolean;
};
export type SharedImageResolutionFailure = {
failureReason: SharedImageFailureReason;
};
export type SharedImageResolutionResult = SharedImageResolution | SharedImageResolutionFailure;
export const isSharedImageResolutionFailure = (
result: SharedImageResolutionResult,
): result is SharedImageResolutionFailure => 'failureReason' in result;
export const detectLoginWall = (html: string): boolean => LOGIN_WALL_PATTERN.test(html);
// URLs aus HTML-Attributen tragen Entities (&amp;) — undecodiert bricht die
// CDN-Signatur (z. B. Instagrams oh=/oe=-Parameter) und der Download liefert 403.
const decodeHtmlEntities = (value: string): string => value
.replace(/&amp;/gi, '&')
.replace(/&#0*38;/g, '&')
.replace(/&quot;/gi, '"')
.replace(/&#0*39;/g, "'")
.replace(/&apos;/gi, "'");
const normalizeSharedImageUri = (uri: string, baseUrl?: string | null): string | null => {
const trimmed = uri.trim();
const trimmed = decodeHtmlEntities(uri.trim());
if (!trimmed) return null;
if (/^(data:image|file:|https?:\/\/)/i.test(trimmed)) return trimmed;
if (!baseUrl) return null;
@@ -149,8 +176,18 @@ export const getSharedImageUri = (shareIntent: ShareIntent): string | null => {
return candidate ?? null;
};
const meetsMinimumDimensions = (uri: string): Promise<boolean> => new Promise((resolve) => {
Image.getSize(
uri,
(width, height) => resolve(Math.min(width, height) >= SHARE_IMAGE_MIN_DIMENSION),
() => resolve(false),
);
});
const downloadAndValidateImage = async (imageUrl: string, refererUrl?: string): Promise<string | null> => {
if (/^data:image/i.test(imageUrl)) return imageUrl;
if (/^data:image/i.test(imageUrl)) {
return (await meetsMinimumDimensions(imageUrl)) ? imageUrl : null;
}
if (/^file:/i.test(imageUrl)) return imageUrl;
if (!/^https?:\/\//i.test(imageUrl)) return null;
@@ -176,6 +213,12 @@ const downloadAndValidateImage = async (imageUrl: string, refererUrl?: string):
return null;
}
const meetsMinimumSize = await meetsMinimumDimensions(download.uri);
if (!meetsMinimumSize) {
FileSystem.deleteAsync(download.uri, { idempotent: true }).catch(() => {});
return null;
}
try {
const processed = await ImageManipulator.manipulateAsync(
download.uri,
@@ -196,9 +239,14 @@ const extractSrcsetUrls = (srcset: string): string[] => srcset
.filter(Boolean)
.reverse();
const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] => {
export const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] => {
const candidates: string[] = [];
const add = (value: string | undefined) => addUniqueCandidate(candidates, value, baseUrl);
const add = (value: string | undefined) => {
// Inline-Base64-Bilder im HTML sind praktisch immer Lade-Spinner/Platzhalter —
// die würden downloadAndValidateImage ungeprüft passieren.
if (value && /^data:/i.test(value.trim())) return;
addUniqueCandidate(candidates, value, baseUrl);
};
const metaPatterns = [
/<meta\s+(?:[^>]*?\s+)?property=["']og:image:secure_url["'][^>]*\s+content=["']([^"']+)["']/gi,
/<meta\s+(?:[^>]*?\s+)?content=["']([^"']+)["'][^>]*\s+property=["']og:image:secure_url["']/gi,
@@ -237,7 +285,7 @@ const extractHtmlImageCandidates = (html: string, baseUrl: string): string[] =>
return sortImageCandidates(candidates);
};
export async function fetchOgImageFromUrl(url: string): Promise<string | null> {
export async function fetchOgImageFromUrl(url: string): Promise<{ uri: string } | SharedImageResolutionFailure> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
@@ -249,23 +297,25 @@ export async function fetchOgImageFromUrl(url: string): Promise<string | null> {
'User-Agent': FETCH_USER_AGENT,
},
});
if (!response.ok) return null;
if (!response.ok) return { failureReason: 'no_image' };
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;
if (validated) return { uri: validated };
}
return null;
// Login-Seiten enthalten oft Login-Marker UND brauchbare OG-Tags; deshalb erst
// nach erfolglosem Kandidaten-Durchlauf als Login-Wall werten.
return { failureReason: detectLoginWall(html) ? 'login_wall' : 'no_image' };
} catch {
return null;
return { failureReason: 'no_image' };
} finally {
clearTimeout(timer);
}
}
export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<SharedImageResolution | null> => {
export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<SharedImageResolutionResult> => {
const directFileUri = getDirectSharedImageFileUri(shareIntent.files);
if (directFileUri) {
return { uri: directFileUri, requiresConfirmation: false };
@@ -273,7 +323,16 @@ export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<S
const refererUrl = shareIntent.webUrl || extractUrlFromText(shareIntent.text) || undefined;
for (const candidate of getSharedImageCandidates(shareIntent).slice(0, 16)) {
if (/^(data:image|file:)/i.test(candidate)) {
if (/^data:image/i.test(candidate)) {
// Die Share-Extension sammelt alle <img>-Quellen ein — darunter Base64-Spinner.
// Nur ausreichend große Inline-Bilder akzeptieren, und wegen der Unsicherheit
// der Quelle immer die Bestätigungs-Vorschau zeigen.
if (await meetsMinimumDimensions(candidate)) {
return { uri: candidate, requiresConfirmation: true };
}
continue;
}
if (/^file:/i.test(candidate)) {
return { uri: candidate, requiresConfirmation: false };
}
@@ -284,13 +343,14 @@ export const resolveSharedImageUri = async (shareIntent: ShareIntent): Promise<S
}
if (refererUrl) {
const fetchedUri = await fetchOgImageFromUrl(refererUrl);
if (fetchedUri) {
return { uri: fetchedUri, requiresConfirmation: true };
const fetched = await fetchOgImageFromUrl(refererUrl);
if ('uri' in fetched) {
return { uri: fetched.uri, requiresConfirmation: true };
}
return fetched;
}
return null;
return { failureReason: 'no_image' };
};
export const summarizeShareIntent = (shareIntent: ShareIntent) => ({