SEO
22
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<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",
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
204
app/dataUrl=greenlensShareKey.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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.',
|
||||
|
||||
BIN
marketing/ppo-carousel-treatment-5screens/01_plant_doctor.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
marketing/ppo-carousel-treatment-5screens/02_scan_any_plant.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
BIN
marketing/ppo-carousel-treatment-5screens/04_care_reminders.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
marketing/ppo-carousel-treatment-5screens/05_explore_learn.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
marketing/ppo-carousel-treatment/01_carousel_plant_doctor.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
marketing/ppo-carousel-treatment/02_carousel_scan_any_plant.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
marketing/ppo-carousel-treatment/03_ai_plant_insights.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
marketing/ppo-carousel-treatment/04_care_reminders.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
marketing/ppo-carousel-treatment/05_explore_learn.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
180
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
94
patches/expo-share-intent+5.1.1.patch
Normal 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
@@ -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
@@ -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) : [],
|
||||
});
|
||||