blob: a32d6a9247dd198c5714cf22886fe124c48c7f66 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.idea.uibuilder.palette;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.rendering.api.SessionParams;
import com.android.ide.common.rendering.api.ViewInfo;
import com.android.resources.ResourceFolderType;
import com.android.sdklib.IAndroidTarget;
import com.android.sdklib.SdkVersionInfo;
import com.android.tools.idea.configurations.Configuration;
import com.android.tools.idea.rendering.*;
import com.android.tools.idea.uibuilder.api.InsertType;
import com.android.tools.idea.uibuilder.model.NlComponent;
import com.android.tools.idea.uibuilder.model.NlModel;
import com.android.tools.idea.uibuilder.surface.ScreenView;
import com.google.common.io.CharStreams;
import com.intellij.ide.highlighter.XmlFileType;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.PathManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Computable;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiFileFactory;
import com.intellij.psi.XmlElementFactory;
import com.intellij.psi.xml.XmlTag;
import com.intellij.util.IncorrectOperationException;
import com.intellij.util.PathUtil;
import com.intellij.util.ui.UIUtil;
import org.jetbrains.android.facet.AndroidFacet;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.List;
import static com.android.SdkConstants.*;
/**
* IconPreviewFactory generates a preview of certain palette components.
* The images are rendered from preview.xml and are used as an alternate representation on
* the palette i.e. a button is rendered as the SDK button would look like on the target device.
*/
public class IconPreviewFactory {
private static final Logger LOG = Logger.getInstance(IconPreviewFactory.class);
private static final String DEFAULT_THEME = "AppTheme";
private static final String PREVIEW_PLACEHOLDER_FILE = "preview.xml";
private static final String[] PREVIEW_FILES = {"preview1.xml", "preview2.xml", "preview3.xml", "preview4.xml"};
private static final String LINEAR_LAYOUT = "<LinearLayout\n" +
" xmlns:android=\"http://schemas.android.com/apk/res/android\"\n" +
" android:layout_width=\"wrap_content\"\n" +
" android:layout_height=\"wrap_content\"\n" +
" android:orientation=\"vertical\">\n" +
" %1$s\n" +
"</LinearLayout>\n";
private static IconPreviewFactory ourInstance;
@NonNull
public static IconPreviewFactory get() {
if (ourInstance == null) {
ourInstance = new IconPreviewFactory();
}
return ourInstance;
}
private IconPreviewFactory() {
}
@Nullable
public BufferedImage getImage(@NonNull NlPaletteItem item, @NonNull Configuration configuration, double scale) {
BufferedImage image = readImage(item.getId(), configuration);
if (image == null) {
return null;
}
return ImageUtils.scale(image, scale, scale);
}
/**
* Return a component image to display while dragging a component from the palette.
* Return null if such an image cannot be rendered. The palette must provide a fallback in this case.
*/
@Nullable
public BufferedImage renderDragImage(@NonNull NlPaletteItem item, @NonNull ScreenView screenView, double scale) {
XmlElementFactory elementFactory = XmlElementFactory.getInstance(screenView.getModel().getProject());
String xml = item.getRepresentation();
if (xml.isEmpty()) {
return null;
}
xml = addAndroidNamespaceIfMissing(item.getRepresentation());
XmlTag tag = null;
try {
tag = elementFactory.createTagFromText(xml);
} catch (IncorrectOperationException ignore) {
}
if (tag == null) {
return null;
}
NlModel model = screenView.getModel();
NlComponent component = model.createComponent(screenView, tag, null, null, InsertType.CREATE_PREVIEW);
if (component == null) {
return null;
}
// Some components require a parent to render correctly.
xml = String.format(LINEAR_LAYOUT, component.getTag().getText());
RenderResult result = renderImage(xml, model.getConfiguration());
if (result == null) {
return null;
}
BufferedImage image = result.getRenderedImage();
if (image == null) {
return null;
}
List<ViewInfo> infos = result.getRootViews();
if (infos == null || infos.isEmpty()) {
return null;
}
infos = infos.get(0).getChildren();
if (infos == null || infos.isEmpty()) {
return null;
}
ViewInfo view = infos.get(0);
if (image.getHeight() < view.getBottom() || image.getWidth() < view.getRight() ||
view.getBottom() <= view.getTop() || view.getRight() <= view.getLeft()) {
return null;
}
image = image.getSubimage(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
return ImageUtils.scale(image, scale, scale);
}
private static String addAndroidNamespaceIfMissing(@NonNull String xml) {
// TODO: Remove this temporary hack, which adds an Android namespace if necessary
// (this is such that the resulting tag is namespace aware, and attempts to manipulate it from
// a component handler will correctly set namespace prefixes)
if (!xml.contains(ANDROID_URI)) {
int index = xml.indexOf('<');
if (index != -1) {
index = xml.indexOf(' ', index);
if (index == -1) {
index = xml.indexOf("/>");
if (index == -1) {
index = xml.indexOf('>');
}
}
if (index != -1) {
xml =
xml.substring(0, index) + " xmlns:android=\"http://schemas.android.com/apk/res/android\"" + xml.substring(index);
}
}
}
return xml;
}
private static BufferedImage readImage(@NonNull String id, @NonNull Configuration configuration) {
File file = new File(getPreviewCacheDir(configuration), id + DOT_PNG);
if (!file.exists()) {
return null;
}
try {
return ImageIO.read(file);
}
catch (IOException ignore) {
}
catch (Throwable ignore) {
// corrupt cached image, e.g. I've seen
// java.lang.IndexOutOfBoundsException
// at java.io.RandomAccessFile.readBytes(Native Method)
// at java.io.RandomAccessFile.read(RandomAccessFile.java:338)
// at javax.imageio.stream.FileImageInputStream.read(FileImageInputStream.java:101)
// at com.sun.imageio.plugins.common.SubImageInputStream.read(SubImageInputStream.java:46)
}
return null;
}
public void load(@NonNull final Configuration configuration, @NonNull final Runnable callback) {
File cacheDir = getPreviewCacheDir(configuration);
String[] files = cacheDir.list();
if (files != null && files.length > 0) {
// The previews have already been generated.
callback.run();
return;
}
ApplicationManager.getApplication().runReadAction(new Computable<Void>() {
@Override
public Void compute() {
for (String previewFileName : PREVIEW_FILES) {
String preview = loadPreviews(previewFileName);
assert preview != null;
RenderResult result = renderImage(preview, configuration);
addResultToCache(result, configuration);
}
callback.run();
return null;
}
});
}
@Nullable
private String loadPreviews(@NonNull String previewFile) {
try {
InputStream stream = getClass().getResourceAsStream(previewFile);
try {
return CharStreams.toString(new InputStreamReader(stream));
}
finally {
stream.close();
}
}
catch (IOException ex) {
LOG.error(ex);
return null;
}
}
@NonNull
private static File getPreviewCacheDir(@NonNull Configuration configuration) {
final String path = PathUtil.getCanonicalPath(PathManager.getSystemPath());
int density = configuration.getDensity().getDpiValue();
String theme = getTheme(configuration);
int apiVersion = getApiVersion(configuration);
//noinspection StringBufferReplaceableByString
String cacheFolder = new StringBuilder()
.append(path).append(File.separator)
.append("android-palette").append(File.separator)
.append("v1").append(File.separator)
.append(theme).append(File.separator)
.append(density).append("-").append(apiVersion)
.toString();
return new File(cacheFolder);
}
@NonNull
private static String getTheme(@NonNull Configuration configuration) {
String theme = configuration.getTheme();
if (theme == null) {
theme = DEFAULT_THEME;
} else if (theme.startsWith(STYLE_RESOURCE_PREFIX)) {
theme = theme.substring(STYLE_RESOURCE_PREFIX.length());
} else if (theme.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)) {
theme = theme.substring(ANDROID_STYLE_RESOURCE_PREFIX.length());
}
return theme;
}
private static int getApiVersion(@NonNull Configuration configuration) {
IAndroidTarget target = configuration.getTarget();
return target == null ? SdkVersionInfo.HIGHEST_KNOWN_STABLE_API : target.getVersion().getApiLevel();
}
private static void addResultToCache(@Nullable RenderResult result, @NonNull Configuration configuration) {
if (result == null || result.getRenderedImage() == null || result.getRootViews() == null || result.getRootViews().isEmpty()) {
return;
}
ImageAccumulator accumulator = new ImageAccumulator(result.getRenderedImage(), configuration);
accumulator.run(result.getRootViews(), 0);
}
@Nullable
private static RenderResult renderImage(@NonNull String xml, @NonNull Configuration configuration) {
AndroidFacet facet = AndroidFacet.getInstance(configuration.getModule());
if (facet == null) {
return null;
}
Project project = configuration.getModule().getProject();
PsiFile file = PsiFileFactory.getInstance(project).createFileFromText(PREVIEW_PLACEHOLDER_FILE, XmlFileType.INSTANCE, xml);
RenderService renderService = RenderService.get(facet);
RenderLogger logger = renderService.createLogger();
final RenderTask task = renderService.createTask(file, configuration, logger, null);
RenderResult result = null;
if (task != null) {
task.setOverrideBgColor(UIUtil.TRANSPARENT_COLOR.getRGB());
task.setDecorations(false);
task.setRenderingMode(SessionParams.RenderingMode.FULL_EXPAND);
task.setFolderType(ResourceFolderType.LAYOUT);
result = task.render();
task.dispose();
}
return result;
}
private static class ImageAccumulator {
private final BufferedImage myImage;
private final File myCacheDir;
private final int myHeight;
private final int myWidth;
private ImageAccumulator(@NonNull BufferedImage image, @NonNull Configuration configuration) {
myImage = image;
myCacheDir = getPreviewCacheDir(configuration);
myHeight = image.getRaster().getHeight();
myWidth = image.getRaster().getWidth();
}
private void run(@NonNull List<ViewInfo> views, int top) {
for (ViewInfo info : views) {
if (info.getCookie() instanceof XmlTag) {
XmlTag tag = (XmlTag)info.getCookie();
String id = tag.getAttributeValue(ATTR_ID, ANDROID_URI);
if (id != null && !id.startsWith(PREFIX_RESOURCE_REF)) {
if (info.getBottom() + top <= myHeight && info.getRight() <= myWidth && info.getBottom() > info.getTop()) {
Rectangle bounds =
new Rectangle(info.getLeft(), info.getTop() + top, info.getRight() - info.getLeft(), info.getBottom() - info.getTop());
BufferedImage image = myImage.getSubimage(bounds.x, bounds.y, bounds.width, bounds.height);
saveImage(id, image);
}
else {
LOG.warn(String.format("Dimensions of %1$s is out of range", id));
}
}
}
if (!info.getChildren().isEmpty()) {
run(info.getChildren(), top + info.getTop());
}
}
}
private void saveImage(@NonNull String id, @NonNull BufferedImage image) {
//noinspection ResultOfMethodCallIgnored
myCacheDir.mkdirs();
File file = new File(myCacheDir, id + DOT_PNG);
try {
ImageIO.write(image, "PNG", file);
}
catch (IOException e) {
// pass
if (file.exists()) {
//noinspection ResultOfMethodCallIgnored
file.delete();
}
}
}
}
}