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