import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; import * as Linking from 'expo-linking'; import * as Notifications from 'expo-notifications'; import { Appearance, Platform, PushNotificationIOS, } from 'react-native'; import { type ClipboardReadTextResult, type ClipboardWriteTextPayload, type HapticsImpactPayload, HOST_BRIDGE_VERSION, type HostBridgeError, type HostBridgeRequest, type NavigateNativePagePayload, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, normalizeHostBridgeExternalUrl, normalizeHostBridgeHapticsImpactStyle, normalizeHostBridgeLocalNotification, type OpenExternalUrlPayload, type SetBadgeCountPayload, } from '../../../../packages/shared/src/contracts/hostBridge'; import { resolveMobileShellWebViewUrl } from '../shell/navigation'; import { getMobileNetworkStatus } from '../shell/network'; import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime'; import { resolveMobileHostCapabilities } from './capabilities'; import { captureImageFile, exportAudioFile, exportImageFile, exportTextFile, importAudioFile, importImageFile, importTextFile, } from './files'; import { type MobileHostBridgeNavigation, failure, invalidRequest, ok, unsupported, } from './protocol'; import { resetQrScannerForTest, scanQrCode } from './scanner'; import { openShare } from './share'; import { buildMobileShellUrl } from '../shell/url'; const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false, }), }); let currentShareTarget: unknown = null; let navigation: MobileHostBridgeNavigation | null = null; export function configureMobileHostBridgeNavigation( nextNavigation: MobileHostBridgeNavigation | null, ) { navigation = nextNavigation; } async function openExternalUrl(payload: unknown) { const url = normalizeHostBridgeExternalUrl( (payload as OpenExternalUrlPayload | undefined)?.url, ); if (!url) { throw invalidRequest('url must use an allowed external protocol'); } if (!(await Linking.canOpenURL(url))) { throw { code: 'host_error', message: 'external URL cannot be opened', } satisfies HostBridgeError; } await Linking.openURL(url); return true; } async function writeClipboard(payload: unknown) { const text = (payload as ClipboardWriteTextPayload | undefined)?.text; if (typeof text !== 'string') { throw invalidRequest('text is required'); } await Clipboard.setStringAsync(text); return true; } async function readClipboard(): Promise { const result = normalizeHostBridgeClipboardText( await Clipboard.getStringAsync(), ); if (!result) { throw { code: 'host_error', message: 'clipboard text unavailable', } satisfies HostBridgeError; } return result; } async function runHaptics(payload: unknown) { const style = normalizeHostBridgeHapticsImpactStyle( (payload as HapticsImpactPayload | undefined)?.style, ); if (!style) { throw invalidRequest('haptics impact style must be light, medium, or heavy'); } const impactStyle = style === 'heavy' ? Haptics.ImpactFeedbackStyle.Heavy : style === 'medium' ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Light; await Haptics.impactAsync(impactStyle); return true; } function setBadgeCount(payload: unknown) { if (Platform.OS !== 'ios') { throw { code: 'unsupported_capability', message: 'app badge count is only supported on iOS mobile shell', } satisfies HostBridgeError; } const count = normalizeHostBridgeBadgeCount( (payload as SetBadgeCountPayload | undefined)?.count, ); if (count === null) { throw invalidRequest('count must be an integer between 0 and 99999'); } PushNotificationIOS.setApplicationIconBadgeNumber(count); return true; } function hasNotificationPermission( permission: Awaited>, ) { return ( permission.granted || permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL ); } async function ensureNotificationPermission() { const currentPermission = await Notifications.getPermissionsAsync(); if (hasNotificationPermission(currentPermission)) { return; } const requestedPermission = await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: false, allowSound: false, }, }); if (!hasNotificationPermission(requestedPermission)) { throw { code: 'host_error', message: 'notification permission denied', } satisfies HostBridgeError; } } async function showLocalNotification(payload: unknown) { const notification = normalizeHostBridgeLocalNotification(payload); if (!notification) { throw invalidRequest('title is required'); } await ensureNotificationPermission(); if (Platform.OS === 'android') { await Notifications.setNotificationChannelAsync( LOCAL_NOTIFICATION_CHANNEL_ID, { name: 'Genarrative', importance: Notifications.AndroidImportance.DEFAULT, }, ); } await Notifications.scheduleNotificationAsync({ content: notification, trigger: Platform.OS === 'android' ? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID } : null, }); return true; } function getColorScheme() { return { colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()), }; } function openNativePage(payload: unknown) { if (!navigation) { throw unsupported('navigation.openNativePage'); } const url = (payload as NavigateNativePagePayload | undefined)?.url; if (typeof url !== 'string') { throw invalidRequest('url is required'); } const webViewUrl = resolveMobileShellWebViewUrl( url, navigation.allowedOrigin, ); if (!webViewUrl) { throw invalidRequest('url must be an allowed same-origin web path'); } navigation.openWebViewUrl( buildMobileShellUrl(webViewUrl, navigation.urlOptions), ); return true; } function reloadWebView() { if (!navigation) { throw unsupported('app.reloadWebView'); } navigation.reloadWebView(); return true; } export async function dispatchMobileHostBridgeRequest( request: HostBridgeRequest, ) { switch (request.method) { case 'host.getRuntime': return ok(request, { shell: 'expo_mobile', platform: Platform.OS === 'ios' ? 'ios' : 'android', hostVersion: MOBILE_SHELL_HOST_VERSION, bridgeVersion: HOST_BRIDGE_VERSION, capabilities: resolveMobileHostCapabilities(), }); case 'appearance.getColorScheme': return ok(request, getColorScheme()); case 'app.openExternalUrl': return ok(request, await openExternalUrl(request.payload)); case 'app.reloadWebView': return ok(request, reloadWebView()); case 'network.status': return ok(request, await getMobileNetworkStatus()); case 'clipboard.writeText': return ok(request, await writeClipboard(request.payload)); case 'clipboard.readText': return ok(request, await readClipboard()); case 'file.exportText': return ok(request, await exportTextFile(request.payload)); case 'file.importText': return ok(request, await importTextFile()); case 'file.exportImage': return ok(request, await exportImageFile(request.payload)); case 'file.importImage': return ok(request, await importImageFile()); case 'file.captureImage': return ok(request, await captureImageFile()); case 'scanner.scanQrCode': return ok(request, await scanQrCode()); case 'file.importAudio': return ok(request, await importAudioFile()); case 'file.exportAudio': return ok(request, await exportAudioFile(request.payload)); case 'haptics.impact': return ok(request, await runHaptics(request.payload)); case 'notification.showLocal': return ok(request, await showLocalNotification(request.payload)); case 'app.setBadgeCount': return ok(request, setBadgeCount(request.payload)); case 'share.open': return ok(request, await openShare(request.payload, currentShareTarget)); case 'share.setTarget': currentShareTarget = request.payload && typeof request.payload === 'object' ? (request.payload as { target?: unknown }).target : null; return ok(request, true); case 'navigation.openNativePage': return ok(request, openNativePage(request.payload)); case 'auth.requestLogin': case 'payment.request': return failure(request, unsupported(request.method)); default: return failure(request, unsupported(request.method)); } } export function resetMobileHostBridgeDispatchForTest() { currentShareTarget = null; navigation = null; resetQrScannerForTest(); }