Showing posts with label utility. Show all posts
Showing posts with label utility. Show all posts

Monday, December 12, 2011

First OSX Utility - find unreferenced image files

Today I wrote my first OSX application. I have been writing iPhone apps in Objective C but I had not done an OSX application. I needed a utility to tell me what image files were unreferenced in my iOS project so I can remove them and clean things up.

Started out with a base OSX Application in Xcode. Added some basic fields to the XIB file - Directory name, [Browse...] button, [Find Issues] button and a text area to show the results.

Next I figured out how to browse for a single directory and get the URL back. Take that URL and do a recursive search for all other directories. In each directory find image files using an image extension list and file all code files (*.m, *.h).

Once all the file information is available start looking in the *.xib, *.h and *.m code for images either by NSResourceName, extension or by imageNamed: as that call will automatically look for PNG files. As we find references in code increment the image name reference. I have code to ignore commented out lines and blocks of code although the block indicator needs to start the line not be buried within a line at this time.

At the end of the run it builds a string of all orphan image files (those without reference in code) and populates the text area with the list. This leaves some false positives for icons that you need to publish the app but those are pretty easy to ignore at this point.

I added a progress indicator as the program run can take a few seconds to run, clean up of the menu system to show only valid items, update the about dialog text and adding a real program icon.

Finally I added a check for {name}.png / {name}@2x.png to show what missing retina images or orphan retina images you happen to have. I found eight images that I had not done a retina version of so this was a handy validation. I also found an image that was not being used and was being referenced from an unrelated directory.

This does find false positives but mainly on the numerous icon files you need for all iOS device flavors. I might need to add another text field for you to type in "files that start with {string}" to ignore but only people who are OCD enough to crave an error free run would care. Everyone else will go "yeah, forget those" and move on.

I am using some code I found thanks to Stack Overflow to read basic text files a line at a time. Totally surprised, although at this point I should not be, that is missing from the core library. So odd to me how many very basic things are missing from the iOS / OSX SDK. It still appears that Apple likes to add new API areas but not make existing API calls better.

My overall understanding of Xcode / XIB files / Objective C put the whole project in at under a day. I learned how to query for a directory, roll through a directory structure, read lines from a text file and do widget anchoring in XIB so my window resizes / relayouts out controls as expected.

At this point the program is not as powerful as the Java version I wrote for Android project validation. On the Android side I can check images, strings, layouts, animations, etc. and I don't have any false positives as all my icons are referenced in one of my XML files. For OSX I am making a bit more of a guess about the code to see if you are using an image. The utility will help me do this round of clean up and will keep it clean as time goes on. I really wish the SVN integration with Xcode was better. I know I can delete the file from the project but it does not delete it from SVN. I will have to manually clean that up or do the work under AppCode from Jetbrains as it does full integration.

Friday, December 9, 2011

New Android Utility - find orphan references

I decided to write a new utility to help clean up my Android project. It is a simple Java command line only program, no JFrame / no GUI, that takes a base directory as its only parameter. From that it builds a list of all sub-directories (ignoring .svn directories) then finds the Android generated R.java file.

R.java is parsed based on "public static final class" and "public static final int" to build a list of ID strings.

Each .JAVA and .XML file is then scanned in the directory tree looking for resource IDs in their various formats such as @string/ in XML or R.string. in Java. The reference count is incremented. At the end of the run I print out all the items that have a reference count of zero. I was able to clear up a dozen strings, a couple of layouts and 10 images from the project. Strings and layouts don't amount to much but images sure can when it comes to download size. Some of the images were in multiple directories to handle various Android sizes. Getting rid of layouts is always good as you might open one, edit it a bunch, the find out you edited something that is never used. Killing a layout might kill other image and string references too.

I wrote something similar to this at a previous job to find orphan strings before we sent the Java and C++ programs out to be localized. That saved us from paying money to localize a string that was never used in the program. I also checked for orphan images against the Java code and some other orphan resources against the C++ code. It would be nice if this was just built into Eclipse.

Now I need to do something similar for the iOS side. It will not be as straight forward as the images are directly referenced in the code as string names and I need to add a special check for @2x images to make sure they have a non-retina mate. I guess this would be a good time for me to write my first non-iPhone based Objective C program. Might put a GUI around it with a [Browse..] button to pick the directory and a list control to show the results. Seems to be cheating to write just a command line program on the Mac.

I want to run the program a few more times against various XML / Java code formats before I release it to the wild. It appears to work like a champ against the code I write but I am the only author and follow a consistent code format which does not make for a well rounded test. I kept getting false positives until I tweaked various bits of the parser. Trust but Verify was the rule until the final run when it was 100% accurate for my coding style.

Anyone have a big interest in this program and want to be a beta tester? Leave a comment and I can send you the source code (it is all of 288 lines in a single file) so you can see what you think and request improvements.

 ** Update ** Code appears below for anyone to run and check out

package org.peck;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * Simple command line program to find the R.java Android generated code and
 * look for orphan references against *.java and *.XML files  
 * 
 * @author kevin.peck
 * @date December 9, 2011
 */
public class AndroidResourceScan {
    
    private static List<String> directories = new ArrayList<String>();
    private static List<RefInfo> references = new ArrayList<RefInfo>();
    
    private static List<XmlMapping> xmlMapping = new ArrayList<XmlMapping>();
    
    private static final String RESOURCE_FILE = "R.java";
    private static final String CLASS_START = "public static final class";
    private static final String ITEM_START = "public static final int";
    
    /**
     * Android resource scanner
     * 
     * Find and parse the R.java file and see if there items in that file
     * that are not referenced in any *.java or *.xml files
     * 
     * @param args  Arguments, base directory
     */
    public static void main(String[] args) {
        if (args.length == 0) {
            System.out.println("Android Resource Scan by Kevin Peck");
            System.out.println();
            System.out.println("Parse the Android generated R.java file then look");
            System.out.println("for orphan references - any defined IDs that don't");
            System.out.println("appear in a JAVA or XML file in same directory tree.");
            System.out.println("Comment lines are ignore in all files");
            System.out.println("NOTE: .svn directories are ignored.");
            
            System.out.println();
            System.out.println("Usage: AndroidResourceScan {base path}");
            return;
        }
        
        xmlMapping.add(new XmlMapping("@string/", "R.string."));
        xmlMapping.add(new XmlMapping("@drawable/", "R.drawable."));
        xmlMapping.add(new XmlMapping("@color/", "R.color."));
        xmlMapping.add(new XmlMapping("@style/", "R.style."));
        xmlMapping.add(new XmlMapping("@id/", "R.id."));
        xmlMapping.add(new XmlMapping("@+id/", "R.id."));
        xmlMapping.add(new XmlMapping("@anim/", "R.anim."));
        xmlMapping.add(new XmlMapping("style name=\"", "R.style."));
        xmlMapping.add(new XmlMapping("<attr name=\"", "R.attr."));
        System.out.println("Scanning " + args[0] + " for R.java file");
        buildDirTree(args[0]);
        
        boolean haveResFile = false;
        File resFile = null;
        for (String dirName : directories) {
            resFile = new File(dirName + RESOURCE_FILE);
            if (resFile.exists()) {
                haveResFile = true;
                break;
            }
        }
        
        if (!haveResFile || (resFile == null)) {
            System.out.println("Unable to find " + RESOURCE_FILE + " in " + args[0] + " directory tree");
            return;
        }
        System.out.println("Found resource file " + resFile.getAbsolutePath());
        buildRefList(resFile.getAbsolutePath());
        
        System.out.println("Scanning JAVA files for references");
        scanFiles();
        
        int orphanCount = 0;
        System.out.println("Orphans found");
        for (RefInfo refInfo : references) {
            if (refInfo.refCount == 0) {
                orphanCount++;
                System.out.println(refInfo.name);
            }
        }
        System.out.println("total " + orphanCount);
    }
    
    /**
     * Scan the directory tree .java and .XML files 
     * Send them to proper parser as found
     */
    private static void scanFiles() {
        for (String dir : directories) {
            File fDir = new File(dir);
            String [] files = fDir.list();
            for (String fileName : files) {
                if (fileName.endsWith(".java")) {
                    scanJavaFile(dir + '/' + fileName);
                } else if (fileName.endsWith(".xml")) {
                    scanXmlFile(dir + '/' + fileName);
                }
            }
        }
    }
    
    /**
     * Open a java file and scan it for R. references
     *  
     * @param fileName
     */
    private static void scanJavaFile(String fileName) {
        BufferedReader reader;
        try {
            reader = new BufferedReader(new FileReader(fileName));
            String line;
            boolean inComment = false;
            
            while ((line = reader.readLine()) != null) {
                line = line.trim();
                if (inComment && line.endsWith("*/")) {
                    inComment = false;
                } else if (!inComment && line.startsWith("/*")) {
                    inComment = true;
                } else if (!line.startsWith("//")) {
                    int iStart = line.indexOf("R.");
                    while (iStart != -1) {
                        if (iStart != 0 && " (=".indexOf(line.charAt(iStart - 1)) != -1) {
                            int iEnd = iStart;
                            while (iEnd < line.length() && " ,);".indexOf(line.charAt(iEnd)) == -1) {
                                iEnd++;
                            }
                            String refName = line.substring(iStart, iEnd);
                            for (RefInfo refInfo : references) {
                                if (refInfo.name.equals(refName)) {
                                    refInfo.refCount++;
                                    break;
                                }
                            }
                        }
                        iStart = line.indexOf("R.", iStart + 1);
                    }
                }
            }
            reader.close();
        } catch (IOException exp) {
            exp.printStackTrace();
        }
    }
    
    /**
     * Scan XML file looking for XML mapped entries
     * 
     * @param fileName  Name of file to scan
     */
    private static void scanXmlFile(String fileName) {
        BufferedReader reader;
        try {
            reader = new BufferedReader(new FileReader(fileName));
            String line;
            
            while ((line = reader.readLine()) != null) {
                line = line.trim();
                if (!line.startsWith("<!--")) {
                    for (XmlMapping xmlMap : xmlMapping) {
                        int iStart = line.indexOf(xmlMap.xmlName);
                        if (iStart != -1) {
                            int iEnd = iStart + xmlMap.xmlName.length();
                            while (iEnd < line.length() && " \"><".indexOf(line.charAt(iEnd)) == -1) {
                                iEnd++;
                            }
                            String refName = xmlMap.resName + line.substring(iStart + xmlMap.xmlName.length(), iEnd).trim();
                            for (RefInfo refInfo : references) {
                                if (refInfo.name.equals(refName)) {
                                    refInfo.refCount++;
                                    break;
                                }
                            }
                        }
                    }
                }
            }
            reader.close();
        } catch (IOException exp) {
            exp.printStackTrace();
        }
    }

    /**
     * Build directory tree based on a root node
     *
     * @param baseDir  Root node to scan for sub directories
     */
    private static void buildDirTree(String baseDir) {
        String topDir = baseDir;
        if (!topDir.endsWith("/") && !topDir.endsWith("\\")) {
            topDir += "/";
        }
        File fDir = new File(topDir);
        if (fDir.isDirectory()) {
            directories.add(topDir.replace("//", "/"));
            String [] files = fDir.list();
            for (String fileName : files) {
                String subDir = topDir + '/' + fileName;
                File testDir = new File(subDir);
                if (testDir.isDirectory() && !subDir.contains("/.svn")) {
                    buildDirTree(subDir);
                }
            }
        }
    }
    
    /**
     * Build a list of public static final class references found in given file
     * 
     * @param fileName  Name of file to process ({path}/R.java)
     */
    private static void buildRefList(String fileName) {
        BufferedReader reader;
        try {
            reader = new BufferedReader(new FileReader(fileName));
            String line;
            String className = "";
            boolean inComment = false;
            boolean inClass = false;
            
            while ((line = reader.readLine()) != null) {
                line = line.trim();
                if (inComment && line.endsWith("*/")) {
                    inComment = false;
                } else if (!inComment && line.startsWith("/*")) {
                    inComment = true;
                } else if (!line.startsWith("//")) {
                    if (!inClass && line.startsWith(CLASS_START)) {
                        className = line.substring(CLASS_START.length()).trim();
                        if (className.endsWith("{")) {
                            className = className.substring(0, className.length() - 2);
                        }
                        inClass = true;
                    } else if (inClass) {
                        if (line.startsWith(ITEM_START)) {
                            int nameEnd = line.lastIndexOf('=');
                            if (nameEnd != -1) {
                                references.add(new RefInfo("R." + className + '.' + line.substring(ITEM_START.length() + 1, nameEnd)));
                            }
                        }
                        if (line.endsWith("}")) {
                            inClass = false;
                        }
                    }
                }
            }
            reader.close();
        } catch (IOException exp) {
            exp.printStackTrace();
        }
    }
    
    /**
     * XML to Resource format mapping 
     */
    static class XmlMapping {
        public String xmlName;
        public String resName;
        
        public XmlMapping(String xmlName, String resName) {
            this.xmlName = xmlName;
            this.resName = resName;
        }
    }

    /**
     * Resource name and reference count (all we care about is count != 0) 
     */
    static class RefInfo {
        public String name;
        public int refCount;
        
        public RefInfo(String name) {
            this.name = name;
        }
    }
}

Feel free to report any bugs you find back to me.