This commit is contained in:
2026-05-18 15:59:46 +02:00
parent 2658c37453
commit 26713b13b1
21 changed files with 894 additions and 97 deletions

View File

@@ -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<nodes.length;i++){callback(nodes[i])}} var glCandidates=[];['og:image','og:image:url','og:image:secure_url','twitter:image','twitter:image:src','image'].forEach(function(key){glAddCandidate(glCandidates,metas[key])}); glEach('meta[itemprop=\"image\"]',function(meta){glAddCandidate(glCandidates,meta.getAttribute('content'))}); glEach('img',function(img){glAddCandidate(glCandidates,img.currentSrc);glAddCandidate(glCandidates,img.src);glAddCandidate(glCandidates,img.getAttribute('data-src'));glAddCandidate(glCandidates,img.getAttribute('data-original'));glAddCandidate(glCandidates,img.getAttribute('data-lazy-src'));glAddCandidate(glCandidates,glSrcsetCandidate(img.getAttribute('srcset')))}); glEach('picture source,source',function(source){glAddCandidate(glCandidates,source.src);glAddCandidate(glCandidates,source.getAttribute('src'));glAddCandidate(glCandidates,glSrcsetCandidate(source.getAttribute('srcset')))}); glEach('video',function(video){glAddCandidate(glCandidates,video.getAttribute('poster'))}); glEach('[style*=\"background-image\"]',function(el){var bg=(el.style&&el.style.backgroundImage)||'';var match=bg.match(/url\\([\"']?([^\"')]+)[\"']?\\)/);if(match){glAddCandidate(glCandidates,match[1])}});metas['greenlens:imageCandidates']=JSON.stringify(glCandidates.slice(0,12));metas['greenlens:imageCandidateCount']=String(glCandidates.length);metas['og:image']=metas['og:image']||glCandidates[0]}catch(e){metas['greenlens:preprocessorError']=String(e)}"
}
],
"expo-camera",
"expo-apple-authentication",
"expo-image-picker",

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { Redirect, Stack, usePathname, useRouter } from 'expo-router';
import { useShareIntent } from 'expo-share-intent';
import { Redirect, Stack, usePathname } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppProvider, useApp } from '../context/AppContext';
@@ -11,7 +10,6 @@ import { useColors } from '../constants/Colors';
import { initDatabase, AppMetaDb } from '../services/database';
import * as SecureStore from 'expo-secure-store';
import * as SplashScreen from 'expo-splash-screen';
import * as ExpoLinking from 'expo-linking';
import { AuthService } from '../services/authService';
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
@@ -19,7 +17,8 @@ import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
SplashScreen.preventAutoHideAsync().catch(() => { });
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' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
@@ -232,6 +189,7 @@ function RootLayoutInner() {
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="plant/[id]"
options={{ presentation: 'card', animation: 'slide_from_right' }}

View File

@@ -0,0 +1,204 @@
import React, { useEffect } from 'react';
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useRouter } from 'expo-router';
import { parseShareIntent, ShareIntentModule } from 'expo-share-intent';
import * as ExpoLinking from 'expo-linking';
import { SHARE_INTENT_KEY, storeSharedImageUri } from '../utils/shareHandoff';
import { resolveSharedImageUri, summarizeShareIntent } from '../utils/shareIntent';
const SHARE_INTENT_SCHEME = 'greenlens';
const SHARE_INTENT_OPTIONS = {
scheme: SHARE_INTENT_SCHEME,
};
const isShareIntentUrl = (url: string | null | undefined) => 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<string | null>(null);
const [previewUri, setPreviewUri] = React.useState<string | null>(null);
const pendingKeyRef = React.useRef<string | null>(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 (
<View style={styles.container}>
{isWaiting ? (
<ActivityIndicator color="#F4F7F1" size="large" />
) : previewUri ? (
<View style={styles.messageBox}>
<Text style={styles.title}>Pflanze gefunden</Text>
<Text style={styles.body}>Ist das die Pflanze, die du scannen möchtest?</Text>
<Image source={{ uri: previewUri }} style={styles.previewImage} resizeMode="cover" />
<Text style={styles.hint}>
Wenn das nicht die Pflanze ist, tippe Scanner öffnen" und teile das Bild direkt in Safari.
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => {
if (pendingKeyRef.current) {
router.replace({ pathname: '/scanner', params: { sharedImageKey: pendingKeyRef.current } });
}
}}
>
<Text style={styles.buttonText}>Diese Pflanze scannen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.buttonSecondary} onPress={() => router.replace('/scanner')}>
<Text style={styles.buttonSecondaryText}>Scanner öffnen</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.messageBox}>
<Text style={styles.title}>Bild konnte nicht geladen werden.</Text>
<Text style={styles.body}>
Tippe und halte das Bild in Safari oder Instagram, wähle „Bild teilen" und teile es direkt mit GreenLens.
</Text>
{failureDetails ? (
<Text style={styles.detail}>{failureDetails}</Text>
) : null}
<TouchableOpacity style={styles.button} onPress={() => router.replace('/scanner')}>
<Text style={styles.buttonText}>Scanner öffnen</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
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',
},
});

View File

@@ -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<string | null>(null);
const sharedAnalysisInFlightToken = useRef<string | null>(null);
const resizeForAnalysisRef = useRef<(uri: string) => Promise<string>>(async (uri) => uri);
const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise<void>>(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);

View File

@@ -48,9 +48,9 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
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<CompetitorSlug, CompetitorProfile> = {
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<CompetitorSlug, CompetitorProfile> = {
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<CompetitorSlug, CompetitorProfile> = {
'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: [

View File

@@ -335,9 +335,9 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
'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<string, SeoPageProfile> = {
const additionalSeoPages: Record<string, SeoPageProfile> = {
'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<string, SeoPageProfile> = {
'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<string, SeoPageProfile> = {
const englishSeoPages: Record<string, SeoPageProfile> = {
'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<string, SeoPageProfile> = {
const germanSeoPages2: Record<string, SeoPageProfile> = {
'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<string, SeoPageProfile> = {
'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<string, SeoPageProfile> = {
'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.',

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

180
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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()

19
utils/shareHandoff.ts Normal file
View File

@@ -0,0 +1,19 @@
const SHARE_INTENT_SCHEME = 'greenlens';
export const SHARE_INTENT_KEY = `${SHARE_INTENT_SCHEME}ShareKey`;
const sharedImageHandoff = new Map<string, string>();
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;
};

303
utils/shareIntent.ts Normal file
View File

@@ -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<string | null> => {
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<string, string>)?.['content-type']
?? (download.headers as Record<string, string>)?.['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 = [
/<meta\s+(?:[^>]*?\s+)?property=["']og:image:secure_url["'][^>]*\s+content=["']([^"']+)["']/gi,
/<meta\s+(?:[^>]*?\s+)?content=["']([^"']+)["'][^>]*\s+property=["']og:image:secure_url["']/gi,
/<meta\s+(?:[^>]*?\s+)?property=["']og:image["'][^>]*\s+content=["']([^"']+)["']/gi,
/<meta\s+(?:[^>]*?\s+)?content=["']([^"']+)["'][^>]*\s+property=["']og:image["']/gi,
/<meta\s+(?:[^>]*?\s+)?name=["']twitter:image(?::src)?["'][^>]*\s+content=["']([^"']+)["']/gi,
/<meta\s+(?:[^>]*?\s+)?content=["']([^"']+)["'][^>]*\s+name=["']twitter:image(?::src)?["']/gi,
/<link\s+(?:[^>]*?\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 = /<video\b[^>]*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<string | null> {
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<SharedImageResolution | null> => {
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) : [],
});