@howincodes/expo-dynamic-app-icon
Version:
Programmatically change the app icon in Expo.
378 lines (377 loc) • 16.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const config_plugins_1 = require("@expo/config-plugins");
const image_utils_1 = require("@expo/image-utils");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const moduleRoot = path_1.default.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 buildFile = path_1.default.join(moduleRoot, "build", "types.d.ts");
const buildFileContent = fs_1.default.readFileSync(buildFile, "utf8");
const updatedContent = buildFileContent.replace(/IconName:\s.*/, unionType);
fs_1.default.writeFileSync(buildFile, updatedContent);
return config;
}
// =============================================================================
// Android
// =============================================================================
const withIconAndroidManifest = (config, { icons }) => {
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((iconName) => ({
$: {
"android:name": `${iconNamePrefix}${iconName}`,
"android:enabled": "false",
"android:exported": "true",
"android:icon": `@mipmap/${iconName}`,
"android:targetActivity": ".MainActivity",
"android:roundIcon": `@mipmap/${iconName}_round`,
},
"intent-filter": [
...(mainActivity["intent-filter"] || [
{
action: [
{ $: { "android:name": "android.intent.action.MAIN" } },
],
category: [
{ $: { "android:name": "android.intent.category.LAUNCHER" } },
],
},
]),
],
})),
];
}
function removeIconActivityAlias(config) {
return config.filter((activityAlias) => !activityAlias.$["android:name"].startsWith(iconNamePrefix));
}
mainApplication["activity-alias"] = removeIconActivityAlias(mainApplication["activity-alias"] || []);
mainApplication["activity-alias"] = addIconActivityAlias(mainApplication["activity-alias"] || []);
return config;
});
};
const withIconAndroidImages = (config, { icons }) => {
return (0, config_plugins_1.withDangerousMod)(config, [
"android",
async (config) => {
const androidResPath = path_1.default.join(config.modRequest.platformProjectRoot, ...ANDROID_FOLDER_PATH);
const removeIconRes = async () => {
for (let i = 0; ANDROID_FOLDER_NAMES.length > i; i += 1) {
const folder = path_1.default.join(androidResPath, ANDROID_FOLDER_NAMES[i]);
const files = await fs_1.default.promises.readdir(folder).catch(() => []);
for (let j = 0; files.length > j; j += 1) {
if (!files[j].startsWith("ic_launcher")) {
await fs_1.default.promises
.rm(path_1.default.join(folder, files[j]), { force: true })
.catch(() => null);
}
}
}
};
const addIconRes = async () => {
for (let i = 0; ANDROID_FOLDER_NAMES.length > i; i += 1) {
const size = ANDROID_SIZES[i];
const outputPath = path_1.default.join(androidResPath, ANDROID_FOLDER_NAMES[i]);
// square ones
for (const [name, { android }] of Object.entries(icons)) {
if (!android)
continue;
const fileName = `${name}.png`;
const { source } = await (0, image_utils_1.generateImageAsync)({
projectRoot: config.modRequest.projectRoot,
cacheType: `expo-dynamic-app-icon-${size}`,
}, {
name: fileName,
src: android,
removeTransparency: true,
backgroundColor: "#ffffff",
resizeMode: "cover",
width: size,
height: size,
});
await fs_1.default.promises.writeFile(path_1.default.join(outputPath, fileName), source);
}
// round ones
for (const [name, { android }] of Object.entries(icons)) {
if (!android)
continue;
const fileName = `${name}_round.png`;
const { source } = await (0, image_utils_1.generateImageAsync)({
projectRoot: config.modRequest.projectRoot,
cacheType: `expo-dynamic-app-icon-round-${size}`,
}, {
name: fileName,
src: android,
removeTransparency: true,
backgroundColor: "#ffffff",
resizeMode: "cover",
width: size,
height: size,
borderRadius: size / 2,
});
await fs_1.default.promises.writeFile(path_1.default.join(outputPath, fileName), source);
}
}
};
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_1.default.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_1.default.join(IOS_ASSETS_FOLDER_NAME, `${getIconName(key)}.appiconset`);
const outputIconsetPath = path_1.default.join(iosRoot, iconsetPath);
await fs_1.default.promises
.rm(outputIconsetPath, {
recursive: true,
force: true,
})
.catch(() => null);
await fs_1.default.promises.mkdir(outputIconsetPath, { recursive: true });
// Generate the Contents.json file
const contents = generateIconsetContents(icon.ios, key, dimension);
const outputContentsPath = path_1.default.join(outputIconsetPath, "Contents.json");
await fs_1.default.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)) {
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-${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_1.default.join(outputIconsetPath, iconFileName);
await fs_1.default.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",
},
],
});
}
else {
images.push({
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",
},
],
});
}
else {
images.push({
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;