Skip to content
This repository has been archived by the owner on Jan 18, 2024. It is now read-only.

[config] automate android icon #2087

Merged
merged 33 commits into from
Sep 4, 2020
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9d01e9e
add icon and adaptive icon config
cruzach May 5, 2020
a5dde93
remove setAdaptiveIcon call
cruzach May 5, 2020
5e99f7a
add backgroundColor support
cruzach May 6, 2020
cffe6dc
add tests, clean up
cruzach May 7, 2020
dc7c3ce
install jimp in @expo/config
cruzach May 7, 2020
f1c4a43
remove adaptiveIcon file
cruzach May 7, 2020
fbefaff
re-yarn, clean test
cruzach May 7, 2020
4de78c5
fix tests
cruzach May 8, 2020
3dadc27
handle backgroundColor for legacy icon
cruzach May 8, 2020
3a352c9
flesh out testing
cruzach May 29, 2020
f04f835
Apply suggestions from code review
cruzach Aug 25, 2020
48fa741
apply changes to address the rest of PR feedback
cruzach Aug 25, 2020
6f27d37
leftover rebase change
cruzach Aug 25, 2020
4fa7bb3
add await for Jimp.read
cruzach Aug 27, 2020
866a00d
[image-utils] layerImageAsync function, add circle imageOption, make …
cruzach Aug 27, 2020
56ad914
remove jimp dependency from config
cruzach Aug 27, 2020
272ac16
refactor and improve android icon implementation
cruzach Aug 27, 2020
eff1748
oops
cruzach Aug 27, 2020
e83bacc
Merge branch 'master' into @cruzach/android-icon
cruzach Aug 27, 2020
26540b2
revert most changes to lockfile
cruzach Aug 27, 2020
79f6c3b
added cache types
EvanBacon Aug 28, 2020
069975a
parallelize image generation
cruzach Aug 28, 2020
a4c5db8
delete files from previous icons
cruzach Aug 28, 2020
5c779af
always set adaptive icon foreground
cruzach Aug 28, 2020
459f167
circle -> borderRadius; layer -> composite
cruzach Aug 28, 2020
1e82fb0
Merge branch 'master' into @cruzach/android-icon
EvanBacon Sep 3, 2020
ac5f52d
Update Icon.ts
EvanBacon Sep 3, 2020
59aa8cb
use object for composite params
EvanBacon Sep 3, 2020
54b194e
Fix jimp cropping
EvanBacon Sep 3, 2020
b38bd47
backgroundImage override backgroundColor in legacy icon
cruzach Sep 3, 2020
d261fea
fix adaptive icon bg xml key
cruzach Sep 3, 2020
b6e6090
remove unused function from Colors.ts
cruzach Sep 4, 2020
0e1257a
update changelog
cruzach Sep 4, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions packages/config/src/android/AdaptiveIcon.ts

This file was deleted.

13 changes: 12 additions & 1 deletion packages/config/src/android/Colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function writeColorsXMLAsync(colorsPath: string, colorsContent: any
}

export function setColorItem(itemToAdd: XMLItem[], colorFileContentsJSON: Document) {
if (colorFileContentsJSON.resources.color) {
if (colorFileContentsJSON.resources?.color) {
const colorNameExists = colorFileContentsJSON.resources.color.filter(
(e: XMLItem) => e['$'].name === itemToAdd[0]['$'].name
)[0];
Expand All @@ -56,3 +56,14 @@ export function setColorItem(itemToAdd: XMLItem[], colorFileContentsJSON: Docume
}
return colorFileContentsJSON;
}

export function removeColorItem(keyToRemove: string, colorFileContentsJSON: Document) {
cruzach marked this conversation as resolved.
Show resolved Hide resolved
if (colorFileContentsJSON.resources?.color) {
colorFileContentsJSON.resources.color.forEach((e: XMLItem, index: number) => {
if (e['$'].name === keyToRemove) {
colorFileContentsJSON.resources.color.splice(index, 1);
}
});
}
return colorFileContentsJSON;
}
268 changes: 256 additions & 12 deletions packages/config/src/android/Icon.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,268 @@
import { compositeImagesAsync, generateImageAsync } from '@expo/image-utils';
import fs from 'fs-extra';
import path from 'path';

import { ExpoConfig } from '../Config.types';
import { addWarningAndroid } from '../WarningAggregator';
import * as Colors from './Colors';

type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi';
type dpiMap = Record<DPIString, { folderName: string; scale: number }>;

const dpiValues: dpiMap = {
mdpi: { folderName: 'mipmap-mdpi', scale: 1 },
hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 },
xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 },
xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 },
xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 },
};
const BASELINE_PIXEL_SIZE = 48;
const ANDROID_RES_PATH = 'android/app/src/main/res/';
const MIPMAP_ANYDPI_V26 = 'mipmap-anydpi-v26';
const ICON_BACKGROUND = 'iconBackground';
const IC_LAUNCHER_PNG = 'ic_launcher.png';
const IC_LAUNCHER_ROUND_PNG = 'ic_launcher_round.png';
const IC_LAUNCHER_BACKGROUND_PNG = 'ic_launcher_background.png';
const IC_LAUNCHER_FOREGROUND_PNG = 'ic_launcher_foreground.png';
const IC_LAUNCHER_XML = 'ic_launcher.xml';
const IC_LAUNCHER_ROUND_XML = 'ic_launcher_round.xml';

export function getIcon(config: ExpoConfig) {
// Until we add support applying icon config we just test if the user has configured the icon
// so we can warn
if (config.icon || config.android?.icon) {
return true;
} else {
return false;
}
return config.icon || config.android?.icon || null;
}

export function getAdaptiveIcon(config: ExpoConfig) {
return {
foregroundImage: config.android?.adaptiveIcon?.foregroundImage ?? null,
backgroundColor: config.android?.adaptiveIcon?.backgroundColor ?? '#FFFFFF',
backgroundImage: config.android?.adaptiveIcon?.backgroundImage ?? null,
};
}

/**
* Resizes the user-provided icon to create a set of legacy icon files in
* their respective "mipmap" directories for <= Android 7, and creates a set of adaptive
* icon files for > Android 7 from the adaptive icon files (if provided).
*/
export async function setIconAsync(config: ExpoConfig, projectRoot: string) {
cruzach marked this conversation as resolved.
Show resolved Hide resolved
const icon = getIcon(config);
const { foregroundImage, backgroundColor, backgroundImage } = getAdaptiveIcon(config);
const icon = foregroundImage ?? getIcon(config);

if (!icon) {
return null;
}

await configureLegacyIconAsync(projectRoot, icon, backgroundImage, backgroundColor);

await configureAdaptiveIconAsync(projectRoot, icon, backgroundImage, backgroundColor);

return true;
}

/**
* Configures legacy icon files to be used on Android 7 and earlier. If adaptive icon configuration
* was provided, we create a pseudo-adaptive icon by layering the provided files (or background
* color if no backgroundImage is provided. If no backgroundImage and no backgroundColor are provided,
* the background is set to transparent.)
*/
async function configureLegacyIconAsync(
projectRoot: string,
icon: string,
backgroundImage: string | null,
backgroundColor: string | null
) {
Promise.all(
Object.values(dpiValues).map(async ({ folderName, scale }) => {
const dpiFolderPath = path.resolve(projectRoot, ANDROID_RES_PATH, folderName);
const iconSizePx = BASELINE_PIXEL_SIZE * scale;

try {
let squareIconImage: Buffer = (
await generateImageAsync(
{ projectRoot, cacheType: 'android-standard-square' },
{
src: icon,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: backgroundColor ?? 'transparent',
}
)
).source;
let roundIconImage: Buffer = (
await generateImageAsync(
{ projectRoot, cacheType: 'android-standard-circle' },
{
src: icon,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: backgroundColor ?? 'transparent',
borderRadius: iconSizePx / 2,
}
)
).source;

if (backgroundImage) {
// Layer the buffers we just created on top of the background image that's provided
const squareBackgroundLayer = (
await generateImageAsync(
{ projectRoot, cacheType: 'android-standard-square-background' },
{
src: backgroundImage,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: backgroundColor ?? 'transparent',
}
)
).source;
const roundBackgroundLayer = (
await generateImageAsync(
{ projectRoot, cacheType: 'android-standard-round-background' },
{
src: backgroundImage,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: backgroundColor ?? 'transparent',
borderRadius: iconSizePx / 2,
}
)
).source;
squareIconImage = await compositeImagesAsync(squareIconImage, squareBackgroundLayer);
roundIconImage = await compositeImagesAsync(roundIconImage, roundBackgroundLayer);
}

await fs.mkdirp(dpiFolderPath);
await fs.writeFile(path.resolve(dpiFolderPath, IC_LAUNCHER_PNG), squareIconImage);
await fs.writeFile(path.resolve(dpiFolderPath, IC_LAUNCHER_ROUND_PNG), roundIconImage);
} catch (e) {
throw new Error('Encountered an issue resizing app icon: ' + e);
}
})
);
}

/**
* Configures adaptive icon files to be used on Android 8 and up. A foreground image must be provided,
* and will have a transparent background unless:
* - A backgroundImage is provided, or
* - A backgroundColor was specified
*/
export async function configureAdaptiveIconAsync(
projectRoot: string,
foregroundImage: string,
backgroundImage: string | null,
backgroundColor: string | null
) {
if (backgroundColor) {
await setBackgroundColorAsync(projectRoot, backgroundColor);
}

Promise.all(
Object.values(dpiValues).map(async ({ folderName, scale }) => {
const dpiFolderPath = path.resolve(projectRoot, ANDROID_RES_PATH, folderName);
const iconSizePx = BASELINE_PIXEL_SIZE * scale;

try {
const adpativeIconForeground = (
await generateImageAsync(
{ projectRoot, cacheType: 'android-adaptive-foreground' },
{
src: foregroundImage,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: 'transparent',
}
)
).source;
await fs.writeFile(
path.resolve(dpiFolderPath, IC_LAUNCHER_FOREGROUND_PNG),
adpativeIconForeground
);

if (backgroundImage) {
const adpativeIconBackground = (
await generateImageAsync(
{ projectRoot, cacheType: 'android-adaptive-background' },
{
src: backgroundImage,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: 'transparent',
}
)
).source;
await fs.writeFile(
path.resolve(dpiFolderPath, IC_LAUNCHER_BACKGROUND_PNG),
adpativeIconBackground
);
} else {
// Remove any instances of ic_launcher_background.png that are there from previous icons
await removeBackgroundImageFilesAsync(projectRoot);
}
} catch (e) {
throw new Error('Encountered an issue resizing adaptive app icon: ' + e);
}
})
);

// create ic_launcher.xml and ic_launcher_round.xml
const icLauncherXmlString = createAdaptiveIconXmlString(
backgroundImage ? '' : backgroundColor,
backgroundImage
);
await createAdaptiveIconXmlFiles(projectRoot, icLauncherXmlString);
}

async function setBackgroundColorAsync(projectRoot: string, backgroundColor: string) {
const colorsXmlPath = await Colors.getProjectColorsXMLPathAsync(projectRoot);
if (!colorsXmlPath) {
console.warn(
'Unable to find a colors.xml file in your android project. Background color is not being set.'
);
return;
}
let colorsJson = await Colors.readColorsXMLAsync(colorsXmlPath);
const colorItemToAdd = [
{
_: backgroundColor,
$: { name: ICON_BACKGROUND },
},
];
colorsJson = Colors.setColorItem(colorItemToAdd, colorsJson);
await Colors.writeColorsXMLAsync(colorsXmlPath, colorsJson);
}

export const createAdaptiveIconXmlString = (
backgroundColor: string | null,
backgroundImage: string | null
) => `<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
${
backgroundImage
? `<background android:drawable="@mipmap/ic_launcher_background"/>`
: backgroundColor
? `<background android:drawable="@color/iconBackground"/>`
: null
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
}
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>`;

async function createAdaptiveIconXmlFiles(projectRoot: string, icLauncherXmlString: string) {
const anyDpiV26Directory = path.resolve(projectRoot, ANDROID_RES_PATH, MIPMAP_ANYDPI_V26);
await fs.mkdirp(anyDpiV26Directory);
await fs.writeFile(path.resolve(anyDpiV26Directory, IC_LAUNCHER_XML), icLauncherXmlString);
await fs.writeFile(path.resolve(anyDpiV26Directory, IC_LAUNCHER_ROUND_XML), icLauncherXmlString);
}

addWarningAndroid(
'icon',
'This is the image that your app uses on your home screen, you will need to configure it manually.'
async function removeBackgroundImageFilesAsync(projectRoot: string) {
Promise.all(
Object.values(dpiValues).map(async ({ folderName, scale }) => {
const dpiFolderPath = path.resolve(projectRoot, ANDROID_RES_PATH, folderName);
await fs.remove(path.resolve(dpiFolderPath, IC_LAUNCHER_BACKGROUND_PNG));
})
);
}
Loading