All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.robovm.compiler.plugin.objc.InterfaceBuilderClassesPlugin Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (C) 2015 RoboVM AB
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see .
 */
package org.robovm.compiler.plugin.objc;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Map.Entry;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.filefilter.IOFileFilter;
import org.apache.commons.io.filefilter.SuffixFileFilter;
import org.apache.commons.lang3.StringUtils;
import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Opcodes;
import org.robovm.compiler.Linker;
import org.robovm.compiler.clazz.Clazz;
import org.robovm.compiler.config.Config;
import org.robovm.compiler.config.Config.Builder;
import org.robovm.compiler.config.Resource;
import org.robovm.compiler.config.Resource.Walker;
import org.robovm.compiler.log.Logger;
import org.robovm.compiler.plugin.AbstractCompilerPlugin;
import org.robovm.compiler.plugin.CompilerPlugin;

/**
 * {@link CompilerPlugin} which forces view controllers and views referenced by
 * Storyboards and XIB files in resource folders to be linked into the
 * executable. Also embeds a list of such classes into the executable which can
 * later be pre-registered in {@code UIApplication.main(...)} when the app is
 * launched.
 */
public class InterfaceBuilderClassesPlugin extends AbstractCompilerPlugin {
    private static final String[] JAR_ZIP_EXTENSIONS = new String[] { "jar", "zip" };
    private static final String CLASS_EXTENSION = "class";
    private static final String CUSTOM_CLASS = "Lorg/robovm/objc/annotation/CustomClass;";
    private static final String NATIVE_CLASS = "Lorg/robovm/objc/annotation/NativeClass;";
    private static final Pattern IB_CLASS_PATTERN = Pattern.compile(".*(ViewController|View)");
    /**
     * Ignore package names like this when searching classpath folders for
     * classes in {@link #buildClassToUrlMap(List)}.
     */
    private static final Pattern EXCLUDED_PACKAGES = Pattern.compile("org\\.robovm\\.apple\\..*");
    private static final String RUNTIME_DATA_ID = "org.robovm.apple.uikit.UIApplication.preloadClasses";

    private Logger logger;
    private List preloadClasses;

    @Override
    public void beforeConfig(Builder builder, final Config config) throws IOException {
        logger = config.getLogger();
        preloadClasses = new ArrayList<>();

        List customClasses = findCustomClassesInIBFiles(config);
        if (customClasses.isEmpty()) {
            // Nothing needs to be done by this plugin.
            return;
        }

        // We now have a list of ObjC class names. We need to map those to Java
        // class names. ObjC class names are generated in two ways:
        // 1. Auto-generated by taking the fully-qualified class name of the
        // Java class, replacing '.' by '_' and prepending 'j_'.
        // 2. Using a @CustomClass annotation on the Java class.
        //
        // Reversing 1 is easy. We just iterate the classes in the configured
        // classpath, apply the same rule to each class name and look for a
        // match in customClasses.
        //
        // To reverse 2 we need to parse class files which is more time
        // consuming. We don't want to parse every class in the classpath so
        // first we look for simple class names which match the ObjC class names
        // and look for a @CustomClass annotation on those. Then we look for
        // classes with names that suggest they are view controllers/views.
        // Finally we have to parse every class in the classpath.

        // Build a map of class names to URLs.
        List classpath = new ArrayList<>();
        classpath.addAll(config.getBootclasspath());
        classpath.addAll(config.getClasspath());
        Map classToUrlMap = buildClassToUrlMap(classpath);

        // Build a map of auto-generated ObjC class names to Java class names.
        Map autoNameToJavaName = new HashMap<>();
        for (String javaName : classToUrlMap.keySet()) {
            autoNameToJavaName.put(getAutoName(javaName), javaName);
        }

        Map result = new HashMap<>();
        LinkedList unresolved = new LinkedList<>(customClasses);
        Map customClassValuesCache = new HashMap<>();

        // Resolve auto-generated.
        for (Iterator it = unresolved.iterator(); it.hasNext();) {
            String objCName = it.next();
            String javaName = autoNameToJavaName.get(objCName);
            if (javaName != null) {
                result.put(objCName, javaName);
                it.remove();
            }
        }

        // Resolve classes which match by simple name.
        outer: for (Iterator it = unresolved.iterator(); it.hasNext();) {
            String objCName = it.next();

            for (String javaName : classToUrlMap.keySet()) {
                if (matchSimpleName(objCName, javaName)) {
                    URL url = classToUrlMap.get(javaName);
                    if (objCName.equals(getCustomClass(url, customClassValuesCache))) {
                        result.put(objCName, javaName);
                        it.remove();
                        continue outer;
                    }
                    if (isNativeClass(url)) {
                        // its native class no need to add it to pre-load classes just remove it from unresolved
                        it.remove();
                        continue outer;
                    }
                }
            }
        }

        // Resolve classes by looking for Java classes which have names looking
        // like view controllers/views.
        if (!unresolved.isEmpty()) {
            Map candidates = new HashMap<>();
            for (String javaName : classToUrlMap.keySet()) {
                if (looksLikeObjCClass(javaName)) {
                    String s = getCustomClass(classToUrlMap.get(javaName), customClassValuesCache);
                    if (s != null) {
                        candidates.put(s, javaName);
                    }
                }
            }
            for (Iterator it = unresolved.iterator(); it.hasNext();) {
                String objCName = it.next();
                String javaName = candidates.get(objCName);
                if (javaName != null) {
                    result.put(objCName, javaName);
                    it.remove();
                }
            }
        }

        // Finally parse every class on the classpath and look for @CustomClass
        // annotations.
        if (!unresolved.isEmpty()) {
            outer: for (Iterator it = unresolved.iterator(); it.hasNext();) {
                String objCName = it.next();
                for (String javaName : classToUrlMap.keySet()) {
                    String s = getCustomClass(classToUrlMap.get(javaName), customClassValuesCache);
                    if (objCName.equals(s)) {
                        result.put(objCName, javaName);
                        it.remove();
                        continue outer;
                    }
                }
            }
        }

        if (!unresolved.isEmpty()) {
            logger.warn("Failed to find Java classes for the following Objective-C classes in Storyboard/XIB files: %s",
                    unresolved);
        }

        for (Entry entry : result.entrySet()) {
            builder.addForceLinkClass(entry.getValue());
            preloadClasses.add(entry.getValue());
        }
    }

    @Override
    public void beforeLinker(Config config, Linker linker, Set classes) throws IOException {
        if (!preloadClasses.isEmpty()) {
            linker.addRuntimeData(RUNTIME_DATA_ID, StringUtils.join(preloadClasses, ",").getBytes("UTF8"));
        }
    }

    private boolean looksLikeObjCClass(String javaName) {
        return IB_CLASS_PATTERN.matcher(javaName).matches();
    }

    private boolean matchSimpleName(String objCName, String javaName) {
        if (objCName.equals(javaName)) {
            return true;
        }
        if (javaName.length() > objCName.length() && javaName.endsWith(objCName)) {
            char c = javaName.charAt(javaName.length() - objCName.length() - 1);
            return c == '.' || c == '$';
        }
        return false;
    }

    private String getCustomClass(URL url, Map customClassValuesCache) throws IOException {
        if (customClassValuesCache.containsKey(url)) {
            return customClassValuesCache.get(url);
        }

        class Visitor extends ClassVisitor {
            String customClass;

            Visitor() {
                super(Opcodes.ASM4);
            }

            @Override
            public AnnotationVisitor visitAnnotation(final String desc, boolean visible) {
                if (CUSTOM_CLASS.equals(desc)) {
                    return new AnnotationVisitor(Opcodes.ASM4) {
                        public void visit(String name, Object value) {
                            customClass = (String) value;
                        }
                    };
                }
                return super.visitAnnotation(desc, visible);
            }
        }

        Visitor visitor = new Visitor();
        new ClassReader(IOUtils.toByteArray(url.openStream())).accept(visitor, 0);
        customClassValuesCache.put(url, visitor.customClass);
        return visitor.customClass;
    }

    private boolean isNativeClass(URL url) throws IOException {
        class Visitor extends ClassVisitor {
            private boolean nativeClass;

            private Visitor() {
                super(Opcodes.ASM4);
            }

            @Override
            public AnnotationVisitor visitAnnotation(final String desc, boolean visible) {
                if (NATIVE_CLASS.equals(desc)) {
                    nativeClass = true;
                }
                return super.visitAnnotation(desc, visible);
            }
        }

        Visitor visitor = new Visitor();
        new ClassReader(IOUtils.toByteArray(url.openStream())).accept(visitor, 0);
        return visitor.nativeClass;
    }

    private String getAutoName(String javaName) {
        return "j_" + javaName.replace('.', '_');
    }

    private boolean isJarFile(File f) {
        return f.isFile() && FilenameUtils.isExtension(f.getName(), JAR_ZIP_EXTENSIONS);
    }

    private Map buildClassToUrlMap(List paths) {
        // Reverse the list since classes in the first paths should take
        // precedence over classes in latter paths.
        Collections.reverse(paths);

        Map classToUrlMap = new HashMap<>();

        for (File path : paths) {
            if (isJarFile(path)) {
                try (ZipFile zipFile = new ZipFile(path)) {
                    for (ZipEntry entry : Collections.list(zipFile.entries())) {
                        if (!entry.isDirectory()) {
                            if (FilenameUtils.isExtension(entry.getName(), CLASS_EXTENSION)) {
                                String className = FilenameUtils.removeExtension(entry.getName()).replace('/', '.');
                                URL url = new URL("jar", null, -1, path.toURI().toString() + "!/" + entry.getName());
                                classToUrlMap.put(className, url);
                            }
                        }
                    }
                } catch (IOException e) {
                    logger.warn("Failed to read JAR/ZIP file %s: %s", path.getAbsolutePath(), e.getMessage());
                }
            } else if (path.isDirectory()) {
                path = path.getAbsoluteFile();
                for (File f : FileUtils.listFiles(path, new SuffixFileFilter("." + CLASS_EXTENSION),
                        new PackageNameFilter(path.getAbsolutePath()))) {

                    String className = FilenameUtils.removeExtension(f.getAbsolutePath());
                    className = className.substring(path.getAbsolutePath().length() + 1);
                    className = className.replace(File.separatorChar, '.');
                    try {
                        classToUrlMap.put(className, f.toURI().toURL());
                    } catch (MalformedURLException e) {
                        throw new Error(e);
                    }
                }
            }
        }

        return classToUrlMap;
    }

    private static class PackageNameFilter implements IOFileFilter {
        private final String baseDir;

        public PackageNameFilter(String baseDir) {
            this.baseDir = baseDir;
        }

        @Override
        public boolean accept(File file) {
            String packag = file.getAbsolutePath().substring(baseDir.length() + 1).replace(File.separatorChar, '.');
            return !EXCLUDED_PACKAGES.matcher(packag).matches();
        }

        @Override
        public boolean accept(File dir, String name) {
            // Never called so just return true
            return true;
        }

    }

    private List findCustomClassesInIBFiles(final Config config) throws IOException {
        final List customClasses = new ArrayList<>();

        for (Resource res : config.getResources()) {
            res.walk(new Walker() {
                @Override
                public boolean processDir(Resource resource, File dir, File destDir) throws IOException {
                    return true;
                }

                @Override
                public void processFile(Resource resource, File file, File destDir)
                        throws IOException {

                    String filename = file.getName().toLowerCase();
                    if (filename.endsWith(".storyboard") || filename.endsWith(".xib")) {
                        try {
                            customClasses.addAll(findCustomClassesInIBFile(file));
                        } catch (XMLStreamException | IOException e) {
                            // Storyboard or Xib may be corrupt.
                            config.getLogger().warn("Failed to read Interface Builder file %s: %s",
                                    file.getAbsolutePath(), e.getMessage());
                        }
                    }
                }
            });
        }

        return customClasses;
    }

    private List findCustomClassesInIBFile(File file) throws XMLStreamException, IOException {
        List customClasses = new ArrayList<>();

        try (FileInputStream fis = FileUtils.openInputStream(file)) {
            XMLInputFactory factory = XMLInputFactory.newInstance();
            XMLStreamReader reader = factory.createXMLStreamReader(fis);

            while (reader.hasNext()) {
                int event = reader.next();

                switch (event) {
                case XMLStreamConstants.START_ELEMENT:
                    String customClass = reader.getAttributeValue(null, "customClass");
                    if (customClass != null && !customClass.trim().isEmpty()) {
                        customClasses.add(customClass);
                    }
                }
            }
            reader.close();
        }

        return customClasses;
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy