1import { ExpoConfig } from '@expo/config-types';
2
3import { ConfigPlugin } from '../Plugin.types';
4import { withAndroidManifest } from '../plugins/android-plugins';
5import { AndroidManifest, ensureToolsAvailable, ManifestUsesPermission } from './Manifest';
6
7const USES_PERMISSION = 'uses-permission';
8
9export const withPermissions: ConfigPlugin<string[] | void> = (config, permissions) => {
10  if (Array.isArray(permissions)) {
11    permissions = permissions.filter(Boolean);
12    if (!config.android) config.android = {};
13    if (!config.android.permissions) config.android.permissions = [];
14    config.android.permissions = [
15      // @ts-ignore
16      ...new Set(config.android.permissions.concat(permissions)),
17    ];
18  }
19  return withAndroidManifest(config, async (config) => {
20    config.modResults = await setAndroidPermissions(config, config.modResults);
21    return config;
22  });
23};
24
25/** Given a permission or list of permissions, block permissions in the final `AndroidManifest.xml` to ensure no installed library or plugin can add them. */
26export const withBlockedPermissions: ConfigPlugin<string[] | string> = (config, permissions) => {
27  const resolvedPermissions = prefixAndroidPermissionsIfNecessary(
28    (Array.isArray(permissions) ? permissions : [permissions]).filter(Boolean)
29  );
30
31  if (config?.android?.permissions && Array.isArray(config.android.permissions)) {
32    // Remove any static config permissions
33    config.android.permissions = prefixAndroidPermissionsIfNecessary(
34      config.android.permissions
35    ).filter((permission) => !resolvedPermissions.includes(permission));
36  }
37
38  return withAndroidManifest(config, async (config) => {
39    config.modResults = ensureToolsAvailable(config.modResults);
40    config.modResults = addBlockedPermissions(config.modResults, resolvedPermissions);
41    return config;
42  });
43};
44
45export const withInternalBlockedPermissions: ConfigPlugin = (config) => {
46  // Only add permissions if the user defined the property and added some values
47  // this ensures we don't add the `tools:*` namespace extraneously.
48  if (config.android?.blockedPermissions?.length) {
49    return withBlockedPermissions(config, config.android.blockedPermissions);
50  }
51
52  return config;
53};
54
55export function addBlockedPermissions(androidManifest: AndroidManifest, permissions: string[]) {
56  if (!Array.isArray(androidManifest.manifest['uses-permission'])) {
57    androidManifest.manifest['uses-permission'] = [];
58  }
59
60  for (const permission of prefixAndroidPermissionsIfNecessary(permissions)) {
61    androidManifest.manifest['uses-permission'] = ensureBlockedPermission(
62      androidManifest.manifest['uses-permission'],
63      permission
64    );
65  }
66
67  return androidManifest;
68}
69
70/**
71 * Filter any existing permissions matching the provided permission name, then add a
72 * restricted permission to overwrite any extra permissions that may be added in a
73 * third-party package's AndroidManifest.xml.
74 *
75 * @param manifestPermissions manifest `uses-permissions` array.
76 * @param permission `android:name` of the permission to restrict
77 * @returns
78 */
79function ensureBlockedPermission(
80  manifestPermissions: ManifestUsesPermission[],
81  permission: string
82) {
83  // Remove permission if it currently exists
84  manifestPermissions = manifestPermissions.filter((e) => e.$['android:name'] !== permission);
85
86  // Add a permission with tools:node to overwrite any existing permission and ensure it's removed upon building.
87  manifestPermissions.push({
88    $: { 'android:name': permission, 'tools:node': 'remove' },
89  });
90  return manifestPermissions;
91}
92
93function prefixAndroidPermissionsIfNecessary(permissions: string[]): string[] {
94  return permissions.map((permission) => {
95    if (!permission.includes('.')) {
96      return `android.permission.${permission}`;
97    }
98    return permission;
99  });
100}
101
102export function getAndroidPermissions(config: Pick<ExpoConfig, 'android'>): string[] {
103  return config.android?.permissions ?? [];
104}
105
106export function setAndroidPermissions(
107  config: Pick<ExpoConfig, 'android'>,
108  androidManifest: AndroidManifest
109) {
110  const permissions = getAndroidPermissions(config);
111  const providedPermissions = prefixAndroidPermissionsIfNecessary(permissions);
112  const permissionsToAdd = [...providedPermissions];
113
114  if (!androidManifest.manifest.hasOwnProperty('uses-permission')) {
115    androidManifest.manifest['uses-permission'] = [];
116  }
117  // manifest.manifest['uses-permission'] = [];
118
119  const manifestPermissions = androidManifest.manifest['uses-permission'] ?? [];
120
121  permissionsToAdd.forEach((permission) => {
122    if (!isPermissionAlreadyRequested(permission, manifestPermissions)) {
123      addPermissionToManifest(permission, manifestPermissions);
124    }
125  });
126
127  return androidManifest;
128}
129
130export function isPermissionAlreadyRequested(
131  permission: string,
132  manifestPermissions: ManifestUsesPermission[]
133): boolean {
134  return manifestPermissions.some((e) => e.$['android:name'] === permission);
135}
136
137export function addPermissionToManifest(
138  permission: string,
139  manifestPermissions: ManifestUsesPermission[]
140) {
141  manifestPermissions.push({ $: { 'android:name': permission } });
142  return manifestPermissions;
143}
144
145export function removePermissions(androidManifest: AndroidManifest, permissionNames?: string[]) {
146  const targetNames = permissionNames ? permissionNames.map(ensurePermissionNameFormat) : null;
147  const permissions = androidManifest.manifest[USES_PERMISSION] || [];
148  const nextPermissions = [];
149  for (const attribute of permissions) {
150    if (targetNames) {
151      // @ts-ignore: name isn't part of the type
152      const value = attribute.$['android:name'] || attribute.$.name;
153      if (!targetNames.includes(value)) {
154        nextPermissions.push(attribute);
155      }
156    }
157  }
158
159  androidManifest.manifest[USES_PERMISSION] = nextPermissions;
160}
161
162export function addPermission(androidManifest: AndroidManifest, permissionName: string): void {
163  const usesPermissions: ManifestUsesPermission[] = androidManifest.manifest[USES_PERMISSION] || [];
164  usesPermissions.push({
165    $: { 'android:name': permissionName },
166  });
167  androidManifest.manifest[USES_PERMISSION] = usesPermissions;
168}
169
170export function ensurePermissions(
171  androidManifest: AndroidManifest,
172  permissionNames: string[]
173): { [permission: string]: boolean } {
174  const permissions = getPermissions(androidManifest);
175
176  const results: { [permission: string]: boolean } = {};
177  for (const permissionName of permissionNames) {
178    const targetName = ensurePermissionNameFormat(permissionName);
179    if (!permissions.includes(targetName)) {
180      addPermission(androidManifest, targetName);
181      results[permissionName] = true;
182    } else {
183      results[permissionName] = false;
184    }
185  }
186  return results;
187}
188
189export function ensurePermission(
190  androidManifest: AndroidManifest,
191  permissionName: string
192): boolean {
193  const permissions = getPermissions(androidManifest);
194  const targetName = ensurePermissionNameFormat(permissionName);
195
196  if (!permissions.includes(targetName)) {
197    addPermission(androidManifest, targetName);
198    return true;
199  }
200  return false;
201}
202
203export function ensurePermissionNameFormat(permissionName: string): string {
204  if (permissionName.includes('.')) {
205    const com = permissionName.split('.');
206    const name = com.pop() as string;
207    return [...com, name.toUpperCase()].join('.');
208  } else {
209    // If shorthand form like `WRITE_CONTACTS` is provided, expand it to `android.permission.WRITE_CONTACTS`.
210    return ensurePermissionNameFormat(`android.permission.${permissionName}`);
211  }
212}
213
214export function getPermissions(androidManifest: AndroidManifest): string[] {
215  const usesPermissions: { [key: string]: any }[] = androidManifest.manifest[USES_PERMISSION] || [];
216  const permissions = usesPermissions.map((permissionObject) => {
217    return permissionObject.$['android:name'] || permissionObject.$.name;
218  });
219  return permissions;
220}
221