1import { 2 AndroidConfig, 3 ConfigPlugin, 4 withAndroidColors, 5 withDangerousMod, 6} from '@expo/config-plugins'; 7import { ResourceXML } from '@expo/config-plugins/build/android/Resources'; 8import { ExpoConfig } from '@expo/config-types'; 9import { compositeImagesAsync, generateImageAsync } from '@expo/image-utils'; 10import fs from 'fs-extra'; 11import path from 'path'; 12 13import { withAndroidManifestIcons } from './withAndroidManifestIcons'; 14 15const { Colors } = AndroidConfig; 16 17type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi'; 18type dpiMap = Record<DPIString, { folderName: string; scale: number }>; 19 20export const dpiValues: dpiMap = { 21 mdpi: { folderName: 'mipmap-mdpi', scale: 1 }, 22 hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 }, 23 xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 }, 24 xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 }, 25 xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 }, 26}; 27const BASELINE_PIXEL_SIZE = 48; 28export const ANDROID_RES_PATH = 'android/app/src/main/res/'; 29const MIPMAP_ANYDPI_V26 = 'mipmap-anydpi-v26'; 30const ICON_BACKGROUND = 'iconBackground'; 31const IC_LAUNCHER_PNG = 'ic_launcher.png'; 32const IC_LAUNCHER_ROUND_PNG = 'ic_launcher_round.png'; 33const IC_LAUNCHER_BACKGROUND_PNG = 'ic_launcher_background.png'; 34const IC_LAUNCHER_FOREGROUND_PNG = 'ic_launcher_foreground.png'; 35const IC_LAUNCHER_XML = 'ic_launcher.xml'; 36const IC_LAUNCHER_ROUND_XML = 'ic_launcher_round.xml'; 37 38export const withAndroidIcons: ConfigPlugin = (config) => { 39 const { foregroundImage, backgroundColor, backgroundImage } = getAdaptiveIcon(config); 40 const icon = foregroundImage ?? getIcon(config); 41 42 if (!icon) { 43 return config; 44 } 45 46 config = withAndroidManifestIcons(config); 47 // Apply colors.xml changes 48 config = withAndroidAdaptiveIconColors(config, backgroundColor); 49 return withDangerousMod(config, [ 50 'android', 51 async (config) => { 52 await setIconAsync(config.modRequest.projectRoot, { 53 icon, 54 backgroundColor, 55 backgroundImage, 56 isAdaptive: !!config.android?.adaptiveIcon, 57 }); 58 return config; 59 }, 60 ]); 61}; 62 63export function setRoundIconManifest( 64 config: Pick<ExpoConfig, 'android'>, 65 manifest: AndroidConfig.Manifest.AndroidManifest 66): AndroidConfig.Manifest.AndroidManifest { 67 const isAdaptive = !!config.android?.adaptiveIcon; 68 const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); 69 70 if (isAdaptive) { 71 application.$['android:roundIcon'] = '@mipmap/ic_launcher_round'; 72 } else { 73 delete application.$['android:roundIcon']; 74 } 75 return manifest; 76} 77 78const withAndroidAdaptiveIconColors: ConfigPlugin<string | null> = (config, backgroundColor) => { 79 return withAndroidColors(config, (config) => { 80 config.modResults = setBackgroundColor(backgroundColor ?? '#FFFFFF', config.modResults); 81 return config; 82 }); 83}; 84 85export function getIcon(config: ExpoConfig) { 86 return config.android?.icon || config.icon || null; 87} 88 89export function getAdaptiveIcon(config: ExpoConfig) { 90 return { 91 foregroundImage: config.android?.adaptiveIcon?.foregroundImage ?? null, 92 backgroundColor: config.android?.adaptiveIcon?.backgroundColor ?? null, 93 backgroundImage: config.android?.adaptiveIcon?.backgroundImage ?? null, 94 }; 95} 96 97/** 98 * Resizes the user-provided icon to create a set of legacy icon files in 99 * their respective "mipmap" directories for <= Android 7, and creates a set of adaptive 100 * icon files for > Android 7 from the adaptive icon files (if provided). 101 */ 102export async function setIconAsync( 103 projectRoot: string, 104 { 105 icon, 106 backgroundColor, 107 backgroundImage, 108 isAdaptive, 109 }: { 110 icon: string | null; 111 backgroundColor: string | null; 112 backgroundImage: string | null; 113 isAdaptive: boolean; 114 } 115) { 116 if (!icon) { 117 return null; 118 } 119 120 await configureLegacyIconAsync(projectRoot, icon, backgroundImage, backgroundColor); 121 if (isAdaptive) { 122 await generateRoundIconAsync(projectRoot, icon, backgroundImage, backgroundColor); 123 } else { 124 await deleteIconNamedAsync(projectRoot, IC_LAUNCHER_ROUND_PNG); 125 } 126 await configureAdaptiveIconAsync(projectRoot, icon, backgroundImage, isAdaptive); 127 128 return true; 129} 130 131/** 132 * Configures legacy icon files to be used on Android 7 and earlier. If adaptive icon configuration 133 * was provided, we create a pseudo-adaptive icon by layering the provided files (or background 134 * color if no backgroundImage is provided. If no backgroundImage and no backgroundColor are provided, 135 * the background is set to transparent.) 136 */ 137async function configureLegacyIconAsync( 138 projectRoot: string, 139 icon: string, 140 backgroundImage: string | null, 141 backgroundColor: string | null 142) { 143 return generateMultiLayerImageAsync(projectRoot, { 144 icon, 145 backgroundImage, 146 backgroundColor, 147 outputImageFileName: IC_LAUNCHER_PNG, 148 imageCacheFolder: 'android-standard-square', 149 backgroundImageCacheFolder: 'android-standard-square-background', 150 }); 151} 152 153async function generateRoundIconAsync( 154 projectRoot: string, 155 icon: string, 156 backgroundImage: string | null, 157 backgroundColor: string | null 158) { 159 return generateMultiLayerImageAsync(projectRoot, { 160 icon, 161 borderRadiusRatio: 0.5, 162 outputImageFileName: IC_LAUNCHER_ROUND_PNG, 163 backgroundImage, 164 backgroundColor, 165 imageCacheFolder: 'android-standard-circle', 166 backgroundImageCacheFolder: 'android-standard-round-background', 167 }); 168} 169 170/** 171 * Configures adaptive icon files to be used on Android 8 and up. A foreground image must be provided, 172 * and will have a transparent background unless: 173 * - A backgroundImage is provided, or 174 * - A backgroundColor was specified 175 */ 176export async function configureAdaptiveIconAsync( 177 projectRoot: string, 178 foregroundImage: string, 179 backgroundImage: string | null, 180 isAdaptive: boolean 181) { 182 await generateMultiLayerImageAsync(projectRoot, { 183 backgroundColor: 'transparent', 184 backgroundImage, 185 backgroundImageCacheFolder: 'android-adaptive-background', 186 outputImageFileName: IC_LAUNCHER_FOREGROUND_PNG, 187 icon: foregroundImage, 188 imageCacheFolder: 'android-adaptive-foreground', 189 backgroundImageFileName: IC_LAUNCHER_BACKGROUND_PNG, 190 }); 191 192 // create ic_launcher.xml and ic_launcher_round.xml 193 const icLauncherXmlString = createAdaptiveIconXmlString(backgroundImage); 194 await createAdaptiveIconXmlFiles( 195 projectRoot, 196 icLauncherXmlString, 197 // If the user only defined icon and not android.adaptiveIcon, then skip enabling the layering system 198 // this will scale the image down and present it uncropped. 199 isAdaptive 200 ); 201} 202 203function setBackgroundColor(backgroundColor: string | null, colors: ResourceXML) { 204 return Colors.assignColorValue(colors, { 205 value: backgroundColor, 206 name: ICON_BACKGROUND, 207 }); 208} 209 210export const createAdaptiveIconXmlString = (backgroundImage: string | null) => { 211 const background = backgroundImage ? `@mipmap/ic_launcher_background` : `@color/iconBackground`; 212 213 return `<?xml version="1.0" encoding="utf-8"?> 214<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 215 <background android:drawable="${background}"/> 216 <foreground android:drawable="@mipmap/ic_launcher_foreground"/> 217</adaptive-icon>`; 218}; 219 220async function createAdaptiveIconXmlFiles( 221 projectRoot: string, 222 icLauncherXmlString: string, 223 add: boolean 224) { 225 const anyDpiV26Directory = path.resolve(projectRoot, ANDROID_RES_PATH, MIPMAP_ANYDPI_V26); 226 await fs.ensureDir(anyDpiV26Directory); 227 const launcherPath = path.resolve(anyDpiV26Directory, IC_LAUNCHER_XML); 228 const launcherRoundPath = path.resolve(anyDpiV26Directory, IC_LAUNCHER_ROUND_XML); 229 if (add) { 230 await Promise.all([ 231 fs.writeFile(launcherPath, icLauncherXmlString), 232 fs.writeFile(launcherRoundPath, icLauncherXmlString), 233 ]); 234 } else { 235 // Remove the xml if the icon switches from adaptive to standard. 236 await Promise.all( 237 [launcherPath, launcherRoundPath].map(async (path) => { 238 if (fs.existsSync(path)) { 239 return fs.remove(path); 240 } 241 }) 242 ); 243 } 244} 245 246async function generateMultiLayerImageAsync( 247 projectRoot: string, 248 { 249 icon, 250 backgroundColor, 251 backgroundImage, 252 imageCacheFolder, 253 backgroundImageCacheFolder, 254 borderRadiusRatio, 255 outputImageFileName, 256 backgroundImageFileName, 257 }: { 258 icon: string; 259 backgroundImage: string | null; 260 backgroundColor: string | null; 261 imageCacheFolder: string; 262 backgroundImageCacheFolder: string; 263 backgroundImageFileName?: string; 264 borderRadiusRatio?: number; 265 outputImageFileName: string; 266 } 267) { 268 await iterateDpiValues(projectRoot, async ({ dpiFolder, scale }) => { 269 let iconLayer = await generateIconAsync(projectRoot, { 270 cacheType: imageCacheFolder, 271 src: icon, 272 scale, 273 // backgroundImage overrides backgroundColor 274 backgroundColor: backgroundImage ? 'transparent' : backgroundColor ?? 'transparent', 275 borderRadiusRatio, 276 }); 277 278 if (backgroundImage) { 279 const backgroundLayer = await generateIconAsync(projectRoot, { 280 cacheType: backgroundImageCacheFolder, 281 src: backgroundImage, 282 scale, 283 backgroundColor: 'transparent', 284 borderRadiusRatio, 285 }); 286 287 if (backgroundImageFileName) { 288 await fs.writeFile(path.resolve(dpiFolder, backgroundImageFileName), backgroundLayer); 289 } else { 290 iconLayer = await compositeImagesAsync({ 291 foreground: iconLayer, 292 background: backgroundLayer, 293 }); 294 } 295 } else if (backgroundImageFileName) { 296 // Remove any instances of ic_launcher_background.png that are there from previous icons 297 await deleteIconNamedAsync(projectRoot, backgroundImageFileName); 298 } 299 300 await fs.ensureDir(dpiFolder); 301 await fs.writeFile(path.resolve(dpiFolder, outputImageFileName), iconLayer); 302 }); 303} 304 305function iterateDpiValues( 306 projectRoot: string, 307 callback: (value: { dpiFolder: string; folderName: string; scale: number }) => Promise<void> 308) { 309 return Promise.all( 310 Object.values(dpiValues).map((value) => 311 callback({ 312 dpiFolder: path.resolve(projectRoot, ANDROID_RES_PATH, value.folderName), 313 ...value, 314 }) 315 ) 316 ); 317} 318 319async function deleteIconNamedAsync(projectRoot: string, name: string) { 320 return iterateDpiValues(projectRoot, ({ dpiFolder }) => { 321 return fs.remove(path.resolve(dpiFolder, name)); 322 }); 323} 324 325async function generateIconAsync( 326 projectRoot: string, 327 { 328 cacheType, 329 src, 330 scale, 331 backgroundColor, 332 borderRadiusRatio, 333 }: { 334 cacheType: string; 335 src: string; 336 scale: number; 337 backgroundColor: string; 338 borderRadiusRatio?: number; 339 } 340) { 341 const iconSizePx = BASELINE_PIXEL_SIZE * scale; 342 343 return ( 344 await generateImageAsync( 345 { projectRoot, cacheType }, 346 { 347 src, 348 width: iconSizePx, 349 height: iconSizePx, 350 resizeMode: 'cover', 351 backgroundColor, 352 borderRadius: borderRadiusRatio ? iconSizePx * borderRadiusRatio : undefined, 353 } 354 ) 355 ).source; 356} 357