1import { ExpoConfig } from '@expo/config-types';
2
3import { AndroidManifest, ManifestActivity } from './Manifest';
4import { createAndroidManifestPlugin } from '../plugins/android-plugins';
5import { addWarningAndroid } from '../utils/warnings';
6
7export type IntentFilterProps = {
8  actions: string[];
9  categories: string[];
10  data: {
11    scheme: string;
12    host?: string;
13  }[];
14};
15
16export const withScheme = createAndroidManifestPlugin(setScheme, 'withScheme');
17
18export function getScheme(config: { scheme?: string | string[] }): string[] {
19  if (Array.isArray(config.scheme)) {
20    const validate = (value: any): value is string => typeof value === 'string';
21
22    return config.scheme.filter<string>(validate);
23  } else if (typeof config.scheme === 'string') {
24    return [config.scheme];
25  }
26  return [];
27}
28
29// This plugin used to remove the unused schemes but this is unpredictable because other plugins could add schemes.
30// The only way to reliably remove schemes from the project is to nuke the file and regenerate the code (`npx expo prebuild --clean`).
31// Regardless, having extra schemes isn't a fatal issue and therefore a tolerable compromise is to just add new schemes that aren't currently present.
32export function setScheme(
33  config: Pick<ExpoConfig, 'scheme' | 'android'>,
34  androidManifest: AndroidManifest
35) {
36  const schemes = [
37    ...getScheme(config),
38    // @ts-ignore: TODO: android.scheme is an unreleased -- harder to add to turtle v1.
39    ...getScheme(config.android ?? {}),
40  ];
41  // Add the package name to the list of schemes for easier Google auth and parity with Turtle v1.
42  if (config.android?.package) {
43    schemes.push(config.android.package);
44  }
45  if (schemes.length === 0) {
46    return androidManifest;
47  }
48
49  if (!ensureManifestHasValidIntentFilter(androidManifest)) {
50    addWarningAndroid(
51      'scheme',
52      `Cannot add schemes because the provided manifest does not have a valid Activity with \`android:launchMode="singleTask"\``,
53      'https://expo.fyi/setup-android-uri-scheme'
54    );
55    return androidManifest;
56  }
57
58  // Get the current schemes and remove them from the list of schemes to add.
59  const currentSchemes = getSchemesFromManifest(androidManifest);
60  for (const uri of currentSchemes) {
61    const index = schemes.indexOf(uri);
62    if (index > -1) schemes.splice(index, 1);
63  }
64
65  // Now add all of the remaining schemes.
66  for (const uri of schemes) {
67    androidManifest = appendScheme(uri, androidManifest);
68  }
69
70  return androidManifest;
71}
72
73function isValidRedirectIntentFilter({ actions, categories }: IntentFilterProps): boolean {
74  return (
75    actions.includes('android.intent.action.VIEW') &&
76    !categories.includes('android.intent.category.LAUNCHER')
77  );
78}
79
80function propertiesFromIntentFilter(intentFilter: any): IntentFilterProps {
81  const actions = intentFilter?.action?.map((data: any) => data?.$?.['android:name']) ?? [];
82  const categories = intentFilter?.category?.map((data: any) => data?.$?.['android:name']) ?? [];
83  const data =
84    intentFilter?.data
85      ?.filter((data: any) => data?.$?.['android:scheme'])
86      ?.map((data: any) => ({
87        scheme: data?.$?.['android:scheme'],
88        host: data?.$?.['android:host'],
89      })) ?? [];
90  return {
91    actions,
92    categories,
93    data,
94  };
95}
96
97function getSingleTaskIntentFilters(androidManifest: AndroidManifest): any[] {
98  if (!Array.isArray(androidManifest.manifest.application)) return [];
99
100  let outputSchemes: any[] = [];
101  for (const application of androidManifest.manifest.application) {
102    const { activity } = application;
103    // @ts-ignore
104    const activities = Array.isArray(activity) ? activity : [activity];
105    const singleTaskActivities = (activities as ManifestActivity[]).filter(
106      (activity) => activity?.$?.['android:launchMode'] === 'singleTask'
107    );
108    for (const activity of singleTaskActivities) {
109      const intentFilters = activity['intent-filter'];
110      outputSchemes = outputSchemes.concat(intentFilters);
111    }
112  }
113  return outputSchemes;
114}
115
116export function getSchemesFromManifest(
117  androidManifest: AndroidManifest,
118  requestedHost: string | null = null
119): string[] {
120  const outputSchemes: string[] = [];
121
122  const singleTaskIntentFilters = getSingleTaskIntentFilters(androidManifest);
123  for (const intentFilter of singleTaskIntentFilters) {
124    const properties = propertiesFromIntentFilter(intentFilter);
125    if (isValidRedirectIntentFilter(properties) && properties.data) {
126      for (const { scheme, host } of properties.data) {
127        if (requestedHost === null || !host || host === requestedHost) {
128          outputSchemes.push(scheme);
129        }
130      }
131    }
132  }
133
134  return outputSchemes;
135}
136
137export function ensureManifestHasValidIntentFilter(androidManifest: AndroidManifest): boolean {
138  if (!Array.isArray(androidManifest.manifest.application)) {
139    return false;
140  }
141
142  for (const application of androidManifest.manifest.application) {
143    for (const activity of application.activity || []) {
144      if (activity?.$?.['android:launchMode'] === 'singleTask') {
145        for (const intentFilter of activity['intent-filter'] || []) {
146          // Parse valid intent filters...
147          const properties = propertiesFromIntentFilter(intentFilter);
148          if (isValidRedirectIntentFilter(properties)) {
149            return true;
150          }
151        }
152        if (!activity['intent-filter']) {
153          activity['intent-filter'] = [];
154        }
155
156        activity['intent-filter'].push({
157          action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
158          category: [
159            { $: { 'android:name': 'android.intent.category.DEFAULT' } },
160            { $: { 'android:name': 'android.intent.category.BROWSABLE' } },
161          ],
162        });
163        return true;
164      }
165    }
166  }
167  return false;
168}
169
170export function hasScheme(scheme: string, androidManifest: AndroidManifest): boolean {
171  const schemes = getSchemesFromManifest(androidManifest);
172  return schemes.includes(scheme);
173}
174
175export function appendScheme(scheme: string, androidManifest: AndroidManifest): AndroidManifest {
176  if (!Array.isArray(androidManifest.manifest.application)) {
177    return androidManifest;
178  }
179
180  for (const application of androidManifest.manifest.application) {
181    for (const activity of application.activity || []) {
182      if (activity?.$?.['android:launchMode'] === 'singleTask') {
183        for (const intentFilter of activity['intent-filter'] || []) {
184          const properties = propertiesFromIntentFilter(intentFilter);
185          if (isValidRedirectIntentFilter(properties)) {
186            if (!intentFilter.data) intentFilter.data = [];
187            intentFilter.data.push({
188              $: { 'android:scheme': scheme },
189            });
190          }
191        }
192        break;
193      }
194    }
195  }
196  return androidManifest;
197}
198
199export function removeScheme(scheme: string, androidManifest: AndroidManifest): AndroidManifest {
200  if (!Array.isArray(androidManifest.manifest.application)) {
201    return androidManifest;
202  }
203
204  for (const application of androidManifest.manifest.application) {
205    for (const activity of application.activity || []) {
206      if (activity?.$?.['android:launchMode'] === 'singleTask') {
207        for (const intentFilter of activity['intent-filter'] || []) {
208          // Parse valid intent filters...
209          const properties = propertiesFromIntentFilter(intentFilter);
210          if (isValidRedirectIntentFilter(properties)) {
211            for (const dataKey in intentFilter?.data || []) {
212              const data = intentFilter.data?.[dataKey];
213              if (data?.$?.['android:scheme'] === scheme) {
214                delete intentFilter.data?.[dataKey];
215              }
216            }
217          }
218        }
219        break;
220      }
221    }
222  }
223
224  return androidManifest;
225}
226