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.

3 comments:

  1. Hi,
    I would like to check it out.

    ReplyDelete
  2. What's the difference with AndroLint introduced in the last SDK ?

    ReplyDelete
    Replies
    1. Android Lint came out after I wrote this and does cover some of the same ground. I really wish I had known it was coming out. I did write a very similar program for iOS and I still use both of them.

      Of course you could add this program to your nightly build which I am unsure if you can do that with the Eclipse Lint tool.

      I wanted to provide the source code so others might find other parsing rules they could add to the code to go above what it currently does.

      Delete