1import { generateImageAsync } from '@expo/image-utils'; 2import { ExpoConfig } from 'expo/config'; 3import { 4 AndroidConfig, 5 ConfigPlugin, 6 withDangerousMod, 7 withAndroidColors, 8 withAndroidManifest, 9} from 'expo/config-plugins'; 10import { writeFileSync, unlinkSync, copyFileSync, existsSync, mkdirSync } from 'fs'; 11import { basename, resolve } from 'path'; 12 13import { NotificationsPluginProps } from './withNotifications'; 14 15const { Colors } = AndroidConfig; 16 17type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi'; 18type dpiMap = Record<DPIString, { folderName: string; scale: number }>; 19 20export const ANDROID_RES_PATH = 'android/app/src/main/res/'; 21export const dpiValues: dpiMap = { 22 mdpi: { folderName: 'mipmap-mdpi', scale: 1 }, 23 hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 }, 24 xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 }, 25 xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 }, 26 xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 }, 27}; 28const { 29 addMetaDataItemToMainApplication, 30 getMainApplicationOrThrow, 31 removeMetaDataItemFromMainApplication, 32} = AndroidConfig.Manifest; 33const BASELINE_PIXEL_SIZE = 24; 34const ERROR_MSG_PREFIX = 'An error occurred while configuring Android notifications. '; 35export const META_DATA_NOTIFICATION_ICON = 'expo.modules.notifications.default_notification_icon'; 36export const META_DATA_NOTIFICATION_ICON_COLOR = 37 'expo.modules.notifications.default_notification_color'; 38export const NOTIFICATION_ICON = 'notification_icon'; 39export const NOTIFICATION_ICON_RESOURCE = `@drawable/${NOTIFICATION_ICON}`; 40export const NOTIFICATION_ICON_COLOR = 'notification_icon_color'; 41export const NOTIFICATION_ICON_COLOR_RESOURCE = `@color/${NOTIFICATION_ICON_COLOR}`; 42 43export const withNotificationIcons: ConfigPlugin<{ icon: string | null }> = (config, { icon }) => { 44 // If no icon provided in the config plugin props, fallback to value from app.json 45 icon = icon || getNotificationIcon(config); 46 return withDangerousMod(config, [ 47 'android', 48 async (config) => { 49 await setNotificationIconAsync(config.modRequest.projectRoot, icon); 50 return config; 51 }, 52 ]); 53}; 54 55export const withNotificationIconColor: ConfigPlugin<{ color: string | null }> = ( 56 config, 57 { color } 58) => { 59 // If no color provided in the config plugin props, fallback to value from app.json 60 return withAndroidColors(config, (config) => { 61 color = color || getNotificationColor(config); 62 config.modResults = setNotificationIconColor(color, config.modResults); 63 return config; 64 }); 65}; 66 67export const withNotificationManifest: ConfigPlugin<{ 68 icon: string | null; 69 color: string | null; 70}> = (config, { icon, color }) => { 71 // If no icon or color provided in the config plugin props, fallback to value from app.json 72 icon = icon || getNotificationIcon(config); 73 color = color || getNotificationColor(config); 74 return withAndroidManifest(config, (config) => { 75 config.modResults = setNotificationConfig({ icon, color }, config.modResults); 76 return config; 77 }); 78}; 79 80export const withNotificationSounds: ConfigPlugin<{ sounds: string[] }> = (config, { sounds }) => { 81 return withDangerousMod(config, [ 82 'android', 83 (config) => { 84 setNotificationSounds(config.modRequest.projectRoot, sounds); 85 return config; 86 }, 87 ]); 88}; 89 90export function getNotificationIcon(config: ExpoConfig) { 91 return config.notification?.icon || null; 92} 93 94export function getNotificationColor(config: ExpoConfig) { 95 return config.notification?.color || null; 96} 97 98export function setNotificationIconColor( 99 color: string | null, 100 colors: AndroidConfig.Resources.ResourceXML 101) { 102 return Colors.assignColorValue(colors, { 103 name: NOTIFICATION_ICON_COLOR, 104 value: color, 105 }); 106} 107 108/** 109 * Applies notification icon configuration for expo-notifications 110 */ 111export async function setNotificationIconAsync(projectRoot: string, icon: string | null) { 112 if (icon) { 113 await writeNotificationIconImageFilesAsync(icon, projectRoot); 114 } else { 115 removeNotificationIconImageFiles(projectRoot); 116 } 117} 118 119function setNotificationConfig( 120 props: { icon: string | null; color: string | null }, 121 manifest: AndroidConfig.Manifest.AndroidManifest 122) { 123 const mainApplication = getMainApplicationOrThrow(manifest); 124 if (props.icon) { 125 addMetaDataItemToMainApplication( 126 mainApplication, 127 META_DATA_NOTIFICATION_ICON, 128 NOTIFICATION_ICON_RESOURCE, 129 'resource' 130 ); 131 } else { 132 removeMetaDataItemFromMainApplication(mainApplication, META_DATA_NOTIFICATION_ICON); 133 } 134 if (props.color) { 135 addMetaDataItemToMainApplication( 136 mainApplication, 137 META_DATA_NOTIFICATION_ICON_COLOR, 138 NOTIFICATION_ICON_COLOR_RESOURCE, 139 'resource' 140 ); 141 } else { 142 removeMetaDataItemFromMainApplication(mainApplication, META_DATA_NOTIFICATION_ICON_COLOR); 143 } 144 return manifest; 145} 146 147async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: string) { 148 await Promise.all( 149 Object.values(dpiValues).map(async ({ folderName, scale }) => { 150 const drawableFolderName = folderName.replace('mipmap', 'drawable'); 151 const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName); 152 if (!existsSync(dpiFolderPath)) { 153 mkdirSync(dpiFolderPath, { recursive: true }); 154 } 155 const iconSizePx = BASELINE_PIXEL_SIZE * scale; 156 157 try { 158 const resizedIcon = ( 159 await generateImageAsync( 160 { projectRoot, cacheType: 'android-notification' }, 161 { 162 src: icon, 163 width: iconSizePx, 164 height: iconSizePx, 165 resizeMode: 'cover', 166 backgroundColor: 'transparent', 167 } 168 ) 169 ).source; 170 writeFileSync(resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'), resizedIcon); 171 } catch (e) { 172 throw new Error( 173 ERROR_MSG_PREFIX + 'Encountered an issue resizing Android notification icon: ' + e 174 ); 175 } 176 }) 177 ); 178} 179 180function removeNotificationIconImageFiles(projectRoot: string) { 181 Object.values(dpiValues).forEach(async ({ folderName }) => { 182 const drawableFolderName = folderName.replace('mipmap', 'drawable'); 183 const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName); 184 const iconFile = resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'); 185 if (existsSync(iconFile)) { 186 unlinkSync(iconFile); 187 } 188 }); 189} 190 191/** 192 * Save sound files to `<project-root>/android/app/src/main/res/raw` 193 */ 194export function setNotificationSounds(projectRoot: string, sounds: string[]) { 195 if (!Array.isArray(sounds)) { 196 throw new Error( 197 ERROR_MSG_PREFIX + 198 `Must provide an array of sound files in your app config, found ${typeof sounds}.` 199 ); 200 } 201 for (const soundFileRelativePath of sounds) { 202 writeNotificationSoundFile(soundFileRelativePath, projectRoot); 203 } 204} 205 206/** 207 * Copies the input file to the `<project-root>/android/app/src/main/res/raw` directory if 208 * there isn't already an existing file under that name. 209 */ 210function writeNotificationSoundFile(soundFileRelativePath: string, projectRoot: string) { 211 const rawResourcesPath = resolve(projectRoot, ANDROID_RES_PATH, 'raw'); 212 const inputFilename = basename(soundFileRelativePath); 213 214 if (inputFilename) { 215 try { 216 const sourceFilepath = resolve(projectRoot, soundFileRelativePath); 217 const destinationFilepath = resolve(rawResourcesPath, inputFilename); 218 if (!existsSync(rawResourcesPath)) { 219 mkdirSync(rawResourcesPath, { recursive: true }); 220 } 221 copyFileSync(sourceFilepath, destinationFilepath); 222 } catch (e) { 223 throw new Error( 224 ERROR_MSG_PREFIX + 'Encountered an issue copying Android notification sounds: ' + e 225 ); 226 } 227 } 228} 229 230export const withNotificationsAndroid: ConfigPlugin<NotificationsPluginProps> = ( 231 config, 232 { icon = null, color = null, sounds = [] } 233) => { 234 config = withNotificationIconColor(config, { color }); 235 config = withNotificationIcons(config, { icon }); 236 config = withNotificationManifest(config, { icon, color }); 237 config = withNotificationSounds(config, { sounds }); 238 return config; 239}; 240