1import assert from 'assert';
2import fs from 'fs';
3import path from 'path';
4
5import * as XML from '../utils/XML';
6
7export type StringBoolean = 'true' | 'false';
8
9type ManifestMetaDataAttributes = AndroidManifestAttributes & {
10  'android:value'?: string;
11  'android:resource'?: string;
12};
13
14type AndroidManifestAttributes = {
15  'android:name': string | 'android.intent.action.VIEW';
16  'tools:node'?: string | 'remove';
17};
18
19type ManifestAction = {
20  $: AndroidManifestAttributes;
21};
22
23type ManifestCategory = {
24  $: AndroidManifestAttributes;
25};
26
27type ManifestData = {
28  $: {
29    [key: string]: string | undefined;
30    'android:host'?: string;
31    'android:pathPrefix'?: string;
32    'android:scheme'?: string;
33  };
34};
35
36type ManifestReceiver = {
37  $: AndroidManifestAttributes & {
38    'android:exported'?: StringBoolean;
39    'android:enabled'?: StringBoolean;
40  };
41  'intent-filter'?: ManifestIntentFilter[];
42};
43
44export type ManifestIntentFilter = {
45  $?: {
46    'android:autoVerify'?: StringBoolean;
47    'data-generated'?: StringBoolean;
48  };
49  action?: ManifestAction[];
50  data?: ManifestData[];
51  category?: ManifestCategory[];
52};
53
54export type ManifestMetaData = {
55  $: ManifestMetaDataAttributes;
56};
57
58type ManifestServiceAttributes = AndroidManifestAttributes & {
59  'android:enabled'?: StringBoolean;
60  'android:exported'?: StringBoolean;
61  'android:permission'?: string;
62  // ...
63};
64
65type ManifestService = {
66  $: ManifestServiceAttributes;
67  'intent-filter'?: ManifestIntentFilter[];
68};
69
70type ManifestApplicationAttributes = {
71  'android:name': string | '.MainApplication';
72  'android:icon'?: string;
73  'android:roundIcon'?: string;
74  'android:label'?: string;
75  'android:allowBackup'?: StringBoolean;
76  'android:largeHeap'?: StringBoolean;
77  'android:requestLegacyExternalStorage'?: StringBoolean;
78  'android:usesCleartextTraffic'?: StringBoolean;
79  [key: string]: string | undefined;
80};
81
82export type ManifestActivity = {
83  $: ManifestApplicationAttributes & {
84    'android:exported'?: StringBoolean;
85    'android:launchMode'?: string;
86    'android:theme'?: string;
87    'android:windowSoftInputMode'?:
88      | string
89      | 'stateUnspecified'
90      | 'stateUnchanged'
91      | 'stateHidden'
92      | 'stateAlwaysHidden'
93      | 'stateVisible'
94      | 'stateAlwaysVisible'
95      | 'adjustUnspecified'
96      | 'adjustResize'
97      | 'adjustPan';
98    [key: string]: string | undefined;
99  };
100  'intent-filter'?: ManifestIntentFilter[];
101  // ...
102};
103
104export type ManifestUsesLibrary = {
105  $: AndroidManifestAttributes & {
106    'android:required'?: StringBoolean;
107  };
108};
109
110export type ManifestApplication = {
111  $: ManifestApplicationAttributes;
112  activity?: ManifestActivity[];
113  service?: ManifestService[];
114  receiver?: ManifestReceiver[];
115  'meta-data'?: ManifestMetaData[];
116  'uses-library'?: ManifestUsesLibrary[];
117  // ...
118};
119
120type ManifestPermission = {
121  $: AndroidManifestAttributes & {
122    'android:protectionLevel'?: string | 'signature';
123  };
124};
125
126export type ManifestUsesPermission = {
127  $: AndroidManifestAttributes;
128};
129
130type ManifestUsesFeature = {
131  $: AndroidManifestAttributes & {
132    'android:glEsVersion'?: string;
133    'android:required': StringBoolean;
134  };
135};
136
137export type AndroidManifest = {
138  manifest: {
139    // Probably more, but this is currently all we'd need for most cases in Expo.
140    $: {
141      'xmlns:android': string;
142      'xmlns:tools'?: string;
143      package?: string;
144      [key: string]: string | undefined;
145    };
146    permission?: ManifestPermission[];
147    'uses-permission'?: ManifestUsesPermission[];
148    'uses-permission-sdk-23'?: ManifestUsesPermission[];
149    'uses-feature'?: ManifestUsesFeature[];
150    application?: ManifestApplication[];
151  };
152};
153
154export async function writeAndroidManifestAsync(
155  manifestPath: string,
156  androidManifest: AndroidManifest
157): Promise<void> {
158  const manifestXml = XML.format(androidManifest);
159  await fs.promises.mkdir(path.dirname(manifestPath), { recursive: true });
160  await fs.promises.writeFile(manifestPath, manifestXml);
161}
162
163export async function readAndroidManifestAsync(manifestPath: string): Promise<AndroidManifest> {
164  const xml = await XML.readXMLAsync({ path: manifestPath });
165  if (!isManifest(xml)) {
166    throw new Error('Invalid manifest found at: ' + manifestPath);
167  }
168  return xml;
169}
170
171function isManifest(xml: XML.XMLObject): xml is AndroidManifest {
172  // TODO: Maybe more validation
173  return !!xml.manifest;
174}
175
176/** Returns the `manifest.application` tag ending in `.MainApplication` */
177export function getMainApplication(androidManifest: AndroidManifest): ManifestApplication | null {
178  return (
179    androidManifest?.manifest?.application?.filter(
180      (e) => e?.$?.['android:name'].endsWith('.MainApplication')
181    )[0] ?? null
182  );
183}
184
185export function getMainApplicationOrThrow(androidManifest: AndroidManifest): ManifestApplication {
186  const mainApplication = getMainApplication(androidManifest);
187  assert(mainApplication, 'AndroidManifest.xml is missing the required MainApplication element');
188  return mainApplication;
189}
190
191export function getMainActivityOrThrow(androidManifest: AndroidManifest): ManifestActivity {
192  const mainActivity = getMainActivity(androidManifest);
193  assert(mainActivity, 'AndroidManifest.xml is missing the required MainActivity element');
194  return mainActivity;
195}
196
197export function getRunnableActivity(androidManifest: AndroidManifest): ManifestActivity | null {
198  // Get enabled activities
199  const enabledActivities = androidManifest?.manifest?.application?.[0]?.activity?.filter?.(
200    (e: any) => e.$['android:enabled'] !== 'false' && e.$['android:enabled'] !== false
201  );
202
203  if (!enabledActivities) {
204    return null;
205  }
206
207  // Get the activity that has a runnable intent-filter
208  for (const activity of enabledActivities) {
209    if (Array.isArray(activity['intent-filter'])) {
210      for (const intentFilter of activity['intent-filter']) {
211        if (
212          intentFilter.action?.find(
213            (action) => action.$['android:name'] === 'android.intent.action.MAIN'
214          ) &&
215          intentFilter.category?.find(
216            (category) => category.$['android:name'] === 'android.intent.category.LAUNCHER'
217          )
218        ) {
219          return activity;
220        }
221      }
222    }
223  }
224
225  return null;
226}
227
228export function getMainActivity(androidManifest: AndroidManifest): ManifestActivity | null {
229  const mainActivity = androidManifest?.manifest?.application?.[0]?.activity?.filter?.(
230    (e: any) => e.$['android:name'] === '.MainActivity'
231  );
232  return mainActivity?.[0] ?? null;
233}
234
235export function addMetaDataItemToMainApplication(
236  mainApplication: ManifestApplication,
237  itemName: string,
238  itemValue: string,
239  itemType: 'resource' | 'value' = 'value'
240): ManifestApplication {
241  let existingMetaDataItem;
242  const newItem = {
243    $: prefixAndroidKeys({ name: itemName, [itemType]: itemValue }),
244  } as ManifestMetaData;
245  if (mainApplication['meta-data']) {
246    existingMetaDataItem = mainApplication['meta-data'].filter(
247      (e: any) => e.$['android:name'] === itemName
248    );
249    if (existingMetaDataItem.length) {
250      existingMetaDataItem[0].$[`android:${itemType}` as keyof ManifestMetaDataAttributes] =
251        itemValue;
252    } else {
253      mainApplication['meta-data'].push(newItem);
254    }
255  } else {
256    mainApplication['meta-data'] = [newItem];
257  }
258  return mainApplication;
259}
260
261export function removeMetaDataItemFromMainApplication(mainApplication: any, itemName: string) {
262  const index = findMetaDataItem(mainApplication, itemName);
263  if (mainApplication?.['meta-data'] && index > -1) {
264    mainApplication['meta-data'].splice(index, 1);
265  }
266  return mainApplication;
267}
268
269function findApplicationSubItem(
270  mainApplication: ManifestApplication,
271  category: keyof ManifestApplication,
272  itemName: string
273): number {
274  const parent = mainApplication[category];
275  if (Array.isArray(parent)) {
276    const index = parent.findIndex((e: any) => e.$['android:name'] === itemName);
277
278    return index;
279  }
280  return -1;
281}
282
283export function findMetaDataItem(mainApplication: any, itemName: string): number {
284  return findApplicationSubItem(mainApplication, 'meta-data', itemName);
285}
286
287export function findUsesLibraryItem(mainApplication: any, itemName: string): number {
288  return findApplicationSubItem(mainApplication, 'uses-library', itemName);
289}
290
291export function getMainApplicationMetaDataValue(
292  androidManifest: AndroidManifest,
293  name: string
294): string | null {
295  const mainApplication = getMainApplication(androidManifest);
296
297  if (mainApplication?.hasOwnProperty('meta-data')) {
298    const item = mainApplication?.['meta-data']?.find((e: any) => e.$['android:name'] === name);
299    return item?.$['android:value'] ?? null;
300  }
301
302  return null;
303}
304
305export function addUsesLibraryItemToMainApplication(
306  mainApplication: ManifestApplication,
307  item: { name: string; required?: boolean }
308): ManifestApplication {
309  let existingMetaDataItem;
310  const newItem = {
311    $: prefixAndroidKeys(item),
312  } as ManifestUsesLibrary;
313
314  if (mainApplication['uses-library']) {
315    existingMetaDataItem = mainApplication['uses-library'].filter(
316      (e) => e.$['android:name'] === item.name
317    );
318    if (existingMetaDataItem.length) {
319      existingMetaDataItem[0].$ = newItem.$;
320    } else {
321      mainApplication['uses-library'].push(newItem);
322    }
323  } else {
324    mainApplication['uses-library'] = [newItem];
325  }
326  return mainApplication;
327}
328
329export function removeUsesLibraryItemFromMainApplication(
330  mainApplication: ManifestApplication,
331  itemName: string
332) {
333  const index = findUsesLibraryItem(mainApplication, itemName);
334  if (mainApplication?.['uses-library'] && index > -1) {
335    mainApplication['uses-library'].splice(index, 1);
336  }
337  return mainApplication;
338}
339
340export function prefixAndroidKeys<T extends Record<string, any> = Record<string, string>>(
341  head: T
342): Record<string, any> {
343  // prefix all keys with `android:`
344  return Object.entries(head).reduce(
345    (prev, [key, curr]) => ({ ...prev, [`android:${key}`]: curr }),
346    {} as T
347  );
348}
349
350/**
351 * Ensure the `tools:*` namespace is available in the manifest.
352 *
353 * @param manifest AndroidManifest.xml
354 * @returns manifest with the `tools:*` namespace available
355 */
356export function ensureToolsAvailable(manifest: AndroidManifest) {
357  return ensureManifestHasNamespace(manifest, {
358    namespace: 'xmlns:tools',
359    url: 'http://schemas.android.com/tools',
360  });
361}
362
363/**
364 * Ensure a particular namespace is available in the manifest.
365 *
366 * @param manifest `AndroidManifest.xml`
367 * @returns manifest with the provided namespace available
368 */
369function ensureManifestHasNamespace(
370  manifest: AndroidManifest,
371  { namespace, url }: { namespace: string; url: string }
372) {
373  if (manifest?.manifest?.$?.[namespace]) {
374    return manifest;
375  }
376  manifest.manifest.$[namespace] = url;
377  return manifest;
378}
379