@howincodes/expo-dynamic-app-icon
Version:
Programmatically change the app icon in Expo.
497 lines (496 loc) • 25.6 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const config_plugins_1 = require("@expo/config-plugins");
const image_utils_1 = require("@expo/image-utils");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const moduleRoot = path.join(__dirname, "..", "..");
const { getMainApplicationOrThrow, getMainActivityOrThrow } = config_plugins_1.AndroidConfig.Manifest;
const ANDROID_FOLDER_PATH = ["app", "src", "main", "res"];
const ANDROID_FOLDER_NAMES = [
"mipmap-hdpi",
"mipmap-mdpi",
"mipmap-xhdpi",
"mipmap-xxhdpi",
"mipmap-xxxhdpi",
];
const ANDROID_SIZES = [162, 108, 216, 324, 432];
/** The default icon folder name to export to */
const IOS_ASSETS_FOLDER_NAME = "Images.xcassets";
/**
* The default icon dimensions to export.
*
* @see https://developer.apple.com/design/human-interface-guidelines/app-icons#iOS-iPadOS-app-icon-sizes
*/
const IOS_ICON_DIMENSIONS = [
// iPhone, iPad, MacOS
{ scale: 1, size: 1024 },
];
const withDynamicIcon = (config, props = {}) => {
const icons = resolveIcons(props);
const dimensions = resolveIconDimensions(config);
config = withGenerateTypes(config, { icons });
// for ios
config = withIconXcodeProject(config, { icons, dimensions });
config = withIconImages(config, { icons, dimensions });
// for android
config = withIconAndroidManifest(config, { icons, dimensions });
config = withIconAndroidImages(config, { icons, dimensions });
return config;
};
// =============================================================================
// TypeScript
// =============================================================================
function withGenerateTypes(config, props) {
const names = Object.keys(props.icons);
const union = names.map((name) => `"${name}"`).join(" | ") || "string";
const unionType = `IconName: ${union}`;
const buildDir = path.join(moduleRoot, "build");
const buildFile = path.join(buildDir, "types.d.ts");
// Create build directory if it doesn't exist
if (!fs.existsSync(buildDir)) {
fs.mkdirSync(buildDir, { recursive: true });
}
if (fs.existsSync(buildFile)) {
const buildFileContent = fs.readFileSync(buildFile, "utf8");
const updatedContent = buildFileContent.replace(/IconName:\s.*/, unionType);
fs.writeFileSync(buildFile, updatedContent);
}
else {
// Create the types file if it doesn't exist
const content = `export interface DynamicAppIconRegistry {\n ${unionType};\n}\n`;
fs.writeFileSync(buildFile, content);
}
return config;
}
// =============================================================================
// Android
// =============================================================================
const getSafeResourceName = (name) => {
return name.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase();
};
function validateImagePath(projectRoot, imagePath, iconKey, platform) {
const resolvedPath = path.resolve(projectRoot, imagePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`[expo-dynamic-app-icon] Icon "${iconKey}" references image "${imagePath}" for ${platform} which does not exist. Resolved path: ${resolvedPath}`);
}
return resolvedPath;
}
const withIconAndroidManifest = (config, { icons }) => {
const appName = config.name;
return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
const mainApplication = getMainApplicationOrThrow(config.modResults);
const mainActivity = getMainActivityOrThrow(config.modResults);
const iconNamePrefix = `${config.android.package}.MainActivity`;
const iconNames = Object.keys(icons);
function addIconActivityAlias(config) {
return [
...config,
...iconNames.map((iconKey) => {
const iconProps = icons[iconKey];
const androidConfig = iconProps.android;
const safeIconKey = getSafeResourceName(iconKey);
let iconResourceName;
let roundIconResourceName;
if (typeof androidConfig === "object" && androidConfig !== null) {
// Adaptive icon
iconResourceName = `@mipmap/ic_launcher_adaptive_${safeIconKey}`;
roundIconResourceName = `@mipmap/ic_launcher_adaptive_${safeIconKey}`;
}
else {
// Legacy icon (or if androidConfig is a string)
iconResourceName = `@mipmap/${safeIconKey}`;
roundIconResourceName = `@mipmap/${safeIconKey}_round`;
}
return {
$: {
"android:name": `${iconNamePrefix}${iconKey}`,
"android:enabled": "false",
"android:exported": "true",
"android:icon": iconResourceName,
"android:label": mainActivity.$["android:label"] || mainApplication.$["android:label"] || appName || "",
"android:targetActivity": ".MainActivity",
"android:roundIcon": roundIconResourceName,
},
"intent-filter": [
...(mainActivity["intent-filter"] || [
{
action: [
{ $: { "android:name": "android.intent.action.MAIN" } },
],
category: [
{
$: { "android:name": "android.intent.category.LAUNCHER" },
},
],
},
]),
],
};
}),
];
}
function removeIconActivityAlias(currentActivityAliases) {
return currentActivityAliases.filter((activityAlias) => !activityAlias.$["android:name"].startsWith(iconNamePrefix));
}
let activityAliases = mainApplication["activity-alias"] || [];
activityAliases = removeIconActivityAlias(activityAliases);
activityAliases = addIconActivityAlias(activityAliases);
mainApplication["activity-alias"] = activityAliases;
return config; // Return the modified config object itself
});
};
const withIconAndroidImages = (config, { icons }) => {
return (0, config_plugins_1.withDangerousMod)(config, [
"android",
async (config) => {
const androidResPath = path.join(config.modRequest.platformProjectRoot, ...ANDROID_FOLDER_PATH);
const drawableDirPath = path.join(androidResPath, "drawable");
const mipmapAnyDpiV26DirPath = path.join(androidResPath, "mipmap-anydpi-v26");
// Ensure directories exist
await fs.promises.mkdir(drawableDirPath, { recursive: true });
await fs.promises.mkdir(mipmapAnyDpiV26DirPath, { recursive: true });
const removeIconRes = async () => {
// Clean up legacy mipmap-*dpi folders
for (const folderName of ANDROID_FOLDER_NAMES) {
const folderPath = path.join(androidResPath, folderName);
const files = await fs.promises.readdir(folderPath).catch(() => []);
for (const file of files) {
// Avoid deleting main ic_launcher files, only those generated by this plugin (conventionally named)
// This logic might need refinement to be more precise about what this plugin generated previously.
if (!file.startsWith("ic_launcher.") &&
!file.startsWith("ic_launcher_round.")) {
const isPluginGenerated = Object.keys(icons).some((iconKey) => file.startsWith(`${getSafeResourceName(iconKey)}.png`) ||
file.startsWith(`${getSafeResourceName(iconKey)}_round.png`));
if (isPluginGenerated) {
await fs.promises
.rm(path.join(folderPath, file), { force: true })
.catch(() => null);
}
}
}
}
// Clean up adaptive icon files from drawable and mipmap-anydpi-v26
// This assumes a naming convention for plugin-generated adaptive icons.
const drawableFiles = await fs.promises
.readdir(drawableDirPath)
.catch(() => []);
for (const file of drawableFiles) {
if (Object.keys(icons).some((iconKey) => file.startsWith(`ic_launcher_adaptive_${getSafeResourceName(iconKey)}_`))) {
await fs.promises
.rm(path.join(drawableDirPath, file), { force: true })
.catch(() => null);
}
}
const mipmapAnyDpiFiles = await fs.promises
.readdir(mipmapAnyDpiV26DirPath)
.catch(() => []);
for (const file of mipmapAnyDpiFiles) {
if (Object.keys(icons).some((iconKey) => file.startsWith(`ic_launcher_adaptive_${getSafeResourceName(iconKey)}.xml`))) {
await fs.promises
.rm(path.join(mipmapAnyDpiV26DirPath, file), { force: true })
.catch(() => null);
}
}
};
const addIconRes = async () => {
for (const [iconConfigName, iconProps] of Object.entries(icons)) {
const androidConfig = iconProps.android;
if (typeof androidConfig === "object" && androidConfig !== null) {
// Handle Adaptive Icons
const safeIconKey = getSafeResourceName(iconConfigName);
const foregroundBaseName = `ic_launcher_adaptive_${safeIconKey}_fg`;
const backgroundBaseName = `ic_launcher_adaptive_${safeIconKey}_bg`;
const adaptiveIconBaseName = `ic_launcher_adaptive_${safeIconKey}`;
// Foreground Image
validateImagePath(config.modRequest.projectRoot, androidConfig.foregroundImage, iconConfigName, "android");
const foregroundImageSrc = path.resolve(config.modRequest.projectRoot, androidConfig.foregroundImage);
const foregroundImageDest = path.join(drawableDirPath, `${foregroundBaseName}.png`);
const { source: foregroundSource } = await (0, image_utils_1.generateImageAsync)({
projectRoot: config.modRequest.projectRoot,
cacheType: `expo-dynamic-app-icon-fg-${safeIconKey}`,
}, {
src: foregroundImageSrc,
removeTransparency: false,
backgroundColor: "transparent", // Ensure transparency is kept
width: 432, // Standard dimension for foreground assets
height: 432,
resizeMode: "contain", // Preserve aspect ratio within the bounds
});
await fs.promises.writeFile(foregroundImageDest, foregroundSource);
// Background Color Drawable
if (!/^#[0-9A-Fa-f]{3,8}$/.test(androidConfig.backgroundColor)) {
throw new Error(`[expo-dynamic-app-icon] Icon "${iconConfigName}" has invalid backgroundColor "${androidConfig.backgroundColor}". Must be a hex color (e.g., "#FFFFFF" or "#FF000000").`);
}
const backgroundColorXml = `<shape xmlns:android="http://schemas.android.com/apk/res/android">\n <solid android:color="${androidConfig.backgroundColor}" />\n</shape>`;
const backgroundDrawablePath = path.join(drawableDirPath, `${backgroundBaseName}.xml`);
await fs.promises.writeFile(backgroundDrawablePath, backgroundColorXml);
// Adaptive Icon XML
const adaptiveIconXml = `<?xml version="1.0" encoding="utf-8"?>\n<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">\n <background android:drawable="@drawable/${backgroundBaseName}" />\n <foreground android:drawable="@drawable/${foregroundBaseName}" />\n</adaptive-icon>`;
const adaptiveIconXmlPath = path.join(mipmapAnyDpiV26DirPath, `${adaptiveIconBaseName}.xml`);
await fs.promises.writeFile(adaptiveIconXmlPath, adaptiveIconXml);
}
else if (typeof androidConfig === "string") {
// Handle Legacy Icons (existing logic)
validateImagePath(config.modRequest.projectRoot, androidConfig, iconConfigName, "android");
for (let i = 0; ANDROID_FOLDER_NAMES.length > i; i += 1) {
const size = ANDROID_SIZES[i];
const outputPath = path.join(androidResPath, ANDROID_FOLDER_NAMES[i]);
const safeIconKey = getSafeResourceName(iconConfigName); // Use the same safe name
// Square ones
const fileNameSquare = `${safeIconKey}.png`;
const { source: sourceSquare } = await (0, image_utils_1.generateImageAsync)({
projectRoot: config.modRequest.projectRoot,
cacheType: `expo-dynamic-app-icon-${safeIconKey}-${size}`,
}, {
name: fileNameSquare,
src: path.resolve(config.modRequest.projectRoot, androidConfig),
removeTransparency: true,
backgroundColor: "#ffffff",
resizeMode: "cover",
width: size,
height: size,
});
await fs.promises.writeFile(path.join(outputPath, fileNameSquare), sourceSquare);
// Round ones
const fileNameRound = `${safeIconKey}_round.png`;
const { source: sourceRound } = await (0, image_utils_1.generateImageAsync)({
projectRoot: config.modRequest.projectRoot,
cacheType: `expo-dynamic-app-icon-round-${safeIconKey}-${size}`,
}, {
name: fileNameRound,
src: path.resolve(config.modRequest.projectRoot, androidConfig),
removeTransparency: true,
backgroundColor: "#ffffff",
resizeMode: "cover",
width: size,
height: size,
borderRadius: size / 2,
});
await fs.promises.writeFile(path.join(outputPath, fileNameRound), sourceRound);
}
}
}
};
await removeIconRes();
await addIconRes();
return config;
},
]);
};
// =============================================================================
// iOS
// =============================================================================
const withIconXcodeProject = (config, { icons, dimensions }) => {
return (0, config_plugins_1.withXcodeProject)(config, async (config) => {
const project = config.modResults;
// Remove old settings
const configurations = project.hash.project.objects["XCBuildConfiguration"];
for (const id of Object.keys(configurations)) {
const configuration = project.hash.project.objects["XCBuildConfiguration"][id];
if (typeof configuration !== "object")
continue;
const buildSettings = configuration.buildSettings;
delete buildSettings["ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES"];
delete buildSettings["ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS"];
delete buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"];
project.hash.project.objects["XCBuildConfiguration"][id].buildSettings =
buildSettings;
}
// Add new settings
for (const id of Object.keys(configurations)) {
const configuration = project.hash.project.objects["XCBuildConfiguration"][id];
if (typeof configuration !== "object")
continue;
const buildSettings = configuration.buildSettings;
// Include all AppIcon assets
buildSettings["ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS"] = "YES";
// Include all alternate AppIcon names
const names = [];
await iterateIconsAndDimensionsAsync({ icons, dimensions }, async (key) => {
const iconName = getIconName(key);
names.push(iconName);
});
buildSettings["ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES"] =
JSON.stringify(names.join(" "));
// Include default icon
buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"] = "AppIcon";
project.hash.project.objects["XCBuildConfiguration"][id].buildSettings =
buildSettings;
}
return config;
});
};
const withIconImages = (config, { icons, dimensions }) => {
return (0, config_plugins_1.withDangerousMod)(config, [
"ios",
async (config) => {
const iosRoot = path.join(config.modRequest.platformProjectRoot, config.modRequest.projectName);
await iterateIconsAndDimensionsAsync({ icons, dimensions }, async (key, { icon, dimension }) => {
if (!icon.ios)
return;
// Clean the old AppIcon-*.appiconset
const iconsetPath = path.join(IOS_ASSETS_FOLDER_NAME, `${getIconName(key)}.appiconset`);
const outputIconsetPath = path.join(iosRoot, iconsetPath);
await fs.promises
.rm(outputIconsetPath, {
recursive: true,
force: true,
})
.catch(() => null);
await fs.promises.mkdir(outputIconsetPath, { recursive: true });
// Generate the Contents.json file
const contents = generateIconsetContents(icon.ios, key, dimension);
const outputContentsPath = path.join(outputIconsetPath, "Contents.json");
await fs.promises.writeFile(outputContentsPath, JSON.stringify(contents, null, 2));
const images = typeof icon.ios === "string" ? { light: icon.ios } : icon.ios;
// Generate the assets for each variant
for (const [variant, icon] of Object.entries(images)) {
validateImagePath(config.modRequest.projectRoot, icon, `${key}/${variant}`, "ios");
const iconFileName = getIconAssetFileName(key, variant, dimension);
const isTransparent = variant === "dark";
const { source } = await (0, image_utils_1.generateImageAsync)({
projectRoot: config.modRequest.projectRoot,
cacheType: `expo-dynamic-app-icon-${key}-${variant}-${dimension.width}-${dimension.height}`,
}, {
name: iconFileName,
src: icon,
removeTransparency: !isTransparent,
backgroundColor: isTransparent ? "transparent" : "#ffffff",
resizeMode: "cover",
width: dimension.width,
height: dimension.height,
});
const outputAssetPath = path.join(outputIconsetPath, iconFileName);
await fs.promises.writeFile(outputAssetPath, source);
}
});
return config;
},
]);
};
/** Resolve and sanitize the icon set from config plugin props. */
function resolveIcons(props) {
let icons = {};
if (Array.isArray(props)) {
icons = props.reduce((prev, curr, i) => ({ ...prev, [i]: { image: curr } }), {});
}
else if (props) {
icons = props;
}
return icons;
}
/** Resolve the required icon dimension/target based on the app config. */
function resolveIconDimensions(config) {
const targets = [];
if (config.ios?.supportsTablet) {
targets.push("ipad");
}
return IOS_ICON_DIMENSIONS.filter(({ target }) => !target || targets.includes(target)).map((dimension) => ({
...dimension,
target: dimension.target ?? null,
width: dimension.width ?? dimension.size * dimension.scale,
height: dimension.height ?? dimension.size * dimension.scale,
}));
}
/** Get the icon name, used to refer to the icon from within the plist */
function getIconName(name) {
return `AppIcon-${name}`;
}
/** Get the icon asset file name */
function getIconAssetFileName(key, variant, dimension) {
const name = `${getIconName(key)}-${variant}`;
const size = `${dimension.size}x${dimension.size}@${dimension.scale}x`;
return `${name}-${size}.png`;
}
/** Generate the Contents.json for an icon set */
function generateIconsetContents(iconset, key, dimension) {
const lightFileName = getIconAssetFileName(key, "light", dimension);
const images = [
{
filename: lightFileName,
idiom: "universal",
platform: "ios",
size: `${dimension.size}x${dimension.size}`,
},
];
if (typeof iconset === "object" && iconset.dark) {
const darkFileName = getIconAssetFileName(key, "dark", dimension);
images.push({
filename: darkFileName,
idiom: "universal",
platform: "ios",
size: `${dimension.size}x${dimension.size}`,
appearances: [
{
appearance: "luminosity",
value: "dark",
},
],
});
}
if (typeof iconset === "object" && iconset.tinted) {
const tintedFileName = getIconAssetFileName(key, "tinted", dimension);
images.push({
filename: tintedFileName,
idiom: "universal",
platform: "ios",
size: `${dimension.size}x${dimension.size}`,
appearances: [
{
appearance: "luminosity",
value: "tinted",
},
],
});
}
return {
images,
info: {
version: 1,
author: "expo",
},
};
}
/** Iterate all combinations of icons and dimensions to export */
async function iterateIconsAndDimensionsAsync({ icons, dimensions }, callback) {
for (const [iconKey, icon] of Object.entries(icons)) {
for (const dimension of dimensions) {
await callback(iconKey, { icon, dimension });
}
}
}
exports.default = withDynamicIcon;