1082815dcSEvan Baconimport { ExpoConfig } from '@expo/config-types'; 2082815dcSEvan Bacon 3*8a424bebSJames Ideimport { AndroidManifest, ManifestActivity } from './Manifest'; 4082815dcSEvan Baconimport { createAndroidManifestPlugin } from '../plugins/android-plugins'; 5082815dcSEvan Baconimport { addWarningAndroid } from '../utils/warnings'; 6082815dcSEvan Bacon 7082815dcSEvan Baconexport type IntentFilterProps = { 8082815dcSEvan Bacon actions: string[]; 9082815dcSEvan Bacon categories: string[]; 10082815dcSEvan Bacon data: { 11082815dcSEvan Bacon scheme: string; 12082815dcSEvan Bacon host?: string; 13082815dcSEvan Bacon }[]; 14082815dcSEvan Bacon}; 15082815dcSEvan Bacon 16082815dcSEvan Baconexport const withScheme = createAndroidManifestPlugin(setScheme, 'withScheme'); 17082815dcSEvan Bacon 18082815dcSEvan Baconexport function getScheme(config: { scheme?: string | string[] }): string[] { 19082815dcSEvan Bacon if (Array.isArray(config.scheme)) { 20082815dcSEvan Bacon const validate = (value: any): value is string => typeof value === 'string'; 21082815dcSEvan Bacon 22082815dcSEvan Bacon return config.scheme.filter<string>(validate); 23082815dcSEvan Bacon } else if (typeof config.scheme === 'string') { 24082815dcSEvan Bacon return [config.scheme]; 25082815dcSEvan Bacon } 26082815dcSEvan Bacon return []; 27082815dcSEvan Bacon} 28082815dcSEvan Bacon 29082815dcSEvan Bacon// This plugin used to remove the unused schemes but this is unpredictable because other plugins could add schemes. 30384598e2SBrent Vatne// The only way to reliably remove schemes from the project is to nuke the file and regenerate the code (`npx expo prebuild --clean`). 31082815dcSEvan Bacon// 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. 32082815dcSEvan Baconexport function setScheme( 33082815dcSEvan Bacon config: Pick<ExpoConfig, 'scheme' | 'android'>, 34082815dcSEvan Bacon androidManifest: AndroidManifest 35082815dcSEvan Bacon) { 36082815dcSEvan Bacon const schemes = [ 37082815dcSEvan Bacon ...getScheme(config), 38082815dcSEvan Bacon // @ts-ignore: TODO: android.scheme is an unreleased -- harder to add to turtle v1. 39082815dcSEvan Bacon ...getScheme(config.android ?? {}), 40082815dcSEvan Bacon ]; 41082815dcSEvan Bacon // Add the package name to the list of schemes for easier Google auth and parity with Turtle v1. 42082815dcSEvan Bacon if (config.android?.package) { 43082815dcSEvan Bacon schemes.push(config.android.package); 44082815dcSEvan Bacon } 45082815dcSEvan Bacon if (schemes.length === 0) { 46082815dcSEvan Bacon return androidManifest; 47082815dcSEvan Bacon } 48082815dcSEvan Bacon 49082815dcSEvan Bacon if (!ensureManifestHasValidIntentFilter(androidManifest)) { 50082815dcSEvan Bacon addWarningAndroid( 51082815dcSEvan Bacon 'scheme', 52082815dcSEvan Bacon `Cannot add schemes because the provided manifest does not have a valid Activity with \`android:launchMode="singleTask"\``, 53082815dcSEvan Bacon 'https://expo.fyi/setup-android-uri-scheme' 54082815dcSEvan Bacon ); 55082815dcSEvan Bacon return androidManifest; 56082815dcSEvan Bacon } 57082815dcSEvan Bacon 58082815dcSEvan Bacon // Get the current schemes and remove them from the list of schemes to add. 59082815dcSEvan Bacon const currentSchemes = getSchemesFromManifest(androidManifest); 60082815dcSEvan Bacon for (const uri of currentSchemes) { 61082815dcSEvan Bacon const index = schemes.indexOf(uri); 62082815dcSEvan Bacon if (index > -1) schemes.splice(index, 1); 63082815dcSEvan Bacon } 64082815dcSEvan Bacon 65082815dcSEvan Bacon // Now add all of the remaining schemes. 66082815dcSEvan Bacon for (const uri of schemes) { 67082815dcSEvan Bacon androidManifest = appendScheme(uri, androidManifest); 68082815dcSEvan Bacon } 69082815dcSEvan Bacon 70082815dcSEvan Bacon return androidManifest; 71082815dcSEvan Bacon} 72082815dcSEvan Bacon 73082815dcSEvan Baconfunction isValidRedirectIntentFilter({ actions, categories }: IntentFilterProps): boolean { 74082815dcSEvan Bacon return ( 75082815dcSEvan Bacon actions.includes('android.intent.action.VIEW') && 76082815dcSEvan Bacon !categories.includes('android.intent.category.LAUNCHER') 77082815dcSEvan Bacon ); 78082815dcSEvan Bacon} 79082815dcSEvan Bacon 80082815dcSEvan Baconfunction propertiesFromIntentFilter(intentFilter: any): IntentFilterProps { 81082815dcSEvan Bacon const actions = intentFilter?.action?.map((data: any) => data?.$?.['android:name']) ?? []; 82082815dcSEvan Bacon const categories = intentFilter?.category?.map((data: any) => data?.$?.['android:name']) ?? []; 83082815dcSEvan Bacon const data = 84082815dcSEvan Bacon intentFilter?.data 85082815dcSEvan Bacon ?.filter((data: any) => data?.$?.['android:scheme']) 86082815dcSEvan Bacon ?.map((data: any) => ({ 87082815dcSEvan Bacon scheme: data?.$?.['android:scheme'], 88082815dcSEvan Bacon host: data?.$?.['android:host'], 89082815dcSEvan Bacon })) ?? []; 90082815dcSEvan Bacon return { 91082815dcSEvan Bacon actions, 92082815dcSEvan Bacon categories, 93082815dcSEvan Bacon data, 94082815dcSEvan Bacon }; 95082815dcSEvan Bacon} 96082815dcSEvan Bacon 97082815dcSEvan Baconfunction getSingleTaskIntentFilters(androidManifest: AndroidManifest): any[] { 98082815dcSEvan Bacon if (!Array.isArray(androidManifest.manifest.application)) return []; 99082815dcSEvan Bacon 100082815dcSEvan Bacon let outputSchemes: any[] = []; 101082815dcSEvan Bacon for (const application of androidManifest.manifest.application) { 102082815dcSEvan Bacon const { activity } = application; 103082815dcSEvan Bacon // @ts-ignore 104082815dcSEvan Bacon const activities = Array.isArray(activity) ? activity : [activity]; 105082815dcSEvan Bacon const singleTaskActivities = (activities as ManifestActivity[]).filter( 106082815dcSEvan Bacon (activity) => activity?.$?.['android:launchMode'] === 'singleTask' 107082815dcSEvan Bacon ); 108082815dcSEvan Bacon for (const activity of singleTaskActivities) { 109082815dcSEvan Bacon const intentFilters = activity['intent-filter']; 110082815dcSEvan Bacon outputSchemes = outputSchemes.concat(intentFilters); 111082815dcSEvan Bacon } 112082815dcSEvan Bacon } 113082815dcSEvan Bacon return outputSchemes; 114082815dcSEvan Bacon} 115082815dcSEvan Bacon 116082815dcSEvan Baconexport function getSchemesFromManifest( 117082815dcSEvan Bacon androidManifest: AndroidManifest, 118082815dcSEvan Bacon requestedHost: string | null = null 119082815dcSEvan Bacon): string[] { 120082815dcSEvan Bacon const outputSchemes: string[] = []; 121082815dcSEvan Bacon 122082815dcSEvan Bacon const singleTaskIntentFilters = getSingleTaskIntentFilters(androidManifest); 123082815dcSEvan Bacon for (const intentFilter of singleTaskIntentFilters) { 124082815dcSEvan Bacon const properties = propertiesFromIntentFilter(intentFilter); 125082815dcSEvan Bacon if (isValidRedirectIntentFilter(properties) && properties.data) { 126082815dcSEvan Bacon for (const { scheme, host } of properties.data) { 127082815dcSEvan Bacon if (requestedHost === null || !host || host === requestedHost) { 128082815dcSEvan Bacon outputSchemes.push(scheme); 129082815dcSEvan Bacon } 130082815dcSEvan Bacon } 131082815dcSEvan Bacon } 132082815dcSEvan Bacon } 133082815dcSEvan Bacon 134082815dcSEvan Bacon return outputSchemes; 135082815dcSEvan Bacon} 136082815dcSEvan Bacon 137082815dcSEvan Baconexport function ensureManifestHasValidIntentFilter(androidManifest: AndroidManifest): boolean { 138082815dcSEvan Bacon if (!Array.isArray(androidManifest.manifest.application)) { 139082815dcSEvan Bacon return false; 140082815dcSEvan Bacon } 141082815dcSEvan Bacon 142082815dcSEvan Bacon for (const application of androidManifest.manifest.application) { 143082815dcSEvan Bacon for (const activity of application.activity || []) { 144082815dcSEvan Bacon if (activity?.$?.['android:launchMode'] === 'singleTask') { 145082815dcSEvan Bacon for (const intentFilter of activity['intent-filter'] || []) { 146082815dcSEvan Bacon // Parse valid intent filters... 147082815dcSEvan Bacon const properties = propertiesFromIntentFilter(intentFilter); 148082815dcSEvan Bacon if (isValidRedirectIntentFilter(properties)) { 149082815dcSEvan Bacon return true; 150082815dcSEvan Bacon } 151082815dcSEvan Bacon } 152082815dcSEvan Bacon if (!activity['intent-filter']) { 153082815dcSEvan Bacon activity['intent-filter'] = []; 154082815dcSEvan Bacon } 155082815dcSEvan Bacon 156082815dcSEvan Bacon activity['intent-filter'].push({ 157082815dcSEvan Bacon action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }], 158082815dcSEvan Bacon category: [ 159082815dcSEvan Bacon { $: { 'android:name': 'android.intent.category.DEFAULT' } }, 160082815dcSEvan Bacon { $: { 'android:name': 'android.intent.category.BROWSABLE' } }, 161082815dcSEvan Bacon ], 162082815dcSEvan Bacon }); 163082815dcSEvan Bacon return true; 164082815dcSEvan Bacon } 165082815dcSEvan Bacon } 166082815dcSEvan Bacon } 167082815dcSEvan Bacon return false; 168082815dcSEvan Bacon} 169082815dcSEvan Bacon 170082815dcSEvan Baconexport function hasScheme(scheme: string, androidManifest: AndroidManifest): boolean { 171082815dcSEvan Bacon const schemes = getSchemesFromManifest(androidManifest); 172082815dcSEvan Bacon return schemes.includes(scheme); 173082815dcSEvan Bacon} 174082815dcSEvan Bacon 175082815dcSEvan Baconexport function appendScheme(scheme: string, androidManifest: AndroidManifest): AndroidManifest { 176082815dcSEvan Bacon if (!Array.isArray(androidManifest.manifest.application)) { 177082815dcSEvan Bacon return androidManifest; 178082815dcSEvan Bacon } 179082815dcSEvan Bacon 180082815dcSEvan Bacon for (const application of androidManifest.manifest.application) { 181082815dcSEvan Bacon for (const activity of application.activity || []) { 182082815dcSEvan Bacon if (activity?.$?.['android:launchMode'] === 'singleTask') { 183082815dcSEvan Bacon for (const intentFilter of activity['intent-filter'] || []) { 184082815dcSEvan Bacon const properties = propertiesFromIntentFilter(intentFilter); 185082815dcSEvan Bacon if (isValidRedirectIntentFilter(properties)) { 186082815dcSEvan Bacon if (!intentFilter.data) intentFilter.data = []; 187082815dcSEvan Bacon intentFilter.data.push({ 188082815dcSEvan Bacon $: { 'android:scheme': scheme }, 189082815dcSEvan Bacon }); 190082815dcSEvan Bacon } 191082815dcSEvan Bacon } 192082815dcSEvan Bacon break; 193082815dcSEvan Bacon } 194082815dcSEvan Bacon } 195082815dcSEvan Bacon } 196082815dcSEvan Bacon return androidManifest; 197082815dcSEvan Bacon} 198082815dcSEvan Bacon 199082815dcSEvan Baconexport function removeScheme(scheme: string, androidManifest: AndroidManifest): AndroidManifest { 200082815dcSEvan Bacon if (!Array.isArray(androidManifest.manifest.application)) { 201082815dcSEvan Bacon return androidManifest; 202082815dcSEvan Bacon } 203082815dcSEvan Bacon 204082815dcSEvan Bacon for (const application of androidManifest.manifest.application) { 205082815dcSEvan Bacon for (const activity of application.activity || []) { 206082815dcSEvan Bacon if (activity?.$?.['android:launchMode'] === 'singleTask') { 207082815dcSEvan Bacon for (const intentFilter of activity['intent-filter'] || []) { 208082815dcSEvan Bacon // Parse valid intent filters... 209082815dcSEvan Bacon const properties = propertiesFromIntentFilter(intentFilter); 210082815dcSEvan Bacon if (isValidRedirectIntentFilter(properties)) { 211082815dcSEvan Bacon for (const dataKey in intentFilter?.data || []) { 212082815dcSEvan Bacon const data = intentFilter.data?.[dataKey]; 213082815dcSEvan Bacon if (data?.$?.['android:scheme'] === scheme) { 214082815dcSEvan Bacon delete intentFilter.data?.[dataKey]; 215082815dcSEvan Bacon } 216082815dcSEvan Bacon } 217082815dcSEvan Bacon } 218082815dcSEvan Bacon } 219082815dcSEvan Bacon break; 220082815dcSEvan Bacon } 221082815dcSEvan Bacon } 222082815dcSEvan Bacon } 223082815dcSEvan Bacon 224082815dcSEvan Bacon return androidManifest; 225082815dcSEvan Bacon} 226