/* * 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 <http://www.gnu.org/licenses/gpl-2.0.html>. */ 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 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<String> preloadClasses; @Override public void beforeConfig(Builder builder, final Config config) throws IOException { logger = config.getLogger(); preloadClasses = new ArrayList<>(); List<String> 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<File> classpath = new ArrayList<>(); classpath.addAll(config.getBootclasspath()); classpath.addAll(config.getClasspath()); Map<String, URL> classToUrlMap = buildClassToUrlMap(classpath); // Build a map of auto-generated ObjC class names to Java class names. Map<String, String> autoNameToJavaName = new HashMap<>(); for (String javaName : classToUrlMap.keySet()) { autoNameToJavaName.put(getAutoName(javaName), javaName); } Map<String, String> result = new HashMap<>(); LinkedList<String> unresolved = new LinkedList<>(customClasses); Map<URL, String> customClassValuesCache = new HashMap<>(); // Resolve auto-generated. for (Iterator<String> 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<String> it = unresolved.iterator(); it.hasNext();) { String objCName = it.next(); for (String javaName : classToUrlMap.keySet()) { if (matchSimpleName(objCName, javaName)) { if (objCName.equals(getCustomClass(classToUrlMap.get(javaName), customClassValuesCache))) { result.put(objCName, javaName); it.remove(); continue outer; } } } } // Resolve classes by looking for Java classes which have names looking // like view controllers/views. if (!unresolved.isEmpty()) { Map<String, String> 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<String> 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<String> 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<String, String> entry : result.entrySet()) { builder.addForceLinkClass(entry.getValue()); preloadClasses.add(entry.getValue()); } } @Override public void beforeLinker(Config config, Linker linker, Set<Clazz> 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<URL, String> 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)).accept(visitor, 0); customClassValuesCache.put(url, visitor.customClass); return visitor.customClass; } 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<String, URL> buildClassToUrlMap(List<File> paths) { // Reverse the list since classes in the first paths should take // precedence over classes in latter paths. Collections.reverse(paths); Map<String, URL> 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<String> findCustomClassesInIBFiles(final Config config) throws IOException { final List<String> 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<String> findCustomClassesInIBFile(File file) throws XMLStreamException, IOException { List<String> 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; } }