diff --git a/app.json b/app.json index 4f8bf3c..4af1a83 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "GreenLens", "slug": "greenlens", - "version": "2.2.6", + "version": "2.2.7", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -50,14 +50,18 @@ [ "expo-share-intent", { - "iosActivationRules": { - "NSExtensionActivationSupportsImageWithMaxCount": 1 - }, - "androidIntentFilters": ["image/*"], - "iosShareExtensionName": "GreenLens Share", - "iosAppGroupIdentifier": "group.com.greenlens.app" - } - ], + "iosActivationRules": { + "NSExtensionActivationSupportsText": true, + "NSExtensionActivationSupportsWebURLWithMaxCount": 1, + "NSExtensionActivationSupportsWebPageWithMaxCount": 1, + "NSExtensionActivationSupportsImageWithMaxCount": 1 + }, + "androidIntentFilters": ["text/*", "image/*"], + "iosShareExtensionName": "GreenLens Share", + "iosAppGroupIdentifier": "group.com.greenlens.app", + "preprocessorInjectJS": "try{function glAddCandidate(list,value){if(value&&typeof value==='string'&&list.indexOf(value)===-1){list.push(value)}} function glSrcsetCandidate(value){if(!value||typeof value!=='string')return null;var parts=value.split(',').map(function(item){return item.trim().split(/\\s+/)[0]}).filter(Boolean);return parts.length?parts[parts.length-1]:null} function glEach(selector,callback){var nodes=document.querySelectorAll(selector);for(var i=0;i { }); const SECURE_INSTALL_MARKER = 'greenlens_install_v1'; -const isShareIntentUrl = (url: string | null | undefined) => Boolean(url?.includes('://dataUrl=')); +const SHARE_INTENT_CALLBACK_PATH = '/dataUrl=greenlensShareKey'; +const isShareIntentCallbackPath = (path: string | null | undefined) => path === SHARE_INTENT_CALLBACK_PATH; const toStartupErrorMessage = (error: unknown): string => { if (!error) return 'Unknown startup error'; @@ -105,35 +104,9 @@ function RootLayoutInner() { } = useApp(); const colors = useColors(isDarkMode, colorPalette); const pathname = usePathname(); - const router = useRouter(); - const [shareIntentEnabled, setShareIntentEnabled] = useState(false); - const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent({ disabled: !shareIntentEnabled }); const [installCheckDone, setInstallCheckDone] = useState(false); const [splashAnimationComplete, setSplashAnimationComplete] = useState(false); - useEffect(() => { - let mounted = true; - - ExpoLinking.getInitialURL() - .then((url) => { - if (mounted && isShareIntentUrl(url)) { - setShareIntentEnabled(true); - } - }) - .catch(() => {}); - - const subscription = ExpoLinking.addEventListener('url', ({ url }) => { - if (isShareIntentUrl(url)) { - setShareIntentEnabled(true); - } - }); - - return () => { - mounted = false; - subscription.remove(); - }; - }, []); - useEffect(() => { (async () => { const didResetSessionForFreshInstall = await ensureInstallConsistency(); @@ -145,35 +118,18 @@ function RootLayoutInner() { }, [signOut]); const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants; - - useEffect(() => { - if (!hasShareIntent || !isAppReady) return; - const sharedImage = shareIntent.files?.find((file) => file.mimeType?.startsWith('image/')); - if (!sharedImage) { - resetShareIntent(); - return; - } - const uri = sharedImage.path; - if (!uri) { - resetShareIntent(); - return; - } - resetShareIntent(); - router.push({ - pathname: '/scanner', - params: { sharedImageUri: uri }, - }); - }, [hasShareIntent, shareIntent, resetShareIntent, router, isAppReady]); const hasActiveEntitlement = isActivatingEntitlement || (billingSummary?.entitlement?.plan === 'pro' && billingSummary?.entitlement?.status === 'active'); const isAllowedWithoutSession = pathname.includes('onboarding') || pathname.includes('auth/') || pathname.includes('scanner') + || isShareIntentCallbackPath(pathname) || pathname.includes('profile/billing'); const isAllowedWithoutEntitlement = pathname.includes('auth/') || pathname.includes('onboarding') || pathname.includes('scanner') + || isShareIntentCallbackPath(pathname) || pathname.includes('profile/billing'); let content = null; @@ -202,6 +158,7 @@ function RootLayoutInner() { name="scanner" options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }} /> + + Boolean(url?.includes(`${SHARE_INTENT_SCHEME}://dataUrl=`)); + +export default function ShareIntentCallbackScreen() { + const router = useRouter(); + const [isWaiting, setIsWaiting] = React.useState(true); + const [failureDetails, setFailureDetails] = React.useState(null); + const [previewUri, setPreviewUri] = React.useState(null); + const pendingKeyRef = React.useRef(null); + const getIntentCalledRef = React.useRef(false); + const settledRef = React.useRef(false); + const linkingUrl = ExpoLinking.useLinkingURL(); + + useEffect(() => { + const fallback = setTimeout(() => { + if (settledRef.current) return; + setIsWaiting(false); + setFailureDetails((current) => current || 'Keine Antwort von der Share Extension.'); + }, 15000); + + return () => clearTimeout(fallback); + }, []); + + useEffect(() => { + const showFailure = (message: string) => { + settledRef.current = true; + ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY); + setPreviewUri(null); + setIsWaiting(false); + setFailureDetails(message); + }; + + const changeSubscription = ShareIntentModule?.addListener('onChange', async (event) => { + try { + setIsWaiting(true); + setFailureDetails(null); + const shareIntent = parseShareIntent(event.value, SHARE_INTENT_OPTIONS); + if (__DEV__) { + console.debug('[ShareIntentCallback]', summarizeShareIntent(shareIntent)); + } + const resolved = await resolveSharedImageUri(shareIntent); + if (!resolved) { + showFailure('Die Quelle hat keinen nutzbaren Bildanhang oder Bild-Link geliefert.'); + return; + } + settledRef.current = true; + const sharedImageKey = storeSharedImageUri(resolved.uri); + ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY); + if (resolved.requiresConfirmation) { + pendingKeyRef.current = sharedImageKey; + setIsWaiting(false); + setPreviewUri(resolved.uri); + return; + } + router.replace({ + pathname: '/scanner', + params: { sharedImageKey }, + }); + } catch (error) { + console.error('[ShareIntentCallback] failed to parse share intent', error); + showFailure('Die Share-Daten konnten nicht gelesen werden.'); + } + }); + + const errorSubscription = ShareIntentModule?.addListener('onError', (event) => { + console.error('[ShareIntentCallback] native error', event.value); + showFailure(event.value || 'Die Share Extension hat einen Fehler gemeldet.'); + }); + + const url = linkingUrl || ExpoLinking.getLinkingURL(); + if (url && isShareIntentUrl(url) && !getIntentCalledRef.current) { + getIntentCalledRef.current = true; + ShareIntentModule?.getShareIntent(url); + } + + return () => { + changeSubscription?.remove(); + errorSubscription?.remove(); + }; + }, [linkingUrl, router]); + + return ( + + {isWaiting ? ( + + ) : previewUri ? ( + + Pflanze gefunden + Ist das die Pflanze, die du scannen möchtest? + + + Wenn das nicht die Pflanze ist, tippe „Scanner öffnen" und teile das Bild direkt in Safari. + + { + if (pendingKeyRef.current) { + router.replace({ pathname: '/scanner', params: { sharedImageKey: pendingKeyRef.current } }); + } + }} + > + Diese Pflanze scannen + + router.replace('/scanner')}> + Scanner öffnen + + + ) : ( + + Bild konnte nicht geladen werden. + + Tippe und halte das Bild in Safari oder Instagram, wähle „Bild teilen" und teile es direkt mit GreenLens. + + {failureDetails ? ( + {failureDetails} + ) : null} + router.replace('/scanner')}> + Scanner öffnen + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#111813', + padding: 24, + }, + messageBox: { + alignItems: 'center', + gap: 14, + }, + title: { + color: '#F4F7F1', + fontSize: 18, + fontWeight: '700', + textAlign: 'center', + }, + body: { + color: '#CAD3CB', + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + }, + detail: { + color: '#8F9A91', + fontSize: 12, + lineHeight: 17, + textAlign: 'center', + }, + previewImage: { + width: '100%', + aspectRatio: 4 / 3, + borderRadius: 16, + backgroundColor: '#1E2820', + }, + button: { + marginTop: 8, + borderRadius: 12, + backgroundColor: '#D7F5A2', + paddingHorizontal: 18, + paddingVertical: 12, + }, + buttonText: { + color: '#111813', + fontSize: 14, + fontWeight: '800', + }, + hint: { + color: '#8F9A91', + fontSize: 11, + lineHeight: 16, + textAlign: 'center', + marginTop: -6, + }, + buttonSecondary: { + marginTop: 4, + borderRadius: 12, + paddingHorizontal: 18, + paddingVertical: 12, + }, + buttonSecondaryText: { + color: '#8F9A91', + fontSize: 14, + fontWeight: '600', + textAlign: 'center', + }, +}); diff --git a/app/scanner.tsx b/app/scanner.tsx index b3f03e4..050063e 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -11,6 +11,7 @@ import * as ImageManipulator from 'expo-image-manipulator'; import * as Haptics from 'expo-haptics'; import * as AppleAuthentication from 'expo-apple-authentication'; import Constants from 'expo-constants'; +import { ShareIntentModule } from 'expo-share-intent'; import { useSafeAnalytics } from '../services/analytics'; import { useApp } from '../context/AppContext'; import { useColors } from '../constants/Colors'; @@ -22,6 +23,7 @@ import { isBackendApiError } from '../services/backend/contracts'; import { createIdempotencyKey } from '../utils/idempotency'; import { AuthService } from '../services/authService'; import { getMockPlantByImage } from '../services/backend/mockCatalog'; +import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff'; const HEALTH_CHECK_CREDIT_COST = 2; const DEMO_SCAN_LIMIT = 5; @@ -129,7 +131,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => { }; export default function ScannerScreen() { - const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageUri?: string }>(); + const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageKey?: string; sharedImageUri?: string }>(); const posthog = useSafeAnalytics(); const { isDarkMode, @@ -160,6 +162,9 @@ export default function ScannerScreen() { const sharedImageUri = Array.isArray(params.sharedImageUri) ? params.sharedImageUri[0] : params.sharedImageUri; + const sharedImageKey = Array.isArray(params.sharedImageKey) + ? params.sharedImageKey[0] + : params.sharedImageKey; const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' && billingSummary?.entitlement?.status === 'active'; const isDemoMode = !hasActiveEntitlement; @@ -197,17 +202,10 @@ export default function ScannerScreen() { }; }, [isExpoGo]); - const hasProcessedSharedImage = useRef(false); - useEffect(() => { - if (!sharedImageUri || hasProcessedSharedImage.current) return; - hasProcessedSharedImage.current = true; - (async () => { - const analysisUri = await resizeForAnalysis(sharedImageUri); - setDemoResultVisible(false); - setSelectedImage(sharedImageUri); - analyzeImage(analysisUri, sharedImageUri); - })(); - }, [sharedImageUri]); + const lastProcessedShareToken = useRef(null); + const sharedAnalysisInFlightToken = useRef(null); + const resizeForAnalysisRef = useRef<(uri: string) => Promise>(async (uri) => uri); + const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise>(async () => {}); useEffect(() => { if (!isAnalyzing) { @@ -256,8 +254,8 @@ export default function ScannerScreen() { try { const result = await ImageManipulator.manipulateAsync( uri, - [{ resize: { width: 768 } }], - { compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true }, + [{ resize: { width: 1280 } }], + { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG, base64: true }, ); return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri; } catch { @@ -472,6 +470,45 @@ export default function ScannerScreen() { } }; + useEffect(() => { + resizeForAnalysisRef.current = resizeForAnalysis; + analyzeImageRef.current = analyzeImage; + }); + + useEffect(() => { + const shareToken = sharedImageKey || sharedImageUri; + if (!shareToken || isLoadingBilling || isAnalyzing) return; + if (lastProcessedShareToken.current === shareToken) return; + if (sharedAnalysisInFlightToken.current) return; + + const handoffImageUri = consumeSharedImageUri(sharedImageKey); + const nextSharedImageUri = handoffImageUri || sharedImageUri; + if (!nextSharedImageUri) return; + + lastProcessedShareToken.current = shareToken; + sharedAnalysisInFlightToken.current = shareToken; + ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY); + + let cancelled = false; + (async () => { + try { + const analysisUri = await resizeForAnalysisRef.current(nextSharedImageUri); + if (cancelled || sharedAnalysisInFlightToken.current !== shareToken) return; + setDemoResultVisible(false); + setSelectedImage(analysisUri); + await analyzeImageRef.current(analysisUri, nextSharedImageUri); + } finally { + if (sharedAnalysisInFlightToken.current === shareToken) { + sharedAnalysisInFlightToken.current = null; + } + } + })(); + + return () => { + cancelled = true; + }; + }, [sharedImageKey, sharedImageUri, isLoadingBilling, isAnalyzing]); + const takePicture = async () => { if (!cameraRef.current || isAnalyzing) return; await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); diff --git a/greenlns-landing/lib/competitors.ts b/greenlns-landing/lib/competitors.ts index 44fed1c..5623699 100644 --- a/greenlns-landing/lib/competitors.ts +++ b/greenlns-landing/lib/competitors.ts @@ -48,9 +48,9 @@ export const competitorProfiles: Record = { picturethis: { slug: 'picturethis', name: 'PictureThis', - metaTitle: 'GreenLens vs PictureThis', + metaTitle: 'GreenLens vs. PictureThis — Honest Plant App Comparison (2026)', metaDescription: - 'Compare GreenLens vs PictureThis for plant emergencies, next-step diagnosis, pricing friction, and care guidance. See when GreenLens is the better fit.', + 'GreenLens or PictureThis? Compare plant emergency workflows, paywall behavior, care guidance, and diagnosis depth. See which app fits your situation.', heroSummary: 'PictureThis is one of the best-known plant ID apps on the market, but GreenLens is built for a different moment: when your plant already looks wrong and you need the next correct action, not another generic care checklist.', heroVerdict: [ @@ -208,9 +208,9 @@ export const competitorProfiles: Record = { plantum: { slug: 'plantum', name: 'Plantum', - metaTitle: 'GreenLens vs Plantum', + metaTitle: 'GreenLens vs. Plantum — Plant Triage vs. All-in-One Assistant (2026)', metaDescription: - 'Compare GreenLens vs Plantum for plant diagnosis, care workflows, pricing friction, and beginner clarity. See why GreenLens is the better plant ER choice.', + 'GreenLens or Plantum? Compare diagnosis depth, beginner clarity, care workflows, and pricing friction. See which plant app fits your situation.', heroSummary: 'Plantum markets itself as a high-accuracy, all-in-one plant care assistant. GreenLens is the sharper choice when the user does not want an all-in-one system right now, but a clear answer to what to do next for a struggling plant.', heroVerdict: [ @@ -368,9 +368,9 @@ export const competitorProfiles: Record = { inaturalist: { slug: 'inaturalist', name: 'iNaturalist', - metaTitle: 'GreenLens vs iNaturalist', + metaTitle: 'GreenLens vs. iNaturalist — Plant Care vs. Citizen Science (2026)', metaDescription: - 'Compare GreenLens vs iNaturalist for plant identification, care guidance, and disease triage. See which app fits your situation — owned plant care or biodiversity discovery.', + 'GreenLens or iNaturalist? Plant care, watering reminders, and health diagnosis (GreenLens) vs. biodiversity discovery and community ID (iNaturalist). Find your fit.', heroSummary: 'iNaturalist is one of the most respected citizen science platforms in the world. GreenLens is built for a different job: helping you decide what to do next when a plant you own is struggling. These two tools solve different problems, and the right choice depends entirely on what you are trying to accomplish.', heroVerdict: [ @@ -529,9 +529,9 @@ export const competitorProfiles: Record = { 'google-lens': { slug: 'google-lens', name: 'Google Lens', - metaTitle: 'GreenLens vs Google Lens: Pflanzen bestimmen | GreenLens', + metaTitle: 'GreenLens vs. Google Lens: Was kommt nach dem Pflanzennamen? (2026)', metaDescription: - 'Pflanzen mit Google Lens bestimmen? Vergleich: Google nennt den Namen, GreenLens liefert Pflegeplan, Gießerinnerung und Gesundheitscheck.', + 'Google Lens nennt den Namen — GreenLens gibt dir den nächsten Schritt: Pflegeplan, Gießerinnerung und Diagnose für gelbe Blätter. Kostenloser Vergleich →', heroSummary: 'Google Lens kann Pflanzen erkennen — aber es hört genau dort auf. GreenLens ist die spezialisierte Alternative: Pflanze fotografieren, sofort Name erhalten, und dann direkt Pflegeplan, Diagnose und Erinnerungen — alles ohne Umweg über Google-Suchergebnisse.', heroVerdict: [ diff --git a/greenlns-landing/lib/seoPages.ts b/greenlns-landing/lib/seoPages.ts index 6f45c8a..c6fcaf2 100644 --- a/greenlns-landing/lib/seoPages.ts +++ b/greenlns-landing/lib/seoPages.ts @@ -335,9 +335,9 @@ const seoPageProfiles: Record = { 'pflanzen-erkennen-app': { slug: 'pflanzen-erkennen-app', - metaTitle: 'Pflanzen erkennen App per Foto | GreenLens', + metaTitle: 'Pflanzen erkennen App – KI-Scan mit Pflegeplan & Diagnose | GreenLens', metaDescription: - 'Pflanzen erkennen per Foto: GreenLens liefert Artname, Pflegeplan, Gießerinnerungen und Diagnose direkt in einer App.', + 'Pflanzen, Blumen & Zimmerpflanzen per Foto erkennen. GreenLens liefert Artname, Pflegeplan, Gießerinnerungen und Diagnose direkt in einer App. Kostenlos →', canonical: '/pflanzen-erkennen-app', h1: 'Pflanzen erkennen App', tagline: 'Pflanze fotografieren — Name, Pflegeanleitung und Diagnose in einer Sekunde.', @@ -452,9 +452,9 @@ const seoPageProfiles: Record = { const additionalSeoPages: Record = { 'blumen-scanner': { slug: 'blumen-scanner', - metaTitle: 'Blumen Scanner App: Blumen per Foto erkennen | GreenLens', + metaTitle: 'Blumen Scanner – Blumen per Foto scannen & sofort erkennen | GreenLens', metaDescription: - 'Blumen Scanner App: Blume fotografieren, Namen erkennen, Pflegeplan erhalten und bei welken Blüten direkt den Gesundheitscheck nutzen.', + 'Einfach Blumen & Pflanzen per Foto scannen und sofort erkennen. KI-Pflanzenerkennung mit Pflegeplan, Gießerinnerung und Gesundheitscheck. Kostenlos starten →', canonical: '/blumen-scanner', h1: 'Blumen Scanner', tagline: 'Blume fotografieren — sofort Name, Herkunft, Pflegeplan und Erinnerung.', @@ -574,9 +574,9 @@ const additionalSeoPages: Record = { 'pflanzen-bestimmen': { slug: 'pflanzen-bestimmen', - metaTitle: 'Pflanzen bestimmen per Foto: App statt Google | GreenLens', + metaTitle: 'Pflanzen bestimmen per Foto – schneller als Google Lens | GreenLens', metaDescription: - 'Pflanzen per Foto bestimmen: GreenLens erkennt den Namen, erstellt Pflegeplan und Gießerinnerungen und erklärt den Unterschied zu Google Lens.', + 'Pflanze per Foto bestimmen: GreenLens erkennt Artname, erstellt sofort Pflegeplan und Gießerinnerungen — ohne Umweg über Google. Kostenlos testen →', canonical: '/pflanzen-bestimmen', h1: 'Pflanzen bestimmen per Foto', tagline: 'Pflanze fotografieren — sofort bestimmt, mit Pflegeplan und ohne Google-Umweg.', @@ -696,9 +696,9 @@ const additionalSeoPages: Record = { const englishSeoPages: Record = { 'flower-scanner': { slug: 'flower-scanner', - metaTitle: 'Flower Scanner App — Identify Any Flower by Photo | GreenLens', + metaTitle: 'Flower Scanner App – Identify Any Flower by Photo Instantly | GreenLens', metaDescription: - 'GreenLens is the flower scanner that goes further: photograph any flower or plant, get the name instantly — then receive a care plan, watering reminders, and health diagnosis.', + 'Point your camera at any flower and get the name instantly — plus a care plan, watering reminders, and health diagnosis. Free to start, no paywall at the scan.', canonical: '/flower-scanner', h1: 'Flower Scanner', tagline: 'Photograph a flower — get the name, origin, and care plan instantly.', @@ -915,9 +915,9 @@ const englishSeoPages: Record = { const germanSeoPages2: Record = { 'pflanzen-krankheiten-erkennen': { slug: 'pflanzen-krankheiten-erkennen', - metaTitle: 'Pflanzenkrankheiten erkennen per Foto | GreenLens', + metaTitle: 'Pflanzenkrankheiten erkennen & diagnostizieren per Foto | GreenLens', metaDescription: - 'Pflanzenkrankheiten erkennen: gelbe Blätter, braune Flecken, Schädlinge oder Wurzelfäule per Foto prüfen und nächsten Schritt erhalten.', + 'Pflanzenkrankheit erkennen: gelbe Blätter, braune Flecken, Schädlinge oder Wurzelfäule per Foto analysieren und sofort den nächsten richtigen Schritt erhalten.', canonical: '/pflanzen-krankheiten-erkennen', h1: 'Pflanzenkrankheiten erkennen', tagline: 'Symptom fotografieren — Ursache erfahren, nächsten Schritt erhalten.', @@ -1035,9 +1035,9 @@ const germanSeoPages2: Record = { 'pflanzen-pflege-app': { slug: 'pflanzen-pflege-app', - metaTitle: 'Pflanzen Pflege App mit Gießerinnerung | GreenLens', + metaTitle: 'Pflanzen Gießerinnerung App – Nie mehr vergessen zu gießen | GreenLens', metaDescription: - 'Pflanzen Pflege App mit Gießerinnerung: GreenLens erstellt Pflegeplan, erinnert pro Pflanze und erkennt Stress wie Überwässerung.', + 'Gießerinnerung pro Pflanze, automatischer Pflegeplan und KI-Diagnose bei Problemen. GreenLens erinnert genau dann, wenn deine Pflanze Wasser braucht. Kostenlos →', canonical: '/pflanzen-pflege-app', h1: 'Pflanzen Pflege App', tagline: 'Gießerinnerungen, die deine Pflanze kennen — nicht nur deinen Kalender.', @@ -1160,9 +1160,9 @@ const germanSeoPages2: Record = { 'zimmerpflanzen-bestimmen': { slug: 'zimmerpflanzen-bestimmen', - metaTitle: 'Zimmerpflanzen bestimmen per Foto | GreenLens', + metaTitle: 'Zimmerpflanzen bestimmen per Foto – Monstera, Efeutute & mehr | GreenLens', metaDescription: - 'Zimmerpflanzen bestimmen per Foto: GreenLens erkennt Monstera, Efeutute, Ficus, Orchideen und Sukkulenten mit Pflegeplan.', + 'Zimmerpflanze per Foto bestimmen: GreenLens erkennt Monstera, Efeutute, Ficus, Orchideen und Sukkulenten — mit sofortigem Pflegeplan und Gießerinnerung.', canonical: '/zimmerpflanzen-bestimmen', h1: 'Zimmerpflanzen bestimmen', tagline: 'Zimmerpflanze fotografieren — Artname, Pflegebedarf und Gießplan in einer Sekunde.', diff --git a/marketing/ppo-carousel-treatment-5screens/01_plant_doctor.png b/marketing/ppo-carousel-treatment-5screens/01_plant_doctor.png new file mode 100644 index 0000000..d08499e Binary files /dev/null and b/marketing/ppo-carousel-treatment-5screens/01_plant_doctor.png differ diff --git a/marketing/ppo-carousel-treatment-5screens/02_scan_any_plant.png b/marketing/ppo-carousel-treatment-5screens/02_scan_any_plant.png new file mode 100644 index 0000000..8a562e0 Binary files /dev/null and b/marketing/ppo-carousel-treatment-5screens/02_scan_any_plant.png differ diff --git a/marketing/ppo-carousel-treatment-5screens/03_ai_plant_insights.png b/marketing/ppo-carousel-treatment-5screens/03_ai_plant_insights.png new file mode 100644 index 0000000..58317c0 Binary files /dev/null and b/marketing/ppo-carousel-treatment-5screens/03_ai_plant_insights.png differ diff --git a/marketing/ppo-carousel-treatment-5screens/04_care_reminders.png b/marketing/ppo-carousel-treatment-5screens/04_care_reminders.png new file mode 100644 index 0000000..af41986 Binary files /dev/null and b/marketing/ppo-carousel-treatment-5screens/04_care_reminders.png differ diff --git a/marketing/ppo-carousel-treatment-5screens/05_explore_learn.png b/marketing/ppo-carousel-treatment-5screens/05_explore_learn.png new file mode 100644 index 0000000..eb78a05 Binary files /dev/null and b/marketing/ppo-carousel-treatment-5screens/05_explore_learn.png differ diff --git a/marketing/ppo-carousel-treatment/01_carousel_plant_doctor.png b/marketing/ppo-carousel-treatment/01_carousel_plant_doctor.png new file mode 100644 index 0000000..5e38431 Binary files /dev/null and b/marketing/ppo-carousel-treatment/01_carousel_plant_doctor.png differ diff --git a/marketing/ppo-carousel-treatment/02_carousel_scan_any_plant.png b/marketing/ppo-carousel-treatment/02_carousel_scan_any_plant.png new file mode 100644 index 0000000..ddefc0d Binary files /dev/null and b/marketing/ppo-carousel-treatment/02_carousel_scan_any_plant.png differ diff --git a/marketing/ppo-carousel-treatment/03_ai_plant_insights.png b/marketing/ppo-carousel-treatment/03_ai_plant_insights.png new file mode 100644 index 0000000..396c767 Binary files /dev/null and b/marketing/ppo-carousel-treatment/03_ai_plant_insights.png differ diff --git a/marketing/ppo-carousel-treatment/04_care_reminders.png b/marketing/ppo-carousel-treatment/04_care_reminders.png new file mode 100644 index 0000000..402733d Binary files /dev/null and b/marketing/ppo-carousel-treatment/04_care_reminders.png differ diff --git a/marketing/ppo-carousel-treatment/05_explore_learn.png b/marketing/ppo-carousel-treatment/05_explore_learn.png new file mode 100644 index 0000000..46820b8 Binary files /dev/null and b/marketing/ppo-carousel-treatment/05_explore_learn.png differ diff --git a/package-lock.json b/package-lock.json index 461600c..cbfcebf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "greenlens", - "version": "2.2.6", + "version": "2.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "greenlens", - "version": "2.2.6", + "version": "2.2.7", "dependencies": { "@expo/vector-icons": "^15.0.3", "@google/genai": "^1.38.0", @@ -60,6 +60,7 @@ "@types/react": "~19.1.0", "jest": "^29.7.0", "jest-expo": "^54.0.17", + "patch-package": "^8.0.1", "typescript": "^5.3.0" } }, @@ -4149,6 +4150,13 @@ "node": ">=10.0.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -7117,6 +7125,16 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flow-enums-runtime": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", @@ -7219,6 +7237,31 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8093,6 +8136,13 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9245,6 +9295,26 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -9257,6 +9327,39 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -9278,6 +9381,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -10790,6 +10903,59 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12913,6 +13079,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index 4308364..561595d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "greenlens", - "version": "2.2.6", + "version": "2.2.7", "main": "expo-router/entry", "private": true, "scripts": { @@ -11,6 +11,7 @@ "build:dev": "eas build --profile development --platform android", "build:preview": "eas build --profile preview --platform android", "build:prod": "eas build --profile production --platform android", + "postinstall": "patch-package", "test": "jest", "audit:semantic": "node scripts/generate_semantic_audit.js" }, @@ -76,6 +77,7 @@ "@types/react": "~19.1.0", "jest": "^29.7.0", "jest-expo": "^54.0.17", + "patch-package": "^8.0.1", "typescript": "^5.3.0" } } diff --git a/patches/expo-share-intent+5.1.1.patch b/patches/expo-share-intent+5.1.1.patch new file mode 100644 index 0000000..0757aad --- /dev/null +++ b/patches/expo-share-intent+5.1.1.patch @@ -0,0 +1,94 @@ +diff --git a/node_modules/expo-share-intent/plugin/build/ios/ShareExtensionViewController.swift b/node_modules/expo-share-intent/plugin/build/ios/ShareExtensionViewController.swift +index 0911a9e..177b5bf 100644 +--- a/node_modules/expo-share-intent/plugin/build/ios/ShareExtensionViewController.swift ++++ b/node_modules/expo-share-intent/plugin/build/ios/ShareExtensionViewController.swift +@@ -16,6 +16,7 @@ class ShareViewController: UIViewController { + var sharedMedia: [SharedMediaFile] = [] + var sharedWebUrl: [WebUrl] = [] + var sharedText: [String] = [] ++ var expectedAttachmentCount: Int = 0 + let imageContentType: String = UTType.image.identifier + let videoContentType: String = UTType.movie.identifier + let textContentType: String = UTType.text.identifier +@@ -40,7 +41,26 @@ class ShareViewController: UIViewController { + dismissWithError(message: "No content found") + return + } +- for (index, attachment) in (attachments).enumerated() { ++ let hasMediaAttachment = attachments.contains { ++ $0.hasItemConformingToTypeIdentifier(imageContentType) ++ || $0.hasItemConformingToTypeIdentifier(videoContentType) ++ } ++ let hasPreprocessingAttachment = attachments.contains { ++ $0.hasItemConformingToTypeIdentifier(propertyListType) ++ } ++ let handledAttachments = hasMediaAttachment ++ ? attachments.filter { ++ $0.hasItemConformingToTypeIdentifier(imageContentType) ++ || $0.hasItemConformingToTypeIdentifier(videoContentType) ++ } ++ : hasPreprocessingAttachment ++ ? attachments.filter { ++ $0.hasItemConformingToTypeIdentifier(propertyListType) ++ } ++ : attachments ++ self.expectedAttachmentCount = handledAttachments.count ++ ++ for (index, attachment) in handledAttachments.enumerated() { + if attachment.hasItemConformingToTypeIdentifier(imageContentType) { + await handleImages(content: content, attachment: attachment, index: index) + } else if attachment.hasItemConformingToTypeIdentifier(videoContentType) { +@@ -103,7 +123,7 @@ class ShareViewController: UIViewController { + + self.sharedText.append(item) + // If this is the last item, save sharedText in userDefaults and redirect to host app +- if index == (content.attachments?.count)! - 1 { ++ if index == self.expectedAttachmentCount - 1 { + let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) + userDefaults?.set(self.sharedText, forKey: self.sharedKey) + userDefaults?.synchronize() +@@ -126,7 +146,7 @@ class ShareViewController: UIViewController { + + self.sharedWebUrl.append(WebUrl(url: item.absoluteString, meta: "")) + // If this is the last item, save sharedText in userDefaults and redirect to host app +- if index == (content.attachments?.count)! - 1 { ++ if index == self.expectedAttachmentCount - 1 { + let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) + userDefaults?.set(self.toData(data: self.sharedWebUrl), forKey: self.sharedKey) + userDefaults?.synchronize() +@@ -161,7 +181,7 @@ class ShareViewController: UIViewController { + self.sharedWebUrl.append( + WebUrl(url: results["baseURI"] as! String, meta: results["meta"] as! String)) + // If this is the last item, save sharedText in userDefaults and redirect to host app +- if index == (content.attachments?.count)! - 1 { ++ if index == self.expectedAttachmentCount - 1 { + let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) + userDefaults?.set(self.toData(data: self.sharedWebUrl), forKey: self.sharedKey) + userDefaults?.synchronize() +@@ -300,7 +320,7 @@ class ShareViewController: UIViewController { + } + + // If this is the last item, save imagesData in userDefaults and redirect to host app +- if index == (content.attachments?.count)! - 1 { ++ if index == self.expectedAttachmentCount - 1 { + let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) + userDefaults?.set(self.toData(data: self.sharedMedia), forKey: self.sharedKey) + userDefaults?.synchronize() +@@ -385,7 +405,7 @@ class ShareViewController: UIViewController { + } + + // If this is the last item, save imagesData in userDefaults and redirect to host app +- if index == (content.attachments?.count)! - 1 { ++ if index == self.expectedAttachmentCount - 1 { + let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) + userDefaults?.set(self.toData(data: self.sharedMedia), forKey: self.sharedKey) + userDefaults?.synchronize() +@@ -453,7 +473,7 @@ class ShareViewController: UIViewController { + type: .file)) + } + +- if index == (content.attachments?.count)! - 1 { ++ if index == self.expectedAttachmentCount - 1 { + let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) + userDefaults?.set(self.toData(data: self.sharedMedia), forKey: self.sharedKey) + userDefaults?.synchronize() diff --git a/utils/shareHandoff.ts b/utils/shareHandoff.ts new file mode 100644 index 0000000..0bb9895 --- /dev/null +++ b/utils/shareHandoff.ts @@ -0,0 +1,19 @@ +const SHARE_INTENT_SCHEME = 'greenlens'; +export const SHARE_INTENT_KEY = `${SHARE_INTENT_SCHEME}ShareKey`; + +const sharedImageHandoff = new Map(); + +export const storeSharedImageUri = (uri: string): string => { + const key = `share-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + sharedImageHandoff.set(key, uri); + return key; +}; + +export const consumeSharedImageUri = (key: string | null | undefined): string | null => { + if (!key) return null; + const uri = sharedImageHandoff.get(key) || null; + if (uri) { + sharedImageHandoff.delete(key); + } + return uri; +}; diff --git a/utils/shareIntent.ts b/utils/shareIntent.ts new file mode 100644 index 0000000..84a7502 --- /dev/null +++ b/utils/shareIntent.ts @@ -0,0 +1,303 @@ +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) : [], +});