1082815dcSEvan Baconimport { ExpoConfig } from '@expo/config-types';
2082815dcSEvan Baconimport fs from 'fs';
3082815dcSEvan Baconimport path from 'path';
4082815dcSEvan Baconimport resolveFrom from 'resolve-from';
5082815dcSEvan Bacon
6082815dcSEvan Baconimport { ConfigPlugin, InfoPlist } from '../Plugin.types';
7082815dcSEvan Baconimport { createInfoPlistPlugin, withAppDelegate } from '../plugins/ios-plugins';
8082815dcSEvan Baconimport { withDangerousMod } from '../plugins/withDangerousMod';
9082815dcSEvan Baconimport { mergeContents, MergeResults, removeContents } from '../utils/generateCode';
10082815dcSEvan Bacon
11082815dcSEvan Baconconst debug = require('debug')('expo:config-plugins:ios:maps') as typeof console.log;
12082815dcSEvan Bacon
13082815dcSEvan Baconexport const MATCH_INIT =
14*857ad32fSBrent Vatne  /-\s*\(BOOL\)\s*application:\s*\(UIApplication\s*\*\s*\)\s*\w+\s+didFinishLaunchingWithOptions:/g;
15082815dcSEvan Bacon
16082815dcSEvan Baconconst withGoogleMapsKey = createInfoPlistPlugin(setGoogleMapsApiKey, 'withGoogleMapsKey');
17082815dcSEvan Bacon
18082815dcSEvan Baconexport const withMaps: ConfigPlugin = (config) => {
19082815dcSEvan Bacon  config = withGoogleMapsKey(config);
20082815dcSEvan Bacon
21082815dcSEvan Bacon  const apiKey = getGoogleMapsApiKey(config);
22082815dcSEvan Bacon  // Technically adds react-native-maps (Apple maps) and google maps.
23082815dcSEvan Bacon
24082815dcSEvan Bacon  debug('Google Maps API Key:', apiKey);
25082815dcSEvan Bacon  config = withMapsCocoaPods(config, { useGoogleMaps: !!apiKey });
26082815dcSEvan Bacon
27082815dcSEvan Bacon  // Adds/Removes AppDelegate setup for Google Maps API on iOS
28082815dcSEvan Bacon  config = withGoogleMapsAppDelegate(config, { apiKey });
29082815dcSEvan Bacon
30082815dcSEvan Bacon  return config;
31082815dcSEvan Bacon};
32082815dcSEvan Bacon
33082815dcSEvan Baconexport function getGoogleMapsApiKey(config: Pick<ExpoConfig, 'ios'>) {
34082815dcSEvan Bacon  return config.ios?.config?.googleMapsApiKey ?? null;
35082815dcSEvan Bacon}
36082815dcSEvan Bacon
37082815dcSEvan Baconexport function setGoogleMapsApiKey(
38082815dcSEvan Bacon  config: Pick<ExpoConfig, 'ios'>,
39082815dcSEvan Bacon  { GMSApiKey, ...infoPlist }: InfoPlist
40082815dcSEvan Bacon): InfoPlist {
41082815dcSEvan Bacon  const apiKey = getGoogleMapsApiKey(config);
42082815dcSEvan Bacon
43082815dcSEvan Bacon  if (apiKey === null) {
44082815dcSEvan Bacon    return infoPlist;
45082815dcSEvan Bacon  }
46082815dcSEvan Bacon
47082815dcSEvan Bacon  return {
48082815dcSEvan Bacon    ...infoPlist,
49082815dcSEvan Bacon    GMSApiKey: apiKey,
50082815dcSEvan Bacon  };
51082815dcSEvan Bacon}
52082815dcSEvan Bacon
53082815dcSEvan Baconexport function addGoogleMapsAppDelegateImport(src: string): MergeResults {
54082815dcSEvan Bacon  const newSrc = [];
55082815dcSEvan Bacon  newSrc.push(
56082815dcSEvan Bacon    '#if __has_include(<GoogleMaps/GoogleMaps.h>)',
57082815dcSEvan Bacon    '#import <GoogleMaps/GoogleMaps.h>',
58082815dcSEvan Bacon    '#endif'
59082815dcSEvan Bacon  );
60082815dcSEvan Bacon
61082815dcSEvan Bacon  return mergeContents({
62082815dcSEvan Bacon    tag: 'react-native-maps-import',
63082815dcSEvan Bacon    src,
64082815dcSEvan Bacon    newSrc: newSrc.join('\n'),
65082815dcSEvan Bacon    anchor: /#import "AppDelegate\.h"/,
66082815dcSEvan Bacon    offset: 1,
67082815dcSEvan Bacon    comment: '//',
68082815dcSEvan Bacon  });
69082815dcSEvan Bacon}
70082815dcSEvan Bacon
71082815dcSEvan Baconexport function removeGoogleMapsAppDelegateImport(src: string): MergeResults {
72082815dcSEvan Bacon  return removeContents({
73082815dcSEvan Bacon    tag: 'react-native-maps-import',
74082815dcSEvan Bacon    src,
75082815dcSEvan Bacon  });
76082815dcSEvan Bacon}
77082815dcSEvan Bacon
78082815dcSEvan Baconexport function addGoogleMapsAppDelegateInit(src: string, apiKey: string): MergeResults {
79082815dcSEvan Bacon  const newSrc = [];
80082815dcSEvan Bacon  newSrc.push(
81082815dcSEvan Bacon    '#if __has_include(<GoogleMaps/GoogleMaps.h>)',
82082815dcSEvan Bacon    `  [GMSServices provideAPIKey:@"${apiKey}"];`,
83082815dcSEvan Bacon    '#endif'
84082815dcSEvan Bacon  );
85082815dcSEvan Bacon
86082815dcSEvan Bacon  return mergeContents({
87082815dcSEvan Bacon    tag: 'react-native-maps-init',
88082815dcSEvan Bacon    src,
89082815dcSEvan Bacon    newSrc: newSrc.join('\n'),
90082815dcSEvan Bacon    anchor: MATCH_INIT,
91*857ad32fSBrent Vatne    offset: 2,
92082815dcSEvan Bacon    comment: '//',
93082815dcSEvan Bacon  });
94082815dcSEvan Bacon}
95082815dcSEvan Bacon
96082815dcSEvan Baconexport function removeGoogleMapsAppDelegateInit(src: string): MergeResults {
97082815dcSEvan Bacon  return removeContents({
98082815dcSEvan Bacon    tag: 'react-native-maps-init',
99082815dcSEvan Bacon    src,
100082815dcSEvan Bacon  });
101082815dcSEvan Bacon}
102082815dcSEvan Bacon
103082815dcSEvan Bacon/**
104082815dcSEvan Bacon * @param src The contents of the Podfile.
105082815dcSEvan Bacon * @returns Podfile with Google Maps added.
106082815dcSEvan Bacon */
107082815dcSEvan Baconexport function addMapsCocoaPods(src: string): MergeResults {
108082815dcSEvan Bacon  return mergeContents({
109082815dcSEvan Bacon    tag: 'react-native-maps',
110082815dcSEvan Bacon    src,
111082815dcSEvan Bacon    newSrc: `  pod 'react-native-google-maps', path: File.dirname(\`node --print "require.resolve('react-native-maps/package.json')"\`)`,
112082815dcSEvan Bacon    anchor: /use_native_modules/,
113082815dcSEvan Bacon    offset: 0,
114082815dcSEvan Bacon    comment: '#',
115082815dcSEvan Bacon  });
116082815dcSEvan Bacon}
117082815dcSEvan Bacon
118082815dcSEvan Baconexport function removeMapsCocoaPods(src: string): MergeResults {
119082815dcSEvan Bacon  return removeContents({
120082815dcSEvan Bacon    tag: 'react-native-maps',
121082815dcSEvan Bacon    src,
122082815dcSEvan Bacon  });
123082815dcSEvan Bacon}
124082815dcSEvan Bacon
125082815dcSEvan Baconfunction isReactNativeMapsInstalled(projectRoot: string): string | null {
126082815dcSEvan Bacon  const resolved = resolveFrom.silent(projectRoot, 'react-native-maps/package.json');
127082815dcSEvan Bacon  return resolved ? path.dirname(resolved) : null;
128082815dcSEvan Bacon}
129082815dcSEvan Bacon
130082815dcSEvan Baconfunction isReactNativeMapsAutolinked(config: Pick<ExpoConfig, '_internal'>): boolean {
131082815dcSEvan Bacon  // Only add the native code changes if we know that the package is going to be linked natively.
132082815dcSEvan Bacon  // This is specifically for monorepo support where one app might have react-native-maps (adding it to the node_modules)
133082815dcSEvan Bacon  // but another app will not have it installed in the package.json, causing it to not be linked natively.
134082815dcSEvan Bacon  // This workaround only exists because react-native-maps doesn't have a config plugin vendored in the package.
135082815dcSEvan Bacon
136082815dcSEvan Bacon  // TODO: `react-native-maps` doesn't use Expo autolinking so we cannot safely disable the module.
137082815dcSEvan Bacon  return true;
138082815dcSEvan Bacon
139082815dcSEvan Bacon  // return (
140082815dcSEvan Bacon  //   !config._internal?.autolinkedModules ||
141082815dcSEvan Bacon  //   config._internal.autolinkedModules.includes('react-native-maps')
142082815dcSEvan Bacon  // );
143082815dcSEvan Bacon}
144082815dcSEvan Bacon
145082815dcSEvan Baconconst withMapsCocoaPods: ConfigPlugin<{ useGoogleMaps: boolean }> = (config, { useGoogleMaps }) => {
146082815dcSEvan Bacon  return withDangerousMod(config, [
147082815dcSEvan Bacon    'ios',
148082815dcSEvan Bacon    async (config) => {
149082815dcSEvan Bacon      const filePath = path.join(config.modRequest.platformProjectRoot, 'Podfile');
150082815dcSEvan Bacon      const contents = await fs.promises.readFile(filePath, 'utf-8');
151082815dcSEvan Bacon      let results: MergeResults;
152082815dcSEvan Bacon      // Only add the block if react-native-maps is installed in the project (best effort).
153082815dcSEvan Bacon      // Generally prebuild runs after a yarn install so this should always work as expected.
154082815dcSEvan Bacon      const googleMapsPath = isReactNativeMapsInstalled(config.modRequest.projectRoot);
155082815dcSEvan Bacon      const isLinked = isReactNativeMapsAutolinked(config);
156082815dcSEvan Bacon      debug('Is Expo Autolinked:', isLinked);
157082815dcSEvan Bacon      debug('react-native-maps path:', googleMapsPath);
158082815dcSEvan Bacon      if (isLinked && googleMapsPath && useGoogleMaps) {
159082815dcSEvan Bacon        try {
160082815dcSEvan Bacon          results = addMapsCocoaPods(contents);
161082815dcSEvan Bacon        } catch (error: any) {
162082815dcSEvan Bacon          if (error.code === 'ERR_NO_MATCH') {
163082815dcSEvan Bacon            throw new Error(
164082815dcSEvan Bacon              `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.`
165082815dcSEvan Bacon            );
166082815dcSEvan Bacon          }
167082815dcSEvan Bacon          throw error;
168082815dcSEvan Bacon        }
169082815dcSEvan Bacon      } else {
170082815dcSEvan Bacon        // If the package is no longer installed, then remove the block.
171082815dcSEvan Bacon        results = removeMapsCocoaPods(contents);
172082815dcSEvan Bacon      }
173082815dcSEvan Bacon      if (results.didMerge || results.didClear) {
174082815dcSEvan Bacon        await fs.promises.writeFile(filePath, results.contents);
175082815dcSEvan Bacon      }
176082815dcSEvan Bacon      return config;
177082815dcSEvan Bacon    },
178082815dcSEvan Bacon  ]);
179082815dcSEvan Bacon};
180082815dcSEvan Bacon
181082815dcSEvan Baconconst withGoogleMapsAppDelegate: ConfigPlugin<{ apiKey: string | null }> = (config, { apiKey }) => {
182082815dcSEvan Bacon  return withAppDelegate(config, (config) => {
183082815dcSEvan Bacon    if (['objc', 'objcpp'].includes(config.modResults.language)) {
184082815dcSEvan Bacon      if (
185082815dcSEvan Bacon        apiKey &&
186082815dcSEvan Bacon        isReactNativeMapsAutolinked(config) &&
187082815dcSEvan Bacon        isReactNativeMapsInstalled(config.modRequest.projectRoot)
188082815dcSEvan Bacon      ) {
189082815dcSEvan Bacon        try {
190082815dcSEvan Bacon          config.modResults.contents = addGoogleMapsAppDelegateImport(
191082815dcSEvan Bacon            config.modResults.contents
192082815dcSEvan Bacon          ).contents;
193082815dcSEvan Bacon          config.modResults.contents = addGoogleMapsAppDelegateInit(
194082815dcSEvan Bacon            config.modResults.contents,
195082815dcSEvan Bacon            apiKey
196082815dcSEvan Bacon          ).contents;
197082815dcSEvan Bacon        } catch (error: any) {
198082815dcSEvan Bacon          if (error.code === 'ERR_NO_MATCH') {
199082815dcSEvan Bacon            throw new Error(
200082815dcSEvan Bacon              `Cannot add Google Maps to the project's AppDelegate because it's malformed. Please report this with a copy of your project AppDelegate.`
201082815dcSEvan Bacon            );
202082815dcSEvan Bacon          }
203082815dcSEvan Bacon          throw error;
204082815dcSEvan Bacon        }
205082815dcSEvan Bacon      } else {
206082815dcSEvan Bacon        config.modResults.contents = removeGoogleMapsAppDelegateImport(
207082815dcSEvan Bacon          config.modResults.contents
208082815dcSEvan Bacon        ).contents;
209082815dcSEvan Bacon        config.modResults.contents = removeGoogleMapsAppDelegateInit(
210082815dcSEvan Bacon          config.modResults.contents
211082815dcSEvan Bacon        ).contents;
212082815dcSEvan Bacon      }
213082815dcSEvan Bacon    } else {
214082815dcSEvan Bacon      throw new Error(
215082815dcSEvan Bacon        `Cannot setup Google Maps because the project AppDelegate is not a supported language: ${config.modResults.language}`
216082815dcSEvan Bacon      );
217082815dcSEvan Bacon    }
218082815dcSEvan Bacon    return config;
219082815dcSEvan Bacon  });
220082815dcSEvan Bacon};
221