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