1import { ExpoConfig } from '@expo/config-types'; 2import fs from 'fs'; 3import path from 'path'; 4import resolveFrom from 'resolve-from'; 5 6import { ConfigPlugin, InfoPlist } from '../Plugin.types'; 7import { createInfoPlistPlugin, withAppDelegate } from '../plugins/ios-plugins'; 8import { withDangerousMod } from '../plugins/withDangerousMod'; 9import { mergeContents, MergeResults, removeContents } from '../utils/generateCode'; 10 11const debug = require('debug')('expo:config-plugins:ios:maps') as typeof console.log; 12 13// Match against `UMModuleRegistryAdapter` (unimodules), and React Native without unimodules (Expo Modules), and SDK +44 React AppDelegate subscriber. 14export const MATCH_INIT = 15 /(?:(self\.|_)(\w+)\s?=\s?\[\[UMModuleRegistryAdapter alloc\])|(?:RCTBridge\s?\*\s?(\w+)\s?=\s?\[\[RCTBridge alloc\])|(\[self\.reactDelegate createBridgeWithDelegate:self launchOptions:launchOptions\])/g; 16 17const withGoogleMapsKey = createInfoPlistPlugin(setGoogleMapsApiKey, 'withGoogleMapsKey'); 18 19export const withMaps: ConfigPlugin = (config) => { 20 config = withGoogleMapsKey(config); 21 22 const apiKey = getGoogleMapsApiKey(config); 23 // Technically adds react-native-maps (Apple maps) and google maps. 24 25 debug('Google Maps API Key:', apiKey); 26 config = withMapsCocoaPods(config, { useGoogleMaps: !!apiKey }); 27 28 // Adds/Removes AppDelegate setup for Google Maps API on iOS 29 config = withGoogleMapsAppDelegate(config, { apiKey }); 30 31 return config; 32}; 33 34export function getGoogleMapsApiKey(config: Pick<ExpoConfig, 'ios'>) { 35 return config.ios?.config?.googleMapsApiKey ?? null; 36} 37 38export function setGoogleMapsApiKey( 39 config: Pick<ExpoConfig, 'ios'>, 40 { GMSApiKey, ...infoPlist }: InfoPlist 41): InfoPlist { 42 const apiKey = getGoogleMapsApiKey(config); 43 44 if (apiKey === null) { 45 return infoPlist; 46 } 47 48 return { 49 ...infoPlist, 50 GMSApiKey: apiKey, 51 }; 52} 53 54export function addGoogleMapsAppDelegateImport(src: string): MergeResults { 55 const newSrc = []; 56 newSrc.push( 57 '#if __has_include(<GoogleMaps/GoogleMaps.h>)', 58 '#import <GoogleMaps/GoogleMaps.h>', 59 '#endif' 60 ); 61 62 return mergeContents({ 63 tag: 'react-native-maps-import', 64 src, 65 newSrc: newSrc.join('\n'), 66 anchor: /#import "AppDelegate\.h"/, 67 offset: 1, 68 comment: '//', 69 }); 70} 71 72export function removeGoogleMapsAppDelegateImport(src: string): MergeResults { 73 return removeContents({ 74 tag: 'react-native-maps-import', 75 src, 76 }); 77} 78 79export function addGoogleMapsAppDelegateInit(src: string, apiKey: string): MergeResults { 80 const newSrc = []; 81 newSrc.push( 82 '#if __has_include(<GoogleMaps/GoogleMaps.h>)', 83 ` [GMSServices provideAPIKey:@"${apiKey}"];`, 84 '#endif' 85 ); 86 87 return mergeContents({ 88 tag: 'react-native-maps-init', 89 src, 90 newSrc: newSrc.join('\n'), 91 anchor: MATCH_INIT, 92 offset: 0, 93 comment: '//', 94 }); 95} 96 97export function removeGoogleMapsAppDelegateInit(src: string): MergeResults { 98 return removeContents({ 99 tag: 'react-native-maps-init', 100 src, 101 }); 102} 103 104/** 105 * @param src The contents of the Podfile. 106 * @returns Podfile with Google Maps added. 107 */ 108export function addMapsCocoaPods(src: string): MergeResults { 109 return mergeContents({ 110 tag: 'react-native-maps', 111 src, 112 newSrc: ` pod 'react-native-google-maps', path: File.dirname(\`node --print "require.resolve('react-native-maps/package.json')"\`)`, 113 anchor: /use_native_modules/, 114 offset: 0, 115 comment: '#', 116 }); 117} 118 119export function removeMapsCocoaPods(src: string): MergeResults { 120 return removeContents({ 121 tag: 'react-native-maps', 122 src, 123 }); 124} 125 126function isReactNativeMapsInstalled(projectRoot: string): string | null { 127 const resolved = resolveFrom.silent(projectRoot, 'react-native-maps/package.json'); 128 return resolved ? path.dirname(resolved) : null; 129} 130 131function isReactNativeMapsAutolinked(config: Pick<ExpoConfig, '_internal'>): boolean { 132 // Only add the native code changes if we know that the package is going to be linked natively. 133 // This is specifically for monorepo support where one app might have react-native-maps (adding it to the node_modules) 134 // but another app will not have it installed in the package.json, causing it to not be linked natively. 135 // This workaround only exists because react-native-maps doesn't have a config plugin vendored in the package. 136 137 // TODO: `react-native-maps` doesn't use Expo autolinking so we cannot safely disable the module. 138 return true; 139 140 // return ( 141 // !config._internal?.autolinkedModules || 142 // config._internal.autolinkedModules.includes('react-native-maps') 143 // ); 144} 145 146const withMapsCocoaPods: ConfigPlugin<{ useGoogleMaps: boolean }> = (config, { useGoogleMaps }) => { 147 return withDangerousMod(config, [ 148 'ios', 149 async (config) => { 150 const filePath = path.join(config.modRequest.platformProjectRoot, 'Podfile'); 151 const contents = await fs.promises.readFile(filePath, 'utf-8'); 152 let results: MergeResults; 153 // Only add the block if react-native-maps is installed in the project (best effort). 154 // Generally prebuild runs after a yarn install so this should always work as expected. 155 const googleMapsPath = isReactNativeMapsInstalled(config.modRequest.projectRoot); 156 const isLinked = isReactNativeMapsAutolinked(config); 157 debug('Is Expo Autolinked:', isLinked); 158 debug('react-native-maps path:', googleMapsPath); 159 if (isLinked && googleMapsPath && useGoogleMaps) { 160 try { 161 results = addMapsCocoaPods(contents); 162 } catch (error: any) { 163 if (error.code === 'ERR_NO_MATCH') { 164 throw new Error( 165 `Cannot add react-native-maps to the project's ios/Podfile because it's malformed. Please report this with a copy of your project Podfile.` 166 ); 167 } 168 throw error; 169 } 170 } else { 171 // If the package is no longer installed, then remove the block. 172 results = removeMapsCocoaPods(contents); 173 } 174 if (results.didMerge || results.didClear) { 175 await fs.promises.writeFile(filePath, results.contents); 176 } 177 return config; 178 }, 179 ]); 180}; 181 182const withGoogleMapsAppDelegate: ConfigPlugin<{ apiKey: string | null }> = (config, { apiKey }) => { 183 return withAppDelegate(config, (config) => { 184 if (['objc', 'objcpp'].includes(config.modResults.language)) { 185 if ( 186 apiKey && 187 isReactNativeMapsAutolinked(config) && 188 isReactNativeMapsInstalled(config.modRequest.projectRoot) 189 ) { 190 try { 191 config.modResults.contents = addGoogleMapsAppDelegateImport( 192 config.modResults.contents 193 ).contents; 194 config.modResults.contents = addGoogleMapsAppDelegateInit( 195 config.modResults.contents, 196 apiKey 197 ).contents; 198 } catch (error: any) { 199 if (error.code === 'ERR_NO_MATCH') { 200 throw new Error( 201 `Cannot add Google Maps to the project's AppDelegate because it's malformed. Please report this with a copy of your project AppDelegate.` 202 ); 203 } 204 throw error; 205 } 206 } else { 207 config.modResults.contents = removeGoogleMapsAppDelegateImport( 208 config.modResults.contents 209 ).contents; 210 config.modResults.contents = removeGoogleMapsAppDelegateInit( 211 config.modResults.contents 212 ).contents; 213 } 214 } else { 215 throw new Error( 216 `Cannot setup Google Maps because the project AppDelegate is not a supported language: ${config.modResults.language}` 217 ); 218 } 219 return config; 220 }); 221}; 222