Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate notification icons #146

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 7 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
node_modules/
artifacts/
test/ReactNativeIconTest/ios/ReactNativeIconTest/Images.xcassets/AppIcon.appiconset/
test/ReactNativeIconTest/android/app/src/main/res
test/CordovaApp/platforms/ios/ionic_app/Images.xcassets/AppIcon.appiconset
test/CordovaApp/platforms/android/res
test/NativeApp/ios/native_app/Assets.xcassets/AppIcon.appiconset
test/NativeApp/android/native_app/src/main/res
test/ReactNativeIconTest/android/app/src/main/res/
test/CordovaApp/platforms/ios/ionic_app/Images.xcassets/AppIcon.appiconset/
test/CordovaApp/platforms/android/res/
test/NativeApp/ios/native_app/Assets.xcassets/AppIcon.appiconset/
test/NativeApp/android/native_app/src/main/res/
npm-debug.log
src/color/test-images/*-output.png
src/init/test-images/*-output.png
src/label/test-images/*-output.png
src/resize/test-images/*output.png
.nyc_output/

.idea
.idea/
*.iml

# Windows junk.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Icon management for Mobile Apps. Create icons, generate all required sizes, labe
* [Generating Icons](#generating-icons)
* [Labelling Icons](#labelling-icons)
* [Adaptive Icons](#adaptive-icons)
* [Notification Icons](#notification-icons)
* [Developer Guide](#developer-guide)
* [Initial Setup](#initial-setup)
* [Running Tests](#running-tests)
Expand Down Expand Up @@ -180,6 +181,21 @@ To test how adaptive icons will look when animated, swiped, etc, the [Adaptive I

Note that Adaptive Icons of *all* supported sizes are generated. However, we also generate the `res/mipmap-anydpi-v26/` adaptive icon. This is a large size icon which Android from v26 onwards will automatically rescale as needed to all other sizes. This technically makes the density specific icons redundant. The reason we generate both is to ensure that after `generate` is run, *all* icons in the project will be consistent.

### Notification Icons

Support for [Notification Icons for Android](https://developer.android.com/guide/topics/ui/notifiers/notifications) is added.

The current goals are:

1. Notification Icons are 'opt in', they are not generated by default
2. Creating or generating notification icons is done via the `--notification-icons` flag

Another guide about Android Notifications can be found [here](https://material.io/design/platform-guidance/android-notifications.html).

To help you develop notification icons, the [Notification icon generator](https://romannurik.github.io/AndroidAssetStudio/icons-notification.html) website by [Roman Nurik](https://twitter.com/romannurik) is very useful!

Note that Notification Icons of *all* supported sizes are generated.

## Developer Guide

The only dependencies are Node 10 (or above) and Yarn.
Expand Down
12 changes: 11 additions & 1 deletion bin/app-icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,19 @@ program
.option('-p, --platforms [optional]', "The platforms to generate icons for. Defaults to 'android,ios'", 'android,ios')
.option('--background-icon [optional]', "The background icon path. Defaults to 'icon.background.png'")
.option('--foreground-icon [optional]', "The foreground icon path. Defaults to 'icon.foreground.png'")
.option('--adaptive-icons [optional]', "Additionally, generate Android Adaptive Icon templates. Defaults to 'false'")
.option('--adaptive-icons [optional]', "Additionally, generate Android adaptive icons. Defaults to 'false'")
.option('--notification-icon [optional]', "The notification icon path. Defaults to 'notification.png'")
.option('--notification-icons [optional]', "Additionally, generate Android notification icons. Defaults to 'false'")
.action(async (parameters) => {
const {
icon,
backgroundIcon,
foregroundIcon,
notificationIcon,
search,
platforms,
adaptiveIcons,
notificationIcons,
} = parameters;

await imageMagickCheck();
Expand All @@ -67,14 +71,20 @@ program
const checkPath = foregroundIcon || 'icon.foreground.png';
await errorIfMissing(checkPath, `Foreground icon file '${checkPath}' does not exist. Add the file or specify foreground icon with the '--foreground-icon' parameter.`);
}
if (notificationIcons) {
const checkPath = notificationIcon || 'notification.png';
await errorIfMissing(checkPath, `Notification icon file '${checkPath}' does not exist. Add the file or specify notification icon with the '--notification-icon' parameter.`);
}
try {
await generate({
sourceIcon: icon,
backgroundIcon,
foregroundIcon,
notificationIcon,
searchRoot: search,
platforms,
adaptiveIcons,
notificationIcons,
});
} catch (err) {
console.error(chalk.red(`An error occurred generating the icons: ${err.message}`));
Expand Down
9 changes: 9 additions & 0 deletions src/android/AndroidManifest.notification-icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"notificationIcons": [
{ "path": "res/drawable-mdpi/ic_stat_<originalFileName>" , "size": "24x24" },
{ "path": "res/drawable-hdpi/ic_stat_<originalFileName>" , "size": "36x36" },
{ "path": "res/drawable-xhdpi/ic_stat_<originalFileName>" , "size": "48x48" },
{ "path": "res/drawable-xxhdpi/ic_stat_<originalFileName>" , "size": "72x72" },
{ "path": "res/drawable-xxxhdpi/ic_stat_<originalFileName>" , "size": "96x96" }
]
}
4 changes: 2 additions & 2 deletions src/android/generate-manifest-adaptive-icons.specs.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai');
const path = require('path');
const deleteFolderIfExists = require('../utils/delete-folder-if-exists');
const generateManifestAdaptiveIcons = require('./generate-manifest-adaptive-icons');
const deleteFolderIfExists = require('../utils/delete-folder-if-exists');
const fileExists = require('../utils/file-exists');

const backgroundIcon = './test/icon.background.png';
Expand Down Expand Up @@ -59,7 +59,7 @@ describe('generate-manifest-adaptive-icons', () => {

// Delete all of the folders we're expecting to create, then generate the icons.
await Promise.all(resourceFolders.map(deleteFolderIfExists));
await (generateManifestAdaptiveIcons(backgroundIcon, foregroundIcon, manifestPath));
await generateManifestAdaptiveIcons(backgroundIcon, foregroundIcon, manifestPath);
const filesDoExist = await Promise.all(expectedPaths.map(fileExists));
filesDoExist.forEach((exists, index) => {
expect(exists, `${resourceFoldersFiles[index]} should be generated`).to.equal(true);
Expand Down
3 changes: 2 additions & 1 deletion src/android/generate-manifest-icons.specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const expectedFiles = [
'./ic_launcher.png',
'./ic_launcher_round.png',
];

// Create a test for each manifest.
const testManifests = [{
projectName: 'React Native Manifest',
Expand All @@ -47,7 +48,7 @@ describe('generate-manifest-icons', () => {

// Delete all of the folders we're expecting to create, then generate the icons.
await Promise.all(resourceFolders.map(deleteFolderIfExists));
await (generateManifestIcons(sourceIcon, manifestPath));
await generateManifestIcons(sourceIcon, manifestPath);
const filesDoExist = await Promise.all(resourceFoldersFiles.map(fileExists));
filesDoExist.forEach((exists, index) => {
expect(exists, `${resourceFoldersFiles[index]} should be generated`).to.equal(true);
Expand Down
35 changes: 35 additions & 0 deletions src/android/generate-manifest-notification-icons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const path = require('path');
const mkdirp = require('mkdirp');

const androidManifestNotificationIcons = require('./AndroidManifest.notification-icons.json');
const resizeImage = require('../resize/resize-image');
const monochromeImage = require('../color/monochrome-image');

// Generate Android Manifest icons given a manifest file.
module.exports = async function generateManifestIcons(notificationIcon, manifest) {
// Create the object we will return.
const results = {
icons: [],
};

// We've got the manifest file, get the parent folder.
const manifestFolder = path.dirname(manifest);

// Generate each image in the full icon set, updating the contents.
await Promise.all(androidManifestNotificationIcons.notificationIcons.map(async (icon) => {
const iconPathWithFileName = icon.path.replace('<originalFileName>', path.basename(notificationIcon));
const targetPath = path.join(manifestFolder, iconPathWithFileName);

// Each icon lives in its own folder, so we'd better make sure that folder
// exists.
await mkdirp(path.dirname(targetPath));
results.icons.push(iconPathWithFileName);

return resizeImage(notificationIcon, targetPath, icon.size)
.then(() => monochromeImage(targetPath, targetPath, 'white'));
}));
// Before writing the contents file, sort the contents (otherwise
// they could be in a different order each time).
results.icons.sort();
return results;
};
56 changes: 56 additions & 0 deletions src/android/generate-manifest-notification-icons.specs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const { expect } = require('chai');
const path = require('path');
const generateManifestNotificationIcons = require('./generate-manifest-notification-icons');
const deleteFolderIfExists = require('../utils/delete-folder-if-exists');
const fileExists = require('../utils/file-exists');

const notificationIcon = './test/notification.png';

// The folders we expect to generate, relative to the manifest location.
const expectedFolders = [
'./res/drawable-hdpi',
'./res/drawable-mdpi',
'./res/drawable-xhdpi',
'./res/drawable-xxhdpi',
'./res/drawable-xxxhdpi',
];

// The files we expect in each of the folders above.
const expectedFiles = [
'./ic_stat_notification.png',
];

// Create a test for each manifest.
const testManifests = [{
projectName: 'React Native Manifest',
manifestPath: './test/ReactNativeIconTest/android/app/src/main/AndroidManifest.xml',
}, {
projectName: 'Cordova Manifest',
manifestPath: './test/CordovaApp/platforms/android/src/main/AndroidManifest.xml',
}, {
projectName: 'Native Manifest',
manifestPath: './test/NativeApp/android/native_app/src/main/AndroidManifest.xml',
}];

describe('generate-manifest-notification-icons', () => {
// Run each test.
testManifests.forEach(({ projectName, manifestPath }) => {
it(`should be able to generate notification icons for the ${projectName} manifest`, async () => {
// Get the manifest folder, create an array of every icon we expect to see.
const manifestFolder = path.dirname(manifestPath);
const resourceFolders = expectedFolders.map((f) => path.join(manifestFolder, f));
const resourceFoldersFiles = resourceFolders.reduce((allFiles, folder) => {
expectedFiles.forEach((ef) => allFiles.push(path.join(folder, ef)));
return allFiles;
}, []);

// Delete all of the folders we're expecting to create, then generate the icons.
await Promise.all(resourceFolders.map(deleteFolderIfExists));
await generateManifestNotificationIcons(notificationIcon, manifestPath);
const filesDoExist = await Promise.all(resourceFoldersFiles.map(fileExists));
filesDoExist.forEach((exists, index) => {
expect(exists, `${resourceFoldersFiles[index]} should be generated`).to.equal(true);
});
});
});
});
5 changes: 5 additions & 0 deletions src/color/monochrome-image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const imagemagickCli = require('imagemagick-cli');

module.exports = async function monochromeImage(source, target, color = 'black') {
return imagemagickCli.exec(`convert "${source}" -fill ${color} -colorize 100 "${target}"`);
};
31 changes: 31 additions & 0 deletions src/color/monochrome-image.specs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const { expect } = require('chai');
const monochromeImage = require('./monochrome-image');
const compareImages = require('../testing/compare-images');

describe('monochrome-image', () => {
it('should be able to monochrome an image to given color', async () => {
const input = './src/color/test-images/input.png';
const output = './src/color/test-images/output.png';
const reference = './src/color/test-images/reference-white.png';
await monochromeImage(input, output, 'white');
const difference = await compareImages(output, reference);
expect(difference).to.be.below(1, 'Generated image is below accepted similarly threshold');
});

it('should be able to monochrome an image with default color', async () => {
const input = './src/color/test-images/input.png';
const output = './src/color/test-images/output.png';
const reference = './src/color/test-images/reference-default.png';
await monochromeImage(input, output);
const difference = await compareImages(output, reference);
expect(difference).to.be.below(1, 'Generated image is below accepted similarly threshold');
});

it('should fail with a sensible error message if imagemagick returns an error', async () => {
try {
await monochromeImage('badinput', 'badoutput', 'badcolor');
} catch (err) {
expect(err.message).to.match(/failed/);
}
});
});
Binary file added src/color/test-images/input.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/color/test-images/output.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/color/test-images/reference-default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/color/test-images/reference-white.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions src/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const generateIconsetIcons = require('./ios/generate-iconset-icons');
const findAndroidManifests = require('./android/find-android-manifests');
const generateManifestIcons = require('./android/generate-manifest-icons');
const generateManifestAdaptiveIcons = require('./android/generate-manifest-adaptive-icons');
const generateManifestNotificationIcons = require('./android/generate-manifest-notification-icons');
const validateParameters = require('./validate-parameters');

module.exports = async function generate(parameters) {
Expand All @@ -12,16 +13,19 @@ module.exports = async function generate(parameters) {
sourceIcon,
backgroundIcon,
foregroundIcon,
notificationIcon,
searchRoot,
platforms,
adaptiveIcons,
notificationIcons,
} = validateParameters(parameters || {});

// Set up the results object.
const results = {
iconsets: [],
manifests: [],
adaptiveIconManifests: [],
notificationIconManifests: [],
};

const iconSets = await findIconsetFolders(searchRoot);
Expand Down Expand Up @@ -57,6 +61,14 @@ module.exports = async function generate(parameters) {
});
}

if (notificationIcons) {
const notifRes = await generateManifestNotificationIcons(notificationIcon, manifest);
results.notificationIconManifests.push({ manifest, icons: notifRes.icons });
notifRes.icons.forEach((icon) => {
console.log(` ${chalk.green('✓')} Generated notification icon ${icon}`);
});
}

return null;
}));
return results;
Expand Down
17 changes: 17 additions & 0 deletions src/generate.specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,21 @@ describe('generate', () => {
expect(results.manifests.length).to.equal(3);
expect(results.adaptiveIconManifests.length).to.equal(3);
});

it('should be able to generate test app icons with notification icons included', async () => {
const parameters = {
sourceIcon: './test/icon.png',
notificationIcon: './test/notification.png',
searchPath: './',
notificationIcons: true,
};

// Delete all of the files we're expecting to create, then generate them.
const results = await generate(parameters);
// TODO: Check we found the manifests etc etc
expect(results).to.not.equal(null);
expect(results.iconsets.length).to.equal(3);
expect(results.manifests.length).to.equal(3);
expect(results.notificationIconManifests.length).to.equal(3);
});
});
6 changes: 3 additions & 3 deletions src/ios/generate-iconset-icons.specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('generate-iconset-icons', () => {

// Delete all of the files we're expecting to create, then generate them.
await Promise.all(files.map(deleteIfExists));
await (generateIconsetIcons(sourceIcon, 'test/ReactNativeIconTest/ios/ReactNativeIconTest/Images.xcassets/AppIcon.appiconset'));
await generateIconsetIcons(sourceIcon, 'test/ReactNativeIconTest/ios/ReactNativeIconTest/Images.xcassets/AppIcon.appiconset');
const filesDoExist = await Promise.all(files.map(fileExists));
filesDoExist.forEach((exists, index) => {
expect(exists, `${files[index]} should be generated`).to.equal(true);
Expand Down Expand Up @@ -59,7 +59,7 @@ describe('generate-iconset-icons', () => {

// Delete all of the files we're expecting to create, then generate them.
await Promise.all(files.map(deleteIfExists));
await (generateIconsetIcons(sourceIcon, 'test/CordovaApp/platforms/ios/ionic_app/Images.xcassets/AppIcon.appiconset'));
await generateIconsetIcons(sourceIcon, 'test/CordovaApp/platforms/ios/ionic_app/Images.xcassets/AppIcon.appiconset');
const filesDoExist = await Promise.all(files.map(fileExists));
filesDoExist.forEach((exists, index) => {
expect(exists, `${files[index]} should be generated`).to.equal(true);
Expand Down Expand Up @@ -89,7 +89,7 @@ describe('generate-iconset-icons', () => {

// Delete all of the files we're expecting to create, then generate them.
await Promise.all(files.map(deleteIfExists));
await (generateIconsetIcons(sourceIcon, 'test/NativeApp/ios/native_app/Assets.xcassets/AppIcon.appiconset'));
await generateIconsetIcons(sourceIcon, 'test/NativeApp/ios/native_app/Assets.xcassets/AppIcon.appiconset');
const filesDoExist = await Promise.all(files.map(fileExists));
filesDoExist.forEach((exists, index) => {
expect(exists, `${files[index]} should be generated`).to.equal(true);
Expand Down
7 changes: 7 additions & 0 deletions src/validate-parameters.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,19 @@ module.exports = function validateParameters(parameters) {
const backgroundIcon = parameters.backgroundIcon || 'icon.background.png';
const foregroundIcon = parameters.foregroundIcon || 'icon.foreground.png';

// Validate or assign the notification icons flag.
// Set default value for the notification icon path.
const notificationIcons = !!parameters.notificationIcons;
const notificationIcon = parameters.notificationIcon || 'notification.png';

return {
sourceIcon,
backgroundIcon,
foregroundIcon,
notificationIcon,
searchRoot,
platforms,
adaptiveIcons,
notificationIcons,
};
};
Loading