1*435bbba8SBrent Vatneimport { generateImageAsync } from '@expo/image-utils';
2*435bbba8SBrent Vatneimport { ExpoConfig } from 'expo/config';
34f897b47SEvan Baconimport {
4a2691381SEvan Bacon  AndroidConfig,
5a2691381SEvan Bacon  ConfigPlugin,
6a2691381SEvan Bacon  withDangerousMod,
7319d59a4SEvan Bacon  withAndroidColors,
861229c6fSCharlie Cruzan  withAndroidManifest,
9*435bbba8SBrent Vatne} from 'expo/config-plugins';
1061229c6fSCharlie Cruzanimport { writeFileSync, unlinkSync, copyFileSync, existsSync, mkdirSync } from 'fs';
1161229c6fSCharlie Cruzanimport { basename, resolve } from 'path';
124f897b47SEvan Bacon
1361229c6fSCharlie Cruzanimport { NotificationsPluginProps } from './withNotifications';
14609dfb99SEvan Bacon
154f897b47SEvan Baconconst { Colors } = AndroidConfig;
1661229c6fSCharlie Cruzan
1761229c6fSCharlie Cruzantype DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi';
1861229c6fSCharlie Cruzantype dpiMap = Record<DPIString, { folderName: string; scale: number }>;
1961229c6fSCharlie Cruzan
20609dfb99SEvan Baconexport const ANDROID_RES_PATH = 'android/app/src/main/res/';
21609dfb99SEvan Baconexport const dpiValues: dpiMap = {
22609dfb99SEvan Bacon  mdpi: { folderName: 'mipmap-mdpi', scale: 1 },
23609dfb99SEvan Bacon  hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 },
24609dfb99SEvan Bacon  xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 },
25609dfb99SEvan Bacon  xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 },
26609dfb99SEvan Bacon  xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 },
27609dfb99SEvan Bacon};
284f897b47SEvan Baconconst {
294f897b47SEvan Bacon  addMetaDataItemToMainApplication,
304f897b47SEvan Bacon  getMainApplicationOrThrow,
314f897b47SEvan Bacon  removeMetaDataItemFromMainApplication,
324f897b47SEvan Bacon} = AndroidConfig.Manifest;
334f897b47SEvan Baconconst BASELINE_PIXEL_SIZE = 24;
3461229c6fSCharlie Cruzanconst ERROR_MSG_PREFIX = 'An error occurred while configuring Android notifications. ';
354f897b47SEvan Baconexport const META_DATA_NOTIFICATION_ICON = 'expo.modules.notifications.default_notification_icon';
364f897b47SEvan Baconexport const META_DATA_NOTIFICATION_ICON_COLOR =
374f897b47SEvan Bacon  'expo.modules.notifications.default_notification_color';
384f897b47SEvan Baconexport const NOTIFICATION_ICON = 'notification_icon';
394f897b47SEvan Baconexport const NOTIFICATION_ICON_RESOURCE = `@drawable/${NOTIFICATION_ICON}`;
404f897b47SEvan Baconexport const NOTIFICATION_ICON_COLOR = 'notification_icon_color';
414f897b47SEvan Baconexport const NOTIFICATION_ICON_COLOR_RESOURCE = `@color/${NOTIFICATION_ICON_COLOR}`;
424f897b47SEvan Bacon
4361229c6fSCharlie Cruzanexport const withNotificationIcons: ConfigPlugin<{ icon: string | null }> = (config, { icon }) => {
4461229c6fSCharlie Cruzan  // If no icon provided in the config plugin props, fallback to value from app.json
4561229c6fSCharlie Cruzan  icon = icon || getNotificationIcon(config);
464f897b47SEvan Bacon  return withDangerousMod(config, [
474f897b47SEvan Bacon    'android',
4835f78160SBartosz Kaszubowski    async (config) => {
4961229c6fSCharlie Cruzan      await setNotificationIconAsync(config.modRequest.projectRoot, icon);
504f897b47SEvan Bacon      return config;
514f897b47SEvan Bacon    },
524f897b47SEvan Bacon  ]);
534f897b47SEvan Bacon};
544f897b47SEvan Bacon
5561229c6fSCharlie Cruzanexport const withNotificationIconColor: ConfigPlugin<{ color: string | null }> = (
5661229c6fSCharlie Cruzan  config,
5761229c6fSCharlie Cruzan  { color }
5861229c6fSCharlie Cruzan) => {
5961229c6fSCharlie Cruzan  // If no color provided in the config plugin props, fallback to value from app.json
6035f78160SBartosz Kaszubowski  return withAndroidColors(config, (config) => {
6161229c6fSCharlie Cruzan    color = color || getNotificationColor(config);
62319d59a4SEvan Bacon    config.modResults = setNotificationIconColor(color, config.modResults);
634f897b47SEvan Bacon    return config;
64319d59a4SEvan Bacon  });
654f897b47SEvan Bacon};
664f897b47SEvan Bacon
6761229c6fSCharlie Cruzanexport const withNotificationManifest: ConfigPlugin<{
6861229c6fSCharlie Cruzan  icon: string | null;
6961229c6fSCharlie Cruzan  color: string | null;
7061229c6fSCharlie Cruzan}> = (config, { icon, color }) => {
7161229c6fSCharlie Cruzan  // If no icon or color provided in the config plugin props, fallback to value from app.json
7261229c6fSCharlie Cruzan  icon = icon || getNotificationIcon(config);
7361229c6fSCharlie Cruzan  color = color || getNotificationColor(config);
7435f78160SBartosz Kaszubowski  return withAndroidManifest(config, (config) => {
7561229c6fSCharlie Cruzan    config.modResults = setNotificationConfig({ icon, color }, config.modResults);
76a2691381SEvan Bacon    return config;
77a2691381SEvan Bacon  });
78a2691381SEvan Bacon};
794f897b47SEvan Bacon
8061229c6fSCharlie Cruzanexport const withNotificationSounds: ConfigPlugin<{ sounds: string[] }> = (config, { sounds }) => {
8161229c6fSCharlie Cruzan  return withDangerousMod(config, [
8261229c6fSCharlie Cruzan    'android',
8335f78160SBartosz Kaszubowski    (config) => {
8461229c6fSCharlie Cruzan      setNotificationSounds(config.modRequest.projectRoot, sounds);
8561229c6fSCharlie Cruzan      return config;
8661229c6fSCharlie Cruzan    },
8761229c6fSCharlie Cruzan  ]);
8861229c6fSCharlie Cruzan};
8961229c6fSCharlie Cruzan
904f897b47SEvan Baconexport function getNotificationIcon(config: ExpoConfig) {
914f897b47SEvan Bacon  return config.notification?.icon || null;
924f897b47SEvan Bacon}
934f897b47SEvan Bacon
944f897b47SEvan Baconexport function getNotificationColor(config: ExpoConfig) {
954f897b47SEvan Bacon  return config.notification?.color || null;
964f897b47SEvan Bacon}
974f897b47SEvan Bacon
98319d59a4SEvan Baconexport function setNotificationIconColor(
99319d59a4SEvan Bacon  color: string | null,
100319d59a4SEvan Bacon  colors: AndroidConfig.Resources.ResourceXML
101319d59a4SEvan Bacon) {
102319d59a4SEvan Bacon  return Colors.assignColorValue(colors, {
103319d59a4SEvan Bacon    name: NOTIFICATION_ICON_COLOR,
104319d59a4SEvan Bacon    value: color,
105319d59a4SEvan Bacon  });
106319d59a4SEvan Bacon}
107319d59a4SEvan Bacon
1084f897b47SEvan Bacon/**
10961229c6fSCharlie Cruzan * Applies notification icon configuration for expo-notifications
1104f897b47SEvan Bacon */
11161229c6fSCharlie Cruzanexport async function setNotificationIconAsync(projectRoot: string, icon: string | null) {
1124f897b47SEvan Bacon  if (icon) {
1134f897b47SEvan Bacon    await writeNotificationIconImageFilesAsync(icon, projectRoot);
1144f897b47SEvan Bacon  } else {
11561229c6fSCharlie Cruzan    removeNotificationIconImageFiles(projectRoot);
1164f897b47SEvan Bacon  }
1174f897b47SEvan Bacon}
1184f897b47SEvan Bacon
11961229c6fSCharlie Cruzanfunction setNotificationConfig(
12061229c6fSCharlie Cruzan  props: { icon: string | null; color: string | null },
1214f897b47SEvan Bacon  manifest: AndroidConfig.Manifest.AndroidManifest
1224f897b47SEvan Bacon) {
1234f897b47SEvan Bacon  const mainApplication = getMainApplicationOrThrow(manifest);
12461229c6fSCharlie Cruzan  if (props.icon) {
1254f897b47SEvan Bacon    addMetaDataItemToMainApplication(
1264f897b47SEvan Bacon      mainApplication,
1274f897b47SEvan Bacon      META_DATA_NOTIFICATION_ICON,
1284f897b47SEvan Bacon      NOTIFICATION_ICON_RESOURCE,
1294f897b47SEvan Bacon      'resource'
1304f897b47SEvan Bacon    );
1314f897b47SEvan Bacon  } else {
1324f897b47SEvan Bacon    removeMetaDataItemFromMainApplication(mainApplication, META_DATA_NOTIFICATION_ICON);
1334f897b47SEvan Bacon  }
13461229c6fSCharlie Cruzan  if (props.color) {
1354f897b47SEvan Bacon    addMetaDataItemToMainApplication(
1364f897b47SEvan Bacon      mainApplication,
1374f897b47SEvan Bacon      META_DATA_NOTIFICATION_ICON_COLOR,
1384f897b47SEvan Bacon      NOTIFICATION_ICON_COLOR_RESOURCE,
1394f897b47SEvan Bacon      'resource'
1404f897b47SEvan Bacon    );
1414f897b47SEvan Bacon  } else {
1424f897b47SEvan Bacon    removeMetaDataItemFromMainApplication(mainApplication, META_DATA_NOTIFICATION_ICON_COLOR);
1434f897b47SEvan Bacon  }
1444f897b47SEvan Bacon  return manifest;
1454f897b47SEvan Bacon}
1464f897b47SEvan Bacon
1474f897b47SEvan Baconasync function writeNotificationIconImageFilesAsync(icon: string, projectRoot: string) {
1484f897b47SEvan Bacon  await Promise.all(
1494f897b47SEvan Bacon    Object.values(dpiValues).map(async ({ folderName, scale }) => {
1504f897b47SEvan Bacon      const drawableFolderName = folderName.replace('mipmap', 'drawable');
15161229c6fSCharlie Cruzan      const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName);
15261229c6fSCharlie Cruzan      if (!existsSync(dpiFolderPath)) {
15361229c6fSCharlie Cruzan        mkdirSync(dpiFolderPath, { recursive: true });
15461229c6fSCharlie Cruzan      }
1554f897b47SEvan Bacon      const iconSizePx = BASELINE_PIXEL_SIZE * scale;
1564f897b47SEvan Bacon
1574f897b47SEvan Bacon      try {
1584f897b47SEvan Bacon        const resizedIcon = (
1594f897b47SEvan Bacon          await generateImageAsync(
1604f897b47SEvan Bacon            { projectRoot, cacheType: 'android-notification' },
1614f897b47SEvan Bacon            {
1624f897b47SEvan Bacon              src: icon,
1634f897b47SEvan Bacon              width: iconSizePx,
1644f897b47SEvan Bacon              height: iconSizePx,
1654f897b47SEvan Bacon              resizeMode: 'cover',
1664f897b47SEvan Bacon              backgroundColor: 'transparent',
1674f897b47SEvan Bacon            }
1684f897b47SEvan Bacon          )
1694f897b47SEvan Bacon        ).source;
17061229c6fSCharlie Cruzan        writeFileSync(resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'), resizedIcon);
1714f897b47SEvan Bacon      } catch (e) {
17261229c6fSCharlie Cruzan        throw new Error(
17361229c6fSCharlie Cruzan          ERROR_MSG_PREFIX + 'Encountered an issue resizing Android notification icon: ' + e
17461229c6fSCharlie Cruzan        );
1754f897b47SEvan Bacon      }
1764f897b47SEvan Bacon    })
1774f897b47SEvan Bacon  );
1784f897b47SEvan Bacon}
1794f897b47SEvan Bacon
18061229c6fSCharlie Cruzanfunction removeNotificationIconImageFiles(projectRoot: string) {
18161229c6fSCharlie Cruzan  Object.values(dpiValues).forEach(async ({ folderName }) => {
1824f897b47SEvan Bacon    const drawableFolderName = folderName.replace('mipmap', 'drawable');
18361229c6fSCharlie Cruzan    const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName);
184a3f3a9a7SCharlie Cruzan    const iconFile = resolve(dpiFolderPath, NOTIFICATION_ICON + '.png');
185a3f3a9a7SCharlie Cruzan    if (existsSync(iconFile)) {
186a3f3a9a7SCharlie Cruzan      unlinkSync(iconFile);
187a3f3a9a7SCharlie Cruzan    }
18861229c6fSCharlie Cruzan  });
1894f897b47SEvan Bacon}
1904f897b47SEvan Bacon
19161229c6fSCharlie Cruzan/**
19261229c6fSCharlie Cruzan * Save sound files to `<project-root>/android/app/src/main/res/raw`
19361229c6fSCharlie Cruzan */
19461229c6fSCharlie Cruzanexport function setNotificationSounds(projectRoot: string, sounds: string[]) {
19561229c6fSCharlie Cruzan  if (!Array.isArray(sounds)) {
19661229c6fSCharlie Cruzan    throw new Error(
19761229c6fSCharlie Cruzan      ERROR_MSG_PREFIX +
19861229c6fSCharlie Cruzan        `Must provide an array of sound files in your app config, found ${typeof sounds}.`
19961229c6fSCharlie Cruzan    );
20061229c6fSCharlie Cruzan  }
20161229c6fSCharlie Cruzan  for (const soundFileRelativePath of sounds) {
20261229c6fSCharlie Cruzan    writeNotificationSoundFile(soundFileRelativePath, projectRoot);
20361229c6fSCharlie Cruzan  }
20461229c6fSCharlie Cruzan}
20561229c6fSCharlie Cruzan
20661229c6fSCharlie Cruzan/**
20761229c6fSCharlie Cruzan * Copies the input file to the `<project-root>/android/app/src/main/res/raw` directory if
20861229c6fSCharlie Cruzan * there isn't already an existing file under that name.
20961229c6fSCharlie Cruzan */
21061229c6fSCharlie Cruzanfunction writeNotificationSoundFile(soundFileRelativePath: string, projectRoot: string) {
21161229c6fSCharlie Cruzan  const rawResourcesPath = resolve(projectRoot, ANDROID_RES_PATH, 'raw');
21261229c6fSCharlie Cruzan  const inputFilename = basename(soundFileRelativePath);
21361229c6fSCharlie Cruzan
21461229c6fSCharlie Cruzan  if (inputFilename) {
21561229c6fSCharlie Cruzan    try {
21661229c6fSCharlie Cruzan      const sourceFilepath = resolve(projectRoot, soundFileRelativePath);
21761229c6fSCharlie Cruzan      const destinationFilepath = resolve(rawResourcesPath, inputFilename);
21861229c6fSCharlie Cruzan      if (!existsSync(rawResourcesPath)) {
21961229c6fSCharlie Cruzan        mkdirSync(rawResourcesPath, { recursive: true });
22061229c6fSCharlie Cruzan      }
22161229c6fSCharlie Cruzan      copyFileSync(sourceFilepath, destinationFilepath);
22261229c6fSCharlie Cruzan    } catch (e) {
22361229c6fSCharlie Cruzan      throw new Error(
22461229c6fSCharlie Cruzan        ERROR_MSG_PREFIX + 'Encountered an issue copying Android notification sounds: ' + e
22561229c6fSCharlie Cruzan      );
22661229c6fSCharlie Cruzan    }
22761229c6fSCharlie Cruzan  }
22861229c6fSCharlie Cruzan}
22961229c6fSCharlie Cruzan
23061229c6fSCharlie Cruzanexport const withNotificationsAndroid: ConfigPlugin<NotificationsPluginProps> = (
23161229c6fSCharlie Cruzan  config,
23261229c6fSCharlie Cruzan  { icon = null, color = null, sounds = [] }
23361229c6fSCharlie Cruzan) => {
23461229c6fSCharlie Cruzan  config = withNotificationIconColor(config, { color });
23561229c6fSCharlie Cruzan  config = withNotificationIcons(config, { icon });
23661229c6fSCharlie Cruzan  config = withNotificationManifest(config, { icon, color });
23761229c6fSCharlie Cruzan  config = withNotificationSounds(config, { sounds });
2384f897b47SEvan Bacon  return config;
2394f897b47SEvan Bacon};
240