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