com.revetkn.ios.analyzer.ArtworkAnalyzer.java Source code

Java tutorial

Introduction

Here is the source code for com.revetkn.ios.analyzer.ArtworkAnalyzer.java

Source

/*
 * Copyright (c) 2013 Mark Allen.
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package com.revetkn.ios.analyzer;

import static com.revetkn.ios.analyzer.ImageType.IMAGE_TYPE_PNG;
import static com.revetkn.ios.analyzer.ImageUtilities.scaleImageUpToFit;
import static java.io.File.separator;
import static java.lang.Runtime.getRuntime;
import static java.lang.String.format;
import static java.util.Collections.emptySet;
import static java.util.Collections.synchronizedSortedMap;
import static java.util.Collections.synchronizedSortedSet;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.apache.commons.io.FileUtils.listFiles;
import static org.apache.commons.io.FileUtils.readFileToByteArray;
import static org.apache.commons.io.FileUtils.readFileToString;
import static org.apache.commons.io.FileUtils.writeByteArrayToFile;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.commons.io.filefilter.NameFileFilter;
import org.apache.commons.io.filefilter.NotFileFilter;
import org.apache.commons.io.filefilter.SuffixFileFilter;
import org.apache.commons.io.filefilter.TrueFileFilter;

/**
 * Performs artwork analysis and retina image generation for an iOS project.
 * <p>
 * <ul>
 * <li>Use {@link #extractApplicationArtwork(File)} to analyze and return data for all artwork in the supplied iOS
 * project directory.</li>
 * <li>Use {@link #generateRetinaImages(Iterable, File)} to create retina images given a set of nonretina images.</li>
 * </ul>
 * <p>
 * This class is threadsafe and immutable.
 * 
 * @author <a href="http://revetkn.com">Mark Allen</a>
 */
public class ArtworkAnalyzer {
    /** Backing thread pool used for concurrent execution of image processing tasks. */
    private ExecutorService executorService;

    /** Suffixes for files which may reference images. */
    private static final Set<String> REFERENCING_FILE_SUFFIXES = new HashSet<String>() {
        {
            add(".h");
            add(".m");
            add(".pbxproj");
            add(".xib");
            add(".plist");
            add(".html");
            add(".strings");
        }
    };

    /** Suffixes for image files we'd like to detect. */
    private static final Set<String> IMAGE_FILE_SUFFIXES = new HashSet<String>() {
        {
            add(".png");
        }
    };

    /** Apple-defined standard application images. */
    private static final Set<String> STANDARD_APPLICATION_IMAGE_FILENAMES = new HashSet<String>() {
        {
            add("Default.png");
            add("Default@2x.png");
            add("Default-568h@2x.png");
            add("Default-Landscape.png");
            add("Default-Landscape@2x.png");
            add("Default-Portrait.png");
            add("Default-Portrait@2x.png");
            add("Icon.png");
            add("Icon@2x.png");
            add("Icon-72.png");
            add("Icon-72@2x.png");
            add("Icon-Small-50.png");
            add("Icon-Small-50@2x.png");
            add("Icon-Small.png");
            add("Icon-Small@2x.png");
            add("iTunesArtwork");
            add("iTunesArtwork@2x");
        }
    };

    /** Directories to skip over when detecting images */
    private static final Set<String> IGNORED_DIRECTORY_NAMES = emptySet();

    /**
     * Creates a new artwork analyzer.
     */
    public ArtworkAnalyzer() {
        this.executorService = createExecutorService();
    }

    public void shutdown() {
        getExecutorService().shutdown();
    }

    /**
     * Scans the given iOS project root directory and performs analysis on the contents of its artwork. This may take a
     * while for larger projects. All available CPU cores are utilized to perform parallel processing when possible.
     * 
     * @throws ArtworkProcessingException
     *           If an error occurs during extraction/analysis.
     */
    public ApplicationArtwork extractApplicationArtwork(File projectRootDirectory) {
        return extractApplicationArtwork(projectRootDirectory, new ArtworkExtractionProgressCallback() {
            @Override
            public void onProcessedImageReferences(File imageFile, SortedSet<File> filesWhereImageIsReferenced,
                    int currentImageFileNumber, int totalImageFiles) {
            }
        });
    }

    /**
     * Scans the given iOS project root directory and performs analysis on the contents of its artwork. This may take a
     * while for larger projects. All available CPU cores are utilized to perform parallel processing when possible.
     * 
     * @throws ArtworkProcessingException
     *           If an error occurs during extraction/analysis.
     */
    public ApplicationArtwork extractApplicationArtwork(File projectRootDirectory,
            ArtworkExtractionProgressCallback progressCallback) {
        if (projectRootDirectory == null)
            throw new NullPointerException("The 'projectRootDirectory' parameter cannot be null.");
        if (!projectRootDirectory.exists())
            throw new IllegalArgumentException(format("Directory '%s' does not exist.", projectRootDirectory));
        if (!projectRootDirectory.isDirectory())
            throw new IllegalArgumentException(
                    format("'%s' is a regular file - it must be a directory.", projectRootDirectory));
        if (progressCallback == null)
            throw new NullPointerException("The 'progressCallback' parameter cannot be null.");

        try {
            ApplicationArtwork applicationArtwork = new ApplicationArtwork();
            applicationArtwork.setAllImageFiles(extractAllImageFiles(projectRootDirectory));

            detectImageMetrics(applicationArtwork);
            detectImageReferences(projectRootDirectory, applicationArtwork, progressCallback);
            detectRetinaAndNonretinaImages(applicationArtwork);
            detectStandardApplicationImages(applicationArtwork);

            applicationArtwork.setIncorrectlySizedRetinaImageFiles(
                    extractIncorrectlySizedRetinaImageFiles(applicationArtwork));
            applicationArtwork.setImageFilesWithIncorrectDeviceSuffix(
                    extractImageFilesWithIncorrectDeviceSuffix(applicationArtwork.getAllImageFiles()));

            return applicationArtwork;
        } catch (Throwable throwable) {
            throw new ArtworkProcessingException(throwable);
        }
    }

    public void generateRetinaImages(File projectRootDirectory, File outputDirectory,
            Set<File> nonretinaImageFiles) {
        generateRetinaImages(projectRootDirectory, outputDirectory, nonretinaImageFiles,
                new RetinaImageGenerationProgressCallback() {
                    @Override
                    public void generatedRetinaImage(File nonretinaSourceImageFile, File generatedRetinaImageFile,
                            int retinaImageFilesGenerated, int totalRetinaImageFilesToGenerate) {
                    }
                });
    }

    public void generateRetinaImages(final File projectRootDirectory, final File outputDirectory,
            final Set<File> nonretinaImageFiles, final RetinaImageGenerationProgressCallback progressCallback) {
        if (projectRootDirectory == null)
            throw new NullPointerException("The 'projectRootDirectory' parameter cannot be null.");
        if (!projectRootDirectory.exists())
            throw new IllegalArgumentException(format("Directory '%s' does not exist.", projectRootDirectory));
        if (!projectRootDirectory.isDirectory())
            throw new IllegalArgumentException(
                    format("'%s' is a regular file - it must be a directory.", projectRootDirectory));
        if (outputDirectory == null)
            throw new NullPointerException("The 'outputDirectory' parameter cannot be null.");
        if (outputDirectory.exists() && !outputDirectory.isDirectory())
            throw new IllegalArgumentException(
                    format("'%s' is a regular file - it must be a directory.", outputDirectory));
        if (nonretinaImageFiles == null)
            throw new NullPointerException("The 'nonretinaImageFiles' parameter cannot be null.");
        if (progressCallback == null)
            throw new NullPointerException("The 'progressCallback' parameter cannot be null.");

        Set<Callable<Object>> retinaScalingTasks = new HashSet<Callable<Object>>();
        final AtomicInteger imageFilesProcessed = new AtomicInteger(0);

        for (final File nonretinaImageFile : nonretinaImageFiles) {
            retinaScalingTasks.add(new Callable<Object>() {
                @Override
                public Object call() throws Exception {
                    byte[] imageFileData = readFileToByteArray(nonretinaImageFile);
                    ImageMetrics imageMetrics = ImageUtilities.extractImageMetrics(imageFileData);
                    int retinaWidth = imageMetrics.getWidth() * 2;
                    int retinaHeight = imageMetrics.getHeight() * 2;

                    byte[] retinaImageData = scaleImageUpToFit(imageFileData, retinaWidth, retinaHeight,
                            IMAGE_TYPE_PNG);

                    String absoluteRetinaImageFilename = retinaImageFilename(nonretinaImageFile.getAbsolutePath());

                    int rootDirectoryPathLength = projectRootDirectory.getAbsolutePath().length();
                    String relativeRetinaImageFilename = absoluteRetinaImageFilename
                            .substring(rootDirectoryPathLength + 1);

                    String retinaImageFilename = outputDirectory.getAbsolutePath() + separator
                            + relativeRetinaImageFilename;

                    File retinaImageFile = new File(retinaImageFilename);
                    writeByteArrayToFile(retinaImageFile, retinaImageData);

                    progressCallback.generatedRetinaImage(nonretinaImageFile, retinaImageFile,
                            imageFilesProcessed.incrementAndGet(), nonretinaImageFiles.size());

                    return null;
                }
            });
        }

        try {
            for (Future<Object> future : getExecutorService().invokeAll(retinaScalingTasks))
                future.get();
        } catch (Throwable throwable) {
            throw new ArtworkProcessingException(throwable);
        }
    }

    protected void detectRetinaAndNonretinaImages(ApplicationArtwork applicationArtwork) {
        SortedSet<String> allImageFilenames = extractFilenames(applicationArtwork.getAllImageFiles());
        SortedSet<File> retinaImageFiles = new TreeSet<File>();
        SortedSet<File> nonretinaImageFiles = new TreeSet<File>();
        SortedSet<File> nonretinaImageFilesMissingRetinaImages = new TreeSet<File>();
        SortedSet<File> retinaImageFilesMissingNonretinaImages = new TreeSet<File>();

        for (String imageFilename : allImageFilenames) {
            int lastIndexOf2x = imageFilename.lastIndexOf("@2x");

            // Nonretina; search for a retina version
            if (lastIndexOf2x == -1) {
                nonretinaImageFiles.add(new File(imageFilename));

                if (!allImageFilenames.contains(retinaImageFilename(imageFilename)))
                    nonretinaImageFilesMissingRetinaImages.add(new File(imageFilename));
            } else {
                retinaImageFiles.add(new File(imageFilename));

                String nonretinaImageFilename = imageFilename.replace("@2x", "");

                if (!allImageFilenames.contains(nonretinaImageFilename))
                    retinaImageFilesMissingNonretinaImages.add(new File(imageFilename));
            }
        }

        applicationArtwork.setRetinaImageFiles(retinaImageFiles);
        applicationArtwork.setNonretinaImageFiles(nonretinaImageFiles);
        applicationArtwork.setNonretinaImageFilesMissingRetinaImages(nonretinaImageFilesMissingRetinaImages);
        applicationArtwork.setRetinaImageFilesMissingNonretinaImages(retinaImageFilesMissingNonretinaImages);
    }

    /** Modifies the passed-in {@code applicationArtwork} instance to include image reference data. */
    protected void detectImageReferences(File projectRootDirectory, final ApplicationArtwork applicationArtwork,
            final ArtworkExtractionProgressCallback progressCallback) throws Exception {
        final Map<File, String> contentsOfReferencingFiles = extractContentsOfReferencingFiles(
                projectRootDirectory);
        final SortedSet<File> unreferencedImageFiles = synchronizedSortedSet(new TreeSet<File>());
        final SortedSet<File> onlyProjectFileReferencedImageFiles = synchronizedSortedSet(new TreeSet<File>());
        final SortedMap<File, SortedSet<File>> allImageFilesAndReferencingFiles = synchronizedSortedMap(
                new TreeMap<File, SortedSet<File>>());

        final AtomicInteger imageFilesProcessed = new AtomicInteger(0);

        Set<Callable<Object>> imageReferenceProcessingTasks = new HashSet<Callable<Object>>();

        for (final File imageFile : applicationArtwork.getAllImageFiles()) {
            imageReferenceProcessingTasks.add(new Callable<Object>() {
                @Override
                public Object call() throws Exception {
                    String imageFilename = imageFile.getName();
                    SortedSet<File> filesWhereImageIsReferenced = new TreeSet<File>();
                    Set<String> imageFilenameVariants = imageFilenameVariants(imageFilename);

                    for (Entry<File, String> entry : contentsOfReferencingFiles.entrySet()) {
                        String fileContents = entry.getValue();

                        for (String imageFilenameVariant : imageFilenameVariants) {
                            // Quoted references, e.g. "aboutBackground"
                            if (fileContents.indexOf(format("\"%s\"", imageFilenameVariant)) != -1)
                                filesWhereImageIsReferenced.add(entry.getKey());

                            // Nib files, e.g. <string key="NSResourceName">aboutBackground~ipad.png</string>
                            else if (fileContents.indexOf(format(">%s<", imageFilenameVariant)) != -1)
                                filesWhereImageIsReferenced.add(entry.getKey());
                        }
                    }

                    if (filesWhereImageIsReferenced.size() == 1 && "project.pbxproj"
                            .equals(filesWhereImageIsReferenced.first().getName().toLowerCase()))
                        onlyProjectFileReferencedImageFiles.add(imageFile);

                    if (filesWhereImageIsReferenced.size() == 0) {
                        unreferencedImageFiles.add(imageFile);
                    } else {
                        allImageFilesAndReferencingFiles.put(imageFile, filesWhereImageIsReferenced);
                    }

                    progressCallback.onProcessedImageReferences(imageFile, filesWhereImageIsReferenced,
                            imageFilesProcessed.incrementAndGet(), applicationArtwork.getAllImageFiles().size());

                    return null;
                }
            });
        }

        for (Future<Object> future : getExecutorService().invokeAll(imageReferenceProcessingTasks))
            future.get();

        applicationArtwork.setAllImageFilesAndReferencingFiles(allImageFilesAndReferencingFiles);
        applicationArtwork.setUnreferencedImageFiles(unreferencedImageFiles);
        applicationArtwork.setOnlyProjectFileReferencedImageFiles(onlyProjectFileReferencedImageFiles);
    }

    protected void detectStandardApplicationImages(ApplicationArtwork applicationArtwork) {
        SortedSet<File> standardApplicationImageFiles = new TreeSet<File>();
        SortedSet<String> missingStandardApplicationImageFilenames = new TreeSet<String>();

        for (String standardImageFilename : STANDARD_APPLICATION_IMAGE_FILENAMES) {
            boolean foundStandardImage = false;

            for (File imageFile : applicationArtwork.getAllImageFiles()) {
                if (imageFile.getName().equals(standardImageFilename)) {
                    standardApplicationImageFiles.add(imageFile);
                    foundStandardImage = true;
                }
            }

            if (!foundStandardImage)
                missingStandardApplicationImageFilenames.add(standardImageFilename);
        }

        applicationArtwork.setStandardApplicationImageFiles(standardApplicationImageFiles);
        applicationArtwork.setMissingStandardApplicationImageFilenames(missingStandardApplicationImageFilenames);
    }

    /** @return All image files in the project. */
    protected SortedSet<File> extractAllImageFiles(File projectRootDirectory) {
        SortedSet<File> allImageFiles = new TreeSet<File>();

        for (File pngFile : listFiles(projectRootDirectory,
                new SuffixFileFilter(new ArrayList<String>(imageFileSuffixes())),
                new NotFileFilter(new NameFileFilter(new ArrayList<String>(ignoredDirectoryNames())))))
            allImageFiles.add(pngFile);

        return allImageFiles;
    }

    protected SortedSet<String> extractFilenames(Iterable<File> files) {
        SortedSet<String> filenames = new TreeSet<String>();
        for (File file : files)
            filenames.add(file.getAbsolutePath());
        return filenames;
    }

    protected void detectImageMetrics(ApplicationArtwork applicationArtwork) throws IOException {
        SortedMap<File, ImageMetrics> allImageFilesWithMetrics = new TreeMap<File, ImageMetrics>();
        double sizeOfAllImagesFilesInBytes = 0;

        for (File imageFile : applicationArtwork.getAllImageFiles()) {
            byte[] imageData = readFileToByteArray(imageFile);
            sizeOfAllImagesFilesInBytes += imageData.length;
            allImageFilesWithMetrics.put(imageFile, ImageUtilities.extractImageMetrics(imageData));
        }

        applicationArtwork.setAllImageFilesWithMetrics(allImageFilesWithMetrics);
        applicationArtwork.setSizeOfAllImagesFilesInBytes(sizeOfAllImagesFilesInBytes);
    }

    protected SortedSet<File> extractIncorrectlySizedRetinaImageFiles(ApplicationArtwork applicationArtwork) {
        SortedSet<File> incorrectlySizedRetinaImageFiles = new TreeSet<File>();

        for (File retinaImageFile : applicationArtwork.getRetinaImageFiles()) {
            ImageMetrics imageMetrics = applicationArtwork.getAllImageFilesWithMetrics().get(retinaImageFile);
            if (imageMetrics.getWidth() % 2 != 0 || imageMetrics.getHeight() % 2 != 0)
                incorrectlySizedRetinaImageFiles.add(retinaImageFile);
        }

        return incorrectlySizedRetinaImageFiles;
    }

    protected SortedSet<File> extractImageFilesWithIncorrectDeviceSuffix(Iterable<File> imageFiles) {
        SortedSet<File> imageFilesWithIncorrectDeviceSuffix = new TreeSet<File>();

        for (File imageFile : imageFiles)
            if (imageFile.getName().contains("~iphone"))
                imageFilesWithIncorrectDeviceSuffix.add(imageFile);

        return imageFilesWithIncorrectDeviceSuffix;
    }

    /** @return Mapping of files that could potentially include image references -> their textual contents. */
    protected Map<File, String> extractContentsOfReferencingFiles(File projectRootDirectory) throws IOException {
        Map<File, String> referencingFilesToContents = new HashMap<File, String>();

        for (File textFile : listFiles(projectRootDirectory,
                new SuffixFileFilter(new ArrayList<String>(referencingFileSuffixes())), TrueFileFilter.INSTANCE)) {
            String contents = readFileToString(textFile);

            if (contents.length() > 0)
                referencingFilesToContents.put(textFile, contents);
        }

        return referencingFilesToContents;
    }

    /**
     * @return All possible variants of the given image file. For example, an input of {@code background.png} would return
     *         values like {@code background@2x.png}, {@code background~ipad.png}, etc.
     */
    SortedSet<String> imageFilenameVariants(String imageFilename) {
        SortedSet<String> filenameVariants = new TreeSet<String>();

        filenameVariants.add(imageFilename);

        // Remove .png
        imageFilename = imageFilename.substring(0, imageFilename.lastIndexOf("."));
        filenameVariants.add(imageFilename);

        // Remove @2x
        int lastIndexOf2x = imageFilename.lastIndexOf("@2x");
        if (lastIndexOf2x >= 0) {
            imageFilename = imageFilename.substring(0, lastIndexOf2x);
            filenameVariants.add(imageFilename);
        }

        // Remove ~ipad
        int lastIndexOfIpad = imageFilename.lastIndexOf("~ipad");
        if (lastIndexOfIpad >= 0) {
            imageFilename = imageFilename.substring(0, lastIndexOfIpad);
            filenameVariants.add(imageFilename);
        }

        // Remove ~iphone
        int lastIndexOfIphone = imageFilename.lastIndexOf("~iphone");
        if (lastIndexOfIphone >= 0) {
            imageFilename = imageFilename.substring(0, lastIndexOfIphone);
            filenameVariants.add(imageFilename);
        }

        // We must be in our most basic form now, e.g. "ma" from original ma@2x~ipad.png.
        // Let's work back up and add all possible variants in case there were any wacky references to the image, like
        // "ma.png"
        filenameVariants.add(format("%s.png", imageFilename));
        filenameVariants.add(format("%s~ipad.png", imageFilename));
        filenameVariants.add(format("%s~iphone.png", imageFilename));
        filenameVariants.add(format("%s@2x.png", imageFilename));
        filenameVariants.add(format("%s@2x~ipad.png", imageFilename));
        filenameVariants.add(format("%s@2x~iphone.png", imageFilename));

        return filenameVariants;
    }

    protected String retinaImageFilename(String imageFilename) {
        // If we're already a retina image, nothing to do
        if (imageFilename.indexOf("@2x") != -1)
            return imageFilename;

        int lastIndexOfIpad = imageFilename.lastIndexOf("~ipad");
        int lastIndexOfIphone = imageFilename.lastIndexOf("~iphone");
        int replacementIndex = -1;

        if (lastIndexOfIpad != -1)
            replacementIndex = lastIndexOfIpad;
        else if (lastIndexOfIphone != -1)
            replacementIndex = lastIndexOfIphone;
        else
            replacementIndex = imageFilename.lastIndexOf(".png");

        return format("%s@2x%s", imageFilename.substring(0, replacementIndex),
                imageFilename.substring(replacementIndex));
    }

    /**
     * @return Creates a backing thread pool used for concurrent execution of image processing tasks.
     */
    protected ExecutorService createExecutorService() {
        int coreThreadCount = getRuntime().availableProcessors();
        int maximumThreadCount = coreThreadCount;
        int unusedThreadTerminationTimeoutInSeconds = 5;

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(coreThreadCount, maximumThreadCount,
                unusedThreadTerminationTimeoutInSeconds, SECONDS, new LinkedBlockingQueue<Runnable>());

        threadPoolExecutor.allowCoreThreadTimeOut(true);

        return threadPoolExecutor;
    }

    /** @return The backing thread pool used for concurrent execution of image processing tasks. */
    protected ExecutorService getExecutorService() {
        return executorService;
    }

    /**
     * @return Suffixes for files which may reference images. For example: .m, .xib, .pbxproj
     */
    public Set<String> referencingFileSuffixes() {
        return REFERENCING_FILE_SUFFIXES;
    }

    /**
     * @return Suffixes for image files we'd like to detect. For example: .png
     */
    public Set<String> imageFileSuffixes() {
        return IMAGE_FILE_SUFFIXES;
    }

    /**
     * @return Apple-defined standard application images. For example: Icon-72.png
     */
    public Set<String> standardApplicationImageFilenames() {
        return STANDARD_APPLICATION_IMAGE_FILENAMES;
    }

    /**
     * @return Directories to skip over when detecting images. For example: FacebookSDK.framework. Default behavior is to
     *         not skip any directories.
     */
    public Set<String> ignoredDirectoryNames() {
        return IGNORED_DIRECTORY_NAMES;
    }
}