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