1import { promises } from 'fs';
2import path from 'path';
3
4import { ExportedConfig, ModConfig } from '../Plugin.types';
5import { Colors, Manifest, Paths, Properties, Resources, Strings, Styles } from '../android';
6import { AndroidManifest } from '../android/Manifest';
7import { parseXMLAsync, writeXMLAsync } from '../utils/XML';
8import { reverseSortString, sortObject, sortObjWithOrder } from '../utils/sortObject';
9import { ForwardedBaseModOptions, provider, withGeneratedBaseMods } from './createBaseMod';
10
11const { readFile, writeFile } = promises;
12
13type AndroidModName = keyof Required<ModConfig>['android'];
14
15function getAndroidManifestTemplate(config: ExportedConfig) {
16  // Keep in sync with https://github.com/expo/expo/blob/master/templates/expo-template-bare-minimum/android/app/src/main/AndroidManifest.xml
17  // TODO: Read from remote template when possible
18  return parseXMLAsync(`
19  <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${
20    config.android?.package ?? 'com.placeholder.appid'
21  }">
22
23    <uses-permission android:name="android.permission.INTERNET"/>
24    <!-- OPTIONAL PERMISSIONS, REMOVE WHATEVER YOU DO NOT NEED -->
25    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
26    <!-- These require runtime permissions on M -->
27    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
28    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
29    <!-- END OPTIONAL PERMISSIONS -->
30
31    <queries>
32      <!-- Support checking for http(s) links via the Linking API -->
33      <intent>
34        <action android:name="android.intent.action.VIEW" />
35        <category android:name="android.intent.category.BROWSABLE" />
36        <data android:scheme="https" />
37      </intent>
38    </queries>
39
40    <application
41      android:name=".MainApplication"
42      android:label="@string/app_name"
43      android:icon="@mipmap/ic_launcher"
44      android:roundIcon="@mipmap/ic_launcher_round"
45      android:allowBackup="false"
46      android:theme="@style/AppTheme"
47      android:usesCleartextTraffic="true"
48    >
49      <meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="YOUR-APP-URL-HERE"/>
50      <meta-data android:name="expo.modules.updates.EXPO_SDK_VERSION" android:value="YOUR-APP-SDK-VERSION-HERE"/>
51      <activity
52        android:name=".MainActivity"
53        android:label="@string/app_name"
54        android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
55        android:launchMode="singleTask"
56        android:windowSoftInputMode="adjustResize"
57        android:theme="@style/Theme.App.SplashScreen"
58      >
59        <intent-filter>
60          <action android:name="android.intent.action.MAIN"/>
61          <category android:name="android.intent.category.LAUNCHER"/>
62        </intent-filter>
63      </activity>
64      <activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
65    </application>
66  </manifest>
67  `) as Promise<AndroidManifest>;
68}
69
70export function sortAndroidManifest(obj: AndroidManifest) {
71  if (obj.manifest) {
72    // Reverse sort so application is last and permissions are first
73    obj.manifest = sortObject(obj.manifest, reverseSortString);
74
75    if (Array.isArray(obj.manifest['uses-permission'])) {
76      // Sort permissions alphabetically
77      obj.manifest['uses-permission'].sort((a, b) => {
78        if (a.$['android:name'] < b.$['android:name']) return -1;
79        if (a.$['android:name'] > b.$['android:name']) return 1;
80        return 0;
81      });
82    }
83
84    if (Array.isArray(obj.manifest.application)) {
85      // reverse sort applications so activity is towards the end and meta-data is towards the front.
86      obj.manifest.application = obj.manifest.application.map((application) => {
87        application = sortObjWithOrder(application, ['meta-data', 'service', 'activity']);
88
89        if (Array.isArray(application['meta-data'])) {
90          // Sort metadata alphabetically
91          application['meta-data'].sort((a, b) => {
92            if (a.$['android:name'] < b.$['android:name']) return -1;
93            if (a.$['android:name'] > b.$['android:name']) return 1;
94            return 0;
95          });
96        }
97        return application;
98      });
99    }
100  }
101  return obj;
102}
103
104const defaultProviders = {
105  dangerous: provider<unknown>({
106    getFilePath() {
107      return '';
108    },
109    async read() {
110      return { filePath: '', modResults: {} };
111    },
112    async write() {},
113  }),
114
115  // Append a rule to supply gradle.properties data to mods on `mods.android.gradleProperties`
116  manifest: provider<Manifest.AndroidManifest>({
117    isIntrospective: true,
118    getFilePath({ modRequest: { platformProjectRoot } }) {
119      return path.join(platformProjectRoot, 'app/src/main/AndroidManifest.xml');
120    },
121    async read(filePath, config) {
122      try {
123        return await Manifest.readAndroidManifestAsync(filePath);
124      } catch (error: any) {
125        if (!config.modRequest.introspect) {
126          throw error;
127        }
128      }
129      return await getAndroidManifestTemplate(config);
130    },
131    async write(filePath, { modResults, modRequest: { introspect } }) {
132      if (introspect) return;
133      await Manifest.writeAndroidManifestAsync(filePath, sortAndroidManifest(modResults));
134    },
135  }),
136
137  // Append a rule to supply gradle.properties data to mods on `mods.android.gradleProperties`
138  gradleProperties: provider<Properties.PropertiesItem[]>({
139    isIntrospective: true,
140
141    getFilePath({ modRequest: { platformProjectRoot } }) {
142      return path.join(platformProjectRoot, 'gradle.properties');
143    },
144    async read(filePath, config) {
145      try {
146        return await Properties.parsePropertiesFile(await readFile(filePath, 'utf8'));
147      } catch (error) {
148        if (!config.modRequest.introspect) {
149          throw error;
150        }
151      }
152      return [];
153    },
154    async write(filePath, { modResults, modRequest: { introspect } }) {
155      if (introspect) return;
156      await writeFile(filePath, Properties.propertiesListToString(modResults));
157    },
158  }),
159
160  // Append a rule to supply strings.xml data to mods on `mods.android.strings`
161  strings: provider<Resources.ResourceXML>({
162    isIntrospective: true,
163
164    async getFilePath({ modRequest: { projectRoot, introspect } }) {
165      try {
166        return await Strings.getProjectStringsXMLPathAsync(projectRoot);
167      } catch (error: any) {
168        if (!introspect) {
169          throw error;
170        }
171      }
172      return '';
173    },
174
175    async read(filePath, config) {
176      try {
177        return await Resources.readResourcesXMLAsync({ path: filePath });
178      } catch (error) {
179        if (!config.modRequest.introspect) {
180          throw error;
181        }
182      }
183      return { resources: {} };
184    },
185    async write(filePath, { modResults, modRequest: { introspect } }) {
186      if (introspect) return;
187      await writeXMLAsync({ path: filePath, xml: modResults });
188    },
189  }),
190
191  colors: provider<Resources.ResourceXML>({
192    isIntrospective: true,
193
194    async getFilePath({ modRequest: { projectRoot, introspect } }) {
195      try {
196        return await Colors.getProjectColorsXMLPathAsync(projectRoot);
197      } catch (error: any) {
198        if (!introspect) {
199          throw error;
200        }
201      }
202      return '';
203    },
204
205    async read(filePath, { modRequest: { introspect } }) {
206      try {
207        return await Resources.readResourcesXMLAsync({ path: filePath });
208      } catch (error: any) {
209        if (!introspect) {
210          throw error;
211        }
212      }
213      return { resources: {} };
214    },
215    async write(filePath, { modResults, modRequest: { introspect } }) {
216      if (introspect) return;
217      await writeXMLAsync({ path: filePath, xml: modResults });
218    },
219  }),
220
221  colorsNight: provider<Resources.ResourceXML>({
222    isIntrospective: true,
223
224    async getFilePath({ modRequest: { projectRoot, introspect } }) {
225      try {
226        return await Colors.getProjectColorsXMLPathAsync(projectRoot, { kind: 'values-night' });
227      } catch (error: any) {
228        if (!introspect) {
229          throw error;
230        }
231      }
232      return '';
233    },
234    async read(filePath, config) {
235      try {
236        return await Resources.readResourcesXMLAsync({ path: filePath });
237      } catch (error: any) {
238        if (!config.modRequest.introspect) {
239          throw error;
240        }
241      }
242      return { resources: {} };
243    },
244    async write(filePath, { modResults, modRequest: { introspect } }) {
245      if (introspect) return;
246      await writeXMLAsync({ path: filePath, xml: modResults });
247    },
248  }),
249
250  styles: provider<Resources.ResourceXML>({
251    isIntrospective: true,
252
253    async getFilePath({ modRequest: { projectRoot, introspect } }) {
254      try {
255        return await Styles.getProjectStylesXMLPathAsync(projectRoot);
256      } catch (error: any) {
257        if (!introspect) {
258          throw error;
259        }
260      }
261      return '';
262    },
263    async read(filePath, config) {
264      let styles: Resources.ResourceXML = { resources: {} };
265
266      try {
267        // Adds support for `tools:x`
268        styles = await Resources.readResourcesXMLAsync({
269          path: filePath,
270          fallback: `<?xml version="1.0" encoding="utf-8"?><resources xmlns:tools="http://schemas.android.com/tools"></resources>`,
271        });
272      } catch (error: any) {
273        if (!config.modRequest.introspect) {
274          throw error;
275        }
276      }
277
278      // Ensure support for tools is added...
279      if (!styles.resources.$) {
280        styles.resources.$ = {};
281      }
282      if (!styles.resources.$?.['xmlns:tools']) {
283        styles.resources.$['xmlns:tools'] = 'http://schemas.android.com/tools';
284      }
285      return styles;
286    },
287    async write(filePath, { modResults, modRequest: { introspect } }) {
288      if (introspect) return;
289      await writeXMLAsync({ path: filePath, xml: modResults });
290    },
291  }),
292
293  projectBuildGradle: provider<Paths.GradleProjectFile>({
294    getFilePath({ modRequest: { projectRoot } }) {
295      return Paths.getProjectBuildGradleFilePath(projectRoot);
296    },
297    async read(filePath) {
298      return Paths.getFileInfo(filePath);
299    },
300    async write(filePath, { modResults: { contents } }) {
301      await writeFile(filePath, contents);
302    },
303  }),
304
305  settingsGradle: provider<Paths.GradleProjectFile>({
306    getFilePath({ modRequest: { projectRoot } }) {
307      return Paths.getSettingsGradleFilePath(projectRoot);
308    },
309    async read(filePath) {
310      return Paths.getFileInfo(filePath);
311    },
312    async write(filePath, { modResults: { contents } }) {
313      await writeFile(filePath, contents);
314    },
315  }),
316
317  appBuildGradle: provider<Paths.GradleProjectFile>({
318    getFilePath({ modRequest: { projectRoot } }) {
319      return Paths.getAppBuildGradleFilePath(projectRoot);
320    },
321    async read(filePath) {
322      return Paths.getFileInfo(filePath);
323    },
324    async write(filePath, { modResults: { contents } }) {
325      await writeFile(filePath, contents);
326    },
327  }),
328
329  mainActivity: provider<Paths.ApplicationProjectFile>({
330    getFilePath({ modRequest: { projectRoot } }) {
331      return Paths.getProjectFilePath(projectRoot, 'MainActivity');
332    },
333    async read(filePath) {
334      return Paths.getFileInfo(filePath);
335    },
336    async write(filePath, { modResults: { contents } }) {
337      await writeFile(filePath, contents);
338    },
339  }),
340
341  mainApplication: provider<Paths.ApplicationProjectFile>({
342    getFilePath({ modRequest: { projectRoot } }) {
343      return Paths.getProjectFilePath(projectRoot, 'MainApplication');
344    },
345    async read(filePath) {
346      return Paths.getFileInfo(filePath);
347    },
348    async write(filePath, { modResults: { contents } }) {
349      await writeFile(filePath, contents);
350    },
351  }),
352};
353
354type AndroidDefaultProviders = typeof defaultProviders;
355
356export function withAndroidBaseMods(
357  config: ExportedConfig,
358  {
359    providers,
360    ...props
361  }: ForwardedBaseModOptions & { providers?: Partial<AndroidDefaultProviders> } = {}
362): ExportedConfig {
363  return withGeneratedBaseMods<AndroidModName>(config, {
364    ...props,
365    platform: 'android',
366    providers: providers ?? getAndroidModFileProviders(),
367  });
368}
369
370export function getAndroidModFileProviders() {
371  return defaultProviders;
372}
373