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