com.android.tools.idea.uibuilder.model.NlModel.java Source code

Java tutorial

Introduction

Here is the source code for com.android.tools.idea.uibuilder.model.NlModel.java

Source

/*
 * 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.model;

import com.android.ide.common.rendering.api.MergeCookie;
import com.android.ide.common.rendering.api.ViewInfo;
import com.android.ide.common.repository.GradleVersion;
import com.android.sdklib.devices.Device;
import com.android.sdklib.devices.Screen;
import com.android.sdklib.devices.State;
import com.android.tools.idea.AndroidPsiUtils;
import com.android.tools.idea.avdmanager.AvdScreenData;
import com.android.tools.idea.configurations.Configuration;
import com.android.tools.idea.configurations.ConfigurationListener;
import com.android.tools.idea.configurations.ConfigurationMatcher;
import com.android.tools.idea.gradle.project.model.AndroidModuleModel;
import com.android.tools.idea.gradle.util.GradleUtil;
import com.android.tools.idea.rendering.*;
import com.android.tools.idea.rendering.Locale;
import com.android.tools.idea.res.ProjectResourceRepository;
import com.android.tools.idea.res.ResourceNotificationManager;
import com.android.tools.idea.res.ResourceNotificationManager.ResourceChangeListener;
import com.android.tools.idea.res.ResourceNotificationManager.ResourceVersion;
import com.android.tools.idea.templates.TemplateUtils;
import com.android.tools.idea.uibuilder.analytics.NlUsageTrackerManager;
import com.android.tools.idea.uibuilder.api.*;
import com.android.tools.idea.uibuilder.editor.NlEditor;
import com.android.tools.idea.uibuilder.editor.NlEditorProvider;
import com.android.tools.idea.uibuilder.handlers.ViewEditorImpl;
import com.android.tools.idea.uibuilder.handlers.ViewHandlerManager;
import com.android.tools.idea.uibuilder.lint.LintAnnotationsModel;
import com.android.tools.idea.uibuilder.surface.DesignSurface;
import com.android.tools.idea.uibuilder.surface.ScreenView;
import com.android.tools.idea.uibuilder.surface.ZoomType;
import com.android.util.PropertiesMap;
import com.android.utils.XmlUtils;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.*;
import com.google.wireless.android.sdk.stats.LayoutEditorEvent;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.Result;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.fileEditor.FileEditor;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileEditor.OpenFileDescriptor;
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.progress.util.ProgressIndicatorBase;
import com.intellij.openapi.project.DumbService;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Computable;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.ModificationTracker;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiElement;
import com.intellij.psi.XmlElementFactory;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.psi.xml.XmlAttribute;
import com.intellij.psi.xml.XmlDocument;
import com.intellij.psi.xml.XmlFile;
import com.intellij.psi.xml.XmlTag;
import com.intellij.util.Alarm;
import com.intellij.util.IncorrectOperationException;
import com.intellij.util.ui.UIUtil;
import com.intellij.util.ui.update.MergingUpdateQueue;
import com.intellij.util.ui.update.Update;
import org.jetbrains.android.facet.AndroidFacet;
import org.jetbrains.android.util.AndroidResourceUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.annotation.concurrent.GuardedBy;
import javax.swing.Timer;
import java.awt.*;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.awt.dnd.InvalidDnDOperationException;
import java.io.IOException;
import java.util.*;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Stream;

import static com.android.SdkConstants.*;
import static com.intellij.util.ui.update.Update.HIGH_PRIORITY;
import static com.intellij.util.ui.update.Update.LOW_PRIORITY;

/**
 * Model for an XML file
 */
public class NlModel implements Disposable, ResourceChangeListener, ModificationTracker {
    private static final Logger LOG = Logger.getInstance(NlModel.class);
    @AndroidCoordinate
    private static final int VISUAL_EMPTY_COMPONENT_SIZE = 1;
    private static final boolean CHECK_MODEL_INTEGRITY = false;
    private static final int RENDER_DELAY_MS = 10;
    private final Set<String> myPendingIds = Sets.newHashSet();

    @NotNull
    private final DesignSurface mySurface;
    @NotNull
    private final AndroidFacet myFacet;
    @NotNull
    private final ProjectResourceRepository myProjectResourceRepository;
    private final XmlFile myFile;
    private final ReentrantReadWriteLock myRenderResultLock = new ReentrantReadWriteLock();
    private final ConfigurationListener myConfigurationListener = new ConfigurationListener() {
        @Override
        public boolean changed(int flags) {
            if ((flags & (CFG_DEVICE | CFG_DEVICE_STATE)) != 0 && !mySurface.isCanvasResizing()) {
                mySurface.zoom(ZoomType.FIT_INTO);
            }

            return true;
        }
    };
    @GuardedBy("myRenderResultLock")
    private RenderResult myRenderResult;
    private Configuration myConfiguration;
    private final List<ModelListener> myListeners = Lists.newArrayList();
    private List<NlComponent> myComponents = Lists.newArrayList();
    private final SelectionModel mySelectionModel;
    private LintAnnotationsModel myLintAnnotationsModel;
    private final long myId;
    private boolean myActive;
    private ResourceVersion myRenderedVersion;
    private final ModelVersion myModelVersion = new ModelVersion();
    private AndroidPreviewProgressIndicator myCurrentIndicator;
    private static final Object PROGRESS_LOCK = new Object();
    private RenderTask myRenderTask;
    private final NlLayoutType myType;
    private long myConfigurationModificationCount;

    // Variables to track previous values of the configuration bar for tracking purposes
    private String myPreviousDeviceName;
    private Locale myPreviousLocale;
    private String myPreviousVersion;
    private String myPreviousTheme;
    // Variable to track what triggered the latest render (if known)
    private ChangeType myModificationTrigger;

    @NotNull
    public static NlModel create(@NotNull DesignSurface surface, @Nullable Disposable parent,
            @NotNull AndroidFacet facet, @NotNull XmlFile file) {
        return new NlModel(surface, parent, facet, file);
    }

    @VisibleForTesting
    protected NlModel(@NotNull DesignSurface surface, @Nullable Disposable parent, @NotNull AndroidFacet facet,
            @NotNull XmlFile file) {
        mySurface = surface;
        myFacet = facet;
        myFile = file;
        myConfiguration = facet.getConfigurationManager().getConfiguration(myFile.getVirtualFile());
        myConfigurationModificationCount = myConfiguration.getModificationCount();
        mySelectionModel = new SelectionModel();
        myId = System.nanoTime() ^ file.getName().hashCode();
        if (parent != null) {
            Disposer.register(parent, this);
        }
        myType = NlLayoutType.typeOf(file);
        myProjectResourceRepository = ProjectResourceRepository.getProjectResources(myFacet, true);

        updateTrackingConfiguration();
    }

    /**
     * Notify model that it's active. A model is active by default.
     */
    public void activate() {
        if (!myActive) {
            myActive = true;

            myConfiguration.addListener(myConfigurationListener);
            ResourceNotificationManager manager = ResourceNotificationManager.getInstance(myFile.getProject());
            ResourceVersion version = manager.addListener(this, myFacet, myFile, myConfiguration);

            // If the resources have changed or the configuration has been modified, request a model update
            if (!version.equals(myRenderedVersion)
                    || (myConfiguration.getModificationCount() != myConfigurationModificationCount)) {
                String theme = myConfiguration.getTheme();
                if (theme != null && !theme.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)
                        && !myProjectResourceRepository.hasResourceItem(theme)) {
                    myConfiguration.setTheme(
                            myConfiguration.getConfigurationManager().computePreferredTheme(myConfiguration));
                }
                requestModelUpdate();
                myModelVersion.myResourceVersion.incrementAndGet();
            }
        }
    }

    /**
     * Notify model that it's not active. This means it can stop watching for events etc. It may be activated again in the future.
     */
    public void deactivate() {
        if (myActive) {
            getRenderingQueue().cancelAllUpdates();
            ResourceNotificationManager manager = ResourceNotificationManager.getInstance(myFile.getProject());
            manager.removeListener(this, myFacet, myFile, myConfiguration);
            myConfigurationModificationCount = myConfiguration.getModificationCount();
            myConfiguration.removeListener(myConfigurationListener);
            myActive = false;
        }
    }

    @NotNull
    public XmlFile getFile() {
        return myFile;
    }

    @NotNull
    public NlLayoutType getType() {
        return myType;
    }

    @NotNull
    public SelectionModel getSelectionModel() {
        return mySelectionModel;
    }

    @Nullable
    public LintAnnotationsModel getLintAnnotationsModel() {
        return myLintAnnotationsModel;
    }

    public void setLintAnnotationsModel(@Nullable LintAnnotationsModel model) {
        myLintAnnotationsModel = model;
        // Deliberately not rev'ing the model version and firing changes here;
        // we know only the warnings layer cares about this change and can be
        // updated by a single repaint
    }

    /**
     * Asynchronously inflates the model and updates the view hierarchy
     */
    protected void requestModelUpdate() {
        ApplicationManager.getApplication().assertIsDispatchThread();

        synchronized (PROGRESS_LOCK) {
            if (myCurrentIndicator == null) {
                myCurrentIndicator = new AndroidPreviewProgressIndicator();
                myCurrentIndicator.start();
            }
        }

        getRenderingQueue().queue(new Update("model.update", HIGH_PRIORITY) {
            @Override
            public void run() {
                DumbService.getInstance(myFacet.getModule().getProject()).waitForSmartMode();
                if (!myFacet.isDisposed()) {
                    try {
                        if (myFacet.requiresAndroidModel() && myFacet.getAndroidModel() == null) {
                            // Try again later - model hasn't been synced yet (and for example we won't
                            // be able to resolve custom views coming from libraries like appcompat,
                            // resulting in a broken render)
                            ApplicationManager.getApplication().invokeLater(() -> requestModelUpdate());
                            return;
                        }
                        updateModel();
                    } catch (Throwable e) {
                        Logger.getInstance(NlModel.class).error(e);
                    }
                }

                synchronized (PROGRESS_LOCK) {
                    if (myCurrentIndicator != null) {
                        myCurrentIndicator.stop();
                        myCurrentIndicator = null;
                    }
                }
            }

            @Override
            public boolean canEat(Update update) {
                return equals(update);
            }
        });
    }

    @NotNull
    private MergingUpdateQueue getRenderingQueue() {
        synchronized (myRenderingQueueLock) {
            if (myRenderingQueue == null) {
                myRenderingQueue = new MergingUpdateQueue("android.layout.rendering", RENDER_DELAY_MS, true, null,
                        this, null, Alarm.ThreadToUse.OWN_THREAD);
                myRenderingQueue.setRestartTimerOnAdd(true);
            }
            return myRenderingQueue;
        }
    }

    private final Object myRenderingQueueLock = new Object();
    private MergingUpdateQueue myRenderingQueue;
    private static final Object RENDERING_LOCK = new Object();

    /**
     * Whether we should render just the viewport
     */
    private static boolean ourRenderViewPort;

    public static void setRenderViewPort(boolean state) {
        ourRenderViewPort = state;
    }

    public static boolean isRenderViewPort() {
        return ourRenderViewPort;
    }

    @VisibleForTesting
    protected void setupRenderTask(@Nullable RenderTask task) {
    }

    /**
     * Synchronously inflates the model and updates the view hierarchy
     *
     * @param force forces the model to be re-inflated even if a previous version was already inflated
     * @returns whether the model was inflated in this call or not
     */
    private boolean inflate(boolean force) {
        Configuration configuration = myConfiguration;
        if (configuration == null) {
            return false;
        }

        ResourceNotificationManager resourceNotificationManager = ResourceNotificationManager
                .getInstance(myFile.getProject());

        // Some types of files must be saved to disk first, because layoutlib doesn't
        // delegate XML parsers for non-layout files (meaning layoutlib will read the
        // disk contents, so we have to push any edits to disk before rendering)
        LayoutPullParserFactory.saveFileIfNecessary(myFile);

        RenderResult result = null;
        synchronized (RENDERING_LOCK) {
            if (myRenderTask != null && !force) {
                // No need to inflate
                return false;
            }

            // Record the current version we're rendering from; we'll use that in #activate to make sure we're picking up any
            // external changes
            myRenderedVersion = resourceNotificationManager.getCurrentVersion(myFacet, myFile, myConfiguration);

            RenderService renderService = RenderService.get(myFacet);
            RenderLogger logger = renderService.createLogger();
            if (myRenderTask != null) {
                myRenderTask.dispose();
            }
            myRenderTask = renderService.createTask(myFile, configuration, logger, mySurface);
            setupRenderTask(myRenderTask);
            if (myRenderTask != null) {
                if (!isRenderViewPort()) {
                    myRenderTask.useDesignMode(myFile);
                }
                result = myRenderTask.inflate();
                if (result == null || !result.getRenderResult().isSuccess()) {
                    myRenderTask.dispose();
                    myRenderTask = null;

                    if (result == null) {
                        result = RenderResult.createBlank(myFile);
                    }
                }
            }

            updateHierarchy(result);
            myRenderResultLock.writeLock().lock();
            try {
                myRenderResult = result;
            } finally {
                myRenderResultLock.writeLock().unlock();
            }

            return myRenderTask != null;
        }
    }

    @NotNull
    Set<String> getPendingIds() {
        return myPendingIds;
    }

    private void updateHierarchy(@Nullable RenderResult result) {
        if (result == null || !result.getRenderResult().isSuccess()) {
            myComponents = Collections.emptyList();
        } else {
            XmlTag rootTag = AndroidPsiUtils.getRootTagSafely(myFile);
            List<ViewInfo> rootViews;
            rootViews = myType == NlLayoutType.MENU ? result.getSystemRootViews() : result.getRootViews();
            updateHierarchy(rootTag, rootViews);
        }
        myModelVersion.increase(ChangeType.UPDATE_HIERARCHY);

        if (CHECK_MODEL_INTEGRITY) {
            checkStructure();
        }
    }

    @VisibleForTesting
    public void updateHierarchy(@Nullable XmlTag rootTag, @NotNull Iterable<ViewInfo> rootViews) {
        ModelUpdater updater = new ModelUpdater(this);
        updater.update(rootTag, rootViews);
    }

    /**
     * Synchronously update the model. This will inflate the layout and notify the listeners using
     * {@link ModelListener#modelChanged(NlModel)}.
     */
    protected void updateModel() {
        inflate(true);
        notifyListenersModelUpdateComplete();
    }

    private void checkStructure() {
        if (CHECK_MODEL_INTEGRITY) {
            ApplicationManager.getApplication().runReadAction(() -> {
                Set<NlComponent> unique = Sets.newIdentityHashSet();
                Set<XmlTag> uniqueTags = Sets.newIdentityHashSet();
                checkUnique(myFile.getRootTag(), uniqueTags);
                uniqueTags.clear();
                for (NlComponent component : myComponents) {
                    checkUnique(component.getTag(), uniqueTags);
                    checkUnique(component, unique);
                }
                for (NlComponent component : myComponents) {
                    checkStructure(component);
                }
            });
        }
    }

    @SuppressWarnings("MethodMayBeStatic")
    private void checkUnique(NlComponent component, Set<NlComponent> unique) {
        if (CHECK_MODEL_INTEGRITY) {
            assert !unique.contains(component);
            unique.add(component);

            for (NlComponent child : component.getChildren()) {
                checkUnique(child, unique);
            }
        }
    }

    @SuppressWarnings("MethodMayBeStatic")
    private void checkUnique(XmlTag tag, Set<XmlTag> unique) {
        if (CHECK_MODEL_INTEGRITY) {
            assert !unique.contains(tag);
            unique.add(tag);
            for (XmlTag subTag : tag.getSubTags()) {
                checkUnique(subTag, unique);
            }
        }
    }

    @SuppressWarnings("MethodMayBeStatic")
    private void checkStructure(NlComponent component) {
        if (CHECK_MODEL_INTEGRITY) {
            // This is written like this instead of just "assert component.w != -1" to ease
            // setting breakpoint to debug problems
            if (component.w == -1) {
                assert false : component.w;
            }
            if (component.getSnapshot() == null) {
                assert false;
            }
            if (component.getTag() == null) {
                assert false;
            }
            if (!component.getTagName().equals(component.getTag().getName())) {
                assert false;
            }

            if (!component.getTag().isValid()) {
                assert false;
            }

            // Look for parent chain cycle
            NlComponent p = component.getParent();
            while (p != null) {
                if (p == component) {
                    assert false;
                }
                p = p.getParent();
            }

            for (NlComponent child : component.getChildren()) {
                if (child == component) {
                    assert false;
                }
                if (child.getParent() == null) {
                    assert false;
                }
                if (child.getParent() != component) {
                    assert false;
                }
                if (child.getTag().getParent() != component.getTag()) {
                    assert false;
                }

                // Check recursively
                checkStructure(child);
            }
        }
    }

    /**
     * Renders the current model synchronously. Once the render is complete, the listeners {@link ModelListener#modelRendered(NlModel)}
     * method will be called.
     * <p/>
     * If the layout hasn't been inflated before, this call will inflate the layout before rendering.
     * <p/>
     * <b>Do not call this method from the dispatch thread!</b>
     */
    public void render() {
        if (myConfigurationModificationCount != myConfiguration.getModificationCount()) {
            // usage tracking (we only pay attention to individual changes where only one item is affected since those are likely to be triggered
            // by the user
            if (!StringUtil.equals(myConfiguration.getTheme(), myPreviousTheme)) {
                myPreviousTheme = myConfiguration.getTheme();
                NlUsageTrackerManager.getInstance(mySurface)
                        .logAction(LayoutEditorEvent.LayoutEditorEventType.THEME_CHANGE);
            } else if (myConfiguration.getTarget() != null
                    && !StringUtil.equals(myConfiguration.getTarget().getVersionName(), myPreviousVersion)) {
                myPreviousVersion = myConfiguration.getTarget().getVersionName();
                NlUsageTrackerManager.getInstance(mySurface)
                        .logAction(LayoutEditorEvent.LayoutEditorEventType.API_LEVEL_CHANGE);
            } else if (!myConfiguration.getLocale().equals(myPreviousLocale)) {
                myPreviousLocale = myConfiguration.getLocale();
                NlUsageTrackerManager.getInstance(mySurface)
                        .logAction(LayoutEditorEvent.LayoutEditorEventType.LANGUAGE_CHANGE);
            } else if (myConfiguration.getDevice() != null
                    && !StringUtil.equals(myConfiguration.getDevice().getDisplayName(), myPreviousDeviceName)) {
                myPreviousDeviceName = myConfiguration.getDevice().getDisplayName();
                NlUsageTrackerManager.getInstance(mySurface)
                        .logAction(LayoutEditorEvent.LayoutEditorEventType.DEVICE_CHANGE);
            }
        }

        ChangeType changeType = myModificationTrigger;
        myModificationTrigger = null;
        long renderStartTimeMs = System.currentTimeMillis();
        boolean inflated = inflate(false);

        synchronized (RENDERING_LOCK) {
            if (myRenderTask != null) {
                RenderResult result = myRenderTask.render();
                // When the layout was inflated in this same call, we do not have to update the hierarchy again
                if (!inflated) {
                    updateHierarchy(result);
                }
                myRenderResultLock.writeLock().lock();
                try {
                    myRenderResult = result;
                    // Downgrade the write lock to read lock
                    myRenderResultLock.readLock().lock();
                } finally {
                    myRenderResultLock.writeLock().unlock();
                }
                try {
                    NlUsageTrackerManager.getInstance(mySurface).logRenderResult(changeType, myRenderResult,
                            System.currentTimeMillis() - renderStartTimeMs);
                } finally {
                    myRenderResultLock.readLock().unlock();
                }
            }
        }

        notifyListenersRenderComplete();
    }

    /**
     * Renders the current model asynchronously. Once the render is complete, the listeners {@link ModelListener#modelRendered(NlModel)}
     * method will be called.
     */
    public void requestRender() {
        // This method will be removed once we only do direct rendering (see RenderTask.render(Graphics2D))
        // This update is low priority so the model updates take precedence
        getRenderingQueue().queue(new Update("model.render", LOW_PRIORITY) {
            @Override
            public void run() {
                if (myFacet.isDisposed()) {
                    return;
                }

                render();
            }

            @Override
            public boolean canEat(Update update) {
                return this.equals(update);
            }
        });
    }

    /**
     * Request a layout pass
     *
     * @param animate if true, the resuting layout should be animated
     */
    public void requestLayout(boolean animate) {
        if (myRenderTask != null) {
            synchronized (RENDERING_LOCK) {
                RenderResult result = myRenderTask.layout();
                if (result != null) {
                    updateHierarchy(result);
                    notifyListenersModelLayoutComplete(animate);
                }
            }
        }
    }

    /**
     * Method that paints the current layout to the given {@link Graphics2D} object.
     */
    @SuppressWarnings("unused")
    public void paint(@NotNull Graphics2D graphics) {
        synchronized (RENDERING_LOCK) {
            if (myRenderTask != null) {
                myRenderTask.render(graphics);
            }
        }
    }

    /**
     * Adds a new {@link ModelListener}. If the listener already exists, this method will make sure that the listener is only
     * added once.
     */
    public void addListener(@NotNull ModelListener listener) {
        synchronized (myListeners) {
            myListeners.remove(listener); // prevent duplicate registration
            myListeners.add(listener);
        }
    }

    public void removeListener(@NotNull ModelListener listener) {
        synchronized (myListeners) {
            myListeners.remove(listener);
        }
    }

    /**
     * Calls all the listeners {@link ModelListener#modelChanged(NlModel)} method.
     */
    private void notifyListenersModelUpdateComplete() {
        List<ModelListener> listeners;
        synchronized (myListeners) {
            listeners = ImmutableList.copyOf(myListeners);
        }

        listeners.forEach(listener -> listener.modelChanged(this));
    }

    /**
     * Calls all the listeners {@link ModelListener#modelRendered(NlModel)} method.
     */
    private void notifyListenersRenderComplete() {
        List<ModelListener> listeners;
        synchronized (myListeners) {
            listeners = ImmutableList.copyOf(myListeners);
        }

        listeners.forEach(listener -> listener.modelRendered(this));
    }

    /**
     * Calls all the listeners {@link ModelListener#modelChangedOnLayout(NlModel, boolean)} method.
     *
     * @param animate if true, warns the listeners to animate the layout update
     */
    private void notifyListenersModelLayoutComplete(boolean animate) {
        List<ModelListener> listeners;
        synchronized (myListeners) {
            listeners = ImmutableList.copyOf(myListeners);
        }

        listeners.forEach(listener -> listener.modelChangedOnLayout(this, animate));
    }

    @Nullable
    public RenderResult getRenderResult() {
        myRenderResultLock.readLock().lock();
        try {
            return myRenderResult;
        } finally {
            myRenderResultLock.readLock().unlock();
        }
    }

    @NotNull
    public Map<Object, PropertiesMap> getDefaultProperties() {
        myRenderResultLock.readLock().lock();
        try {
            if (myRenderResult == null) {
                return Collections.emptyMap();
            }
            return myRenderResult.getDefaultProperties();
        } finally {
            myRenderResultLock.readLock().unlock();
        }
    }

    @NotNull
    public AndroidFacet getFacet() {
        return myFacet;
    }

    @NotNull
    public Module getModule() {
        return myFacet.getModule();
    }

    @NotNull
    public Project getProject() {
        return getModule().getProject();
    }

    @NotNull
    public Configuration getConfiguration() {
        return myConfiguration;
    }

    /**
     * Returns true if the current module depends on the specified library.
     *
     * @param artifact library artifact e.g. "com.android.support:appcompat-v7"
     */
    public boolean isModuleDependency(@NotNull String artifact) {
        AndroidModuleModel gradleModel = AndroidModuleModel.get(myFacet);
        return gradleModel != null && GradleUtil.dependsOn(gradleModel, artifact);
    }

    /**
     * Returns the {@link GradleVersion} of the specified library that the current module depends on.
     *
     * @param artifact library artifact e.g. "com.android.support:appcompat-v7"
     * @return the revision or null if the module does not depend on the specified library.
     */
    @Nullable
    public GradleVersion getModuleDependencyVersion(@NotNull String artifact) {
        AndroidModuleModel gradleModel = AndroidModuleModel.get(myFacet);
        return gradleModel != null ? GradleUtil.getModuleDependencyVersion(gradleModel, artifact) : null;
    }

    /**
     * Changes the configuration to use a custom device with screen size defined by xDimension and yDimension.
     */
    public void overrideConfigurationScreenSize(@AndroidCoordinate int xDimension,
            @AndroidCoordinate int yDimension) {
        Device original = myConfiguration.getDevice();
        Device.Builder deviceBuilder = new Device.Builder(original); // doesn't copy tag id
        if (original != null) {
            deviceBuilder.setTagId(original.getTagId());
        }
        deviceBuilder.setName("Custom");
        deviceBuilder.setId(Configuration.CUSTOM_DEVICE_ID);
        Device device = deviceBuilder.build();
        for (State state : device.getAllStates()) {
            Screen screen = state.getHardware().getScreen();
            screen.setXDimension(xDimension);
            screen.setYDimension(yDimension);

            double dpi = screen.getPixelDensity().getDpiValue();
            double width = xDimension / dpi;
            double height = yDimension / dpi;
            double diagonalLength = Math.sqrt(width * width + height * height);

            screen.setDiagonalLength(diagonalLength);
            screen.setSize(AvdScreenData.getScreenSize(diagonalLength));

            screen.setRatio(AvdScreenData.getScreenRatio(xDimension, yDimension));

            screen.setScreenRound(device.getDefaultHardware().getScreen().getScreenRound());
            screen.setChin(device.getDefaultHardware().getScreen().getChin());
        }

        // If a custom device already exists, remove it before adding the latest one
        List<Device> devices = myConfiguration.getConfigurationManager().getDevices();
        boolean customDeviceReplaced = false;
        for (int i = 0; i < devices.size(); i++) {
            if ("Custom".equals(devices.get(i).getId())) {
                devices.set(i, device);
                customDeviceReplaced = true;
                break;
            }
        }

        if (!customDeviceReplaced) {
            devices.add(device);
        }

        VirtualFile better;
        State newState;
        //Change the orientation of the device depending on the shape of the canvas
        if (xDimension > yDimension) {
            better = ConfigurationMatcher.getBetterMatch(myConfiguration, device, "Landscape", null, null);
            newState = device.getState("Landscape");
        } else {
            better = ConfigurationMatcher.getBetterMatch(myConfiguration, device, "Portrait", null, null);
            newState = device.getState("Portrait");
        }

        if (better != null) {
            VirtualFile old = myConfiguration.getFile();
            assert old != null;
            Project project = mySurface.getProject();
            OpenFileDescriptor descriptor = new OpenFileDescriptor(project, better, -1);
            FileEditorManager manager = FileEditorManager.getInstance(project);
            FileEditor selectedEditor = manager.getSelectedEditor(old);
            manager.openEditor(descriptor, true);
            // Switch to the same type of editor (XML or Layout Editor) in the target file
            if (selectedEditor instanceof NlEditor) {
                manager.setSelectedEditor(better, NlEditorProvider.DESIGNER_ID);
            } else if (selectedEditor != null) {
                manager.setSelectedEditor(better, TextEditorProvider.getInstance().getEditorTypeId());
            }

            AndroidFacet facet = AndroidFacet.getInstance(myConfiguration.getModule());
            assert facet != null;
            Configuration configuration = facet.getConfigurationManager().getConfiguration(better);
            configuration.setEffectiveDevice(device, newState);
        } else {
            myConfiguration.setEffectiveDevice(device, newState);
        }
    }

    @NotNull
    public List<NlComponent> getComponents() {
        return myComponents;
    }

    @NotNull
    public Stream<NlComponent> flattenComponents() {
        return myComponents.stream().flatMap(NlComponent::flatten);
    }

    /**
     * Synchronizes a {@linkplain NlModel} after a render such that the component hierarchy
     * is up to date wrt view bounds, tag snapshots etc. Crucially, it attempts to preserve
     * component hierarchy (since XmlTags may sometimes not survive a PSI reparse, but we
     * want the {@linkplain NlComponent} instances to keep the same instances across these
     * edits such that for example the selection (a set of {@link NlComponent} instances)
     * are preserved.
     */
    private static class ModelUpdater {
        private final NlModel myModel;
        private final Map<XmlTag, NlComponent> myTagToComponentMap = Maps.newIdentityHashMap();
        private final Map<NlComponent, XmlTag> myComponentToTagMap = Maps.newIdentityHashMap();
        /**
         * Map from snapshots in the old component map to the corresponding components
         */
        private final Map<TagSnapshot, NlComponent> mySnapshotToComponent = Maps.newIdentityHashMap();
        /**
         * Map from tags in the view render tree to the corresponding snapshots
         */
        private final Map<XmlTag, TagSnapshot> myTagToSnapshot = Maps.newHashMap();

        public ModelUpdater(@NotNull NlModel model) {
            myModel = model;
        }

        private void recordComponentMapping(@NotNull XmlTag tag, @NotNull NlComponent component) {
            // Is the component already registered to some other tag?
            XmlTag prevTag = myComponentToTagMap.get(component);
            if (prevTag != null) {
                // Yes. Unregister it.
                myTagToComponentMap.remove(prevTag);
            }

            myComponentToTagMap.put(component, tag);
            myTagToComponentMap.put(tag, component);
        }

        /**
         * Update the component hierarchy associated with this {@linkplain ModelUpdater} such
         * that the associated component list correctly reflects the latest versions of the
         * XML PSI file, the given tag snapshot and {@link ViewInfo} hierarchy from layoutlib.
         */
        @VisibleForTesting
        public void update(@Nullable XmlTag newRoot, @NotNull Iterable<ViewInfo> rootViews) {
            if (newRoot == null) {
                myModel.myComponents = Collections.emptyList();
                return;
            }

            // Next find the snapshots corresponding to the missing components.
            // We have to search among the view infos in the new components.
            for (ViewInfo rootView : rootViews) {
                gatherTagsAndSnapshots(rootView, myTagToSnapshot);
            }

            NlComponent root = ApplicationManager.getApplication().runReadAction((Computable<NlComponent>) () -> {
                // Ensure that all XmlTags in the new XmlFile contents map to a corresponding component
                // form the old map
                mapOldToNew(newRoot);

                for (Map.Entry<XmlTag, NlComponent> entry : myTagToComponentMap.entrySet()) {
                    XmlTag tag = entry.getKey();
                    NlComponent component = entry.getValue();
                    if (!component.getTagName().equals(tag.getName())) {
                        // One or more incompatible changes: PSI nodes have been reused unpredictably
                        // so completely recompute the hierarchy
                        myTagToComponentMap.clear();
                        myComponentToTagMap.clear();
                        break;
                    }
                }

                // Build up the new component tree
                return createTree(newRoot);
            });

            myModel.myComponents = Collections.singletonList(root);

            // Wipe out state in older components to make sure on reuse we don't accidentally inherit old
            // data
            for (NlComponent component : myTagToComponentMap.values()) {
                component.setBounds(0, 0, -1, -1); // -1: not initialized
                component.viewInfo = null;
                component.setSnapshot(null);
            }

            // Update the bounds. This is based on the ViewInfo instances.
            for (ViewInfo view : rootViews) {
                updateHierarchy(view, 0, 0);
            }

            // Finally, fix up bounds: ensure that all components not found in the view
            // info hierarchy inherit position from parent
            fixBounds(root);
        }

        private static void fixBounds(NlComponent root) {
            boolean computeBounds = false;
            if (root.w == -1 && root.h == -1) { // -1: not initialized
                computeBounds = true;

                // Look at parent instead
                NlComponent parent = root.getParent();
                if (parent != null && parent.w >= 0) {
                    root.setBounds(parent.x, parent.y, 0, 0);
                }
            }

            List<NlComponent> children = root.children;
            if (children != null && !children.isEmpty()) {
                for (NlComponent child : children) {
                    fixBounds(child);
                }

                if (computeBounds) {
                    Rectangle rectangle = new Rectangle(root.x, root.y, root.w, root.h);
                    // Grow bounds to include child bounds
                    for (NlComponent child : children) {
                        rectangle = rectangle.union(new Rectangle(child.x, child.y, child.w, child.h));
                    }

                    root.setBounds(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
                }
            }
        }

        private void mapOldToNew(@NotNull XmlTag newRootTag) {
            ApplicationManager.getApplication().assertReadAccessAllowed();

            // First build up a new component tree to reflect the latest XmlFile hierarchy.
            // If there have been no structural changes, these map 1-1 from the previous hierarchy.
            // We first attempt to do it based on the XmlTags:
            //  (1) record a map from XmlTag to NlComponent in the previous component list
            for (NlComponent component : myModel.myComponents) {
                gatherTagsAndSnapshots(component);
            }

            // Look for any NlComponents no longer present in the new set
            List<XmlTag> missing = Lists.newArrayList();
            Set<XmlTag> remaining = Sets.newIdentityHashSet();
            remaining.addAll(myTagToComponentMap.keySet());
            checkMissing(newRootTag, remaining, missing);

            // If we've just removed a component, there will be no missing tags; we
            // can build the new/updated component hierarchy directly from the old
            // NlComponent instances
            if (missing.isEmpty()) {
                return;
            }

            // If we've just added a component, there will be no remaining tags from
            // old component instances. In this case all components should be new
            // instances
            if (remaining.isEmpty()) {
                return;
            }

            // Try to map more component instances from old to new.
            // We will do this via multiple heuristics:
            //   - mapping id's
            //   - looking at all component attributes (e.g. snapshots)

            // First check by id.
            // Note: We can't use XmlTag#getAttribute on the old component hierarchy;
            // those elements may not be valid and PSI will throw exceptions if we
            // attempt to access them.
            Map<String, NlComponent> oldIds = Maps.newHashMap();
            for (Map.Entry<TagSnapshot, NlComponent> entry : mySnapshotToComponent.entrySet()) {
                TagSnapshot snapshot = entry.getKey();
                if (snapshot != null) {
                    String id = snapshot.getAttribute(ATTR_ID, ANDROID_URI);
                    if (id != null) {
                        oldIds.put(id, entry.getValue());
                    }
                }
            }
            ListIterator<XmlTag> missingIterator = missing.listIterator();
            while (missingIterator.hasNext()) {
                XmlTag tag = missingIterator.next();
                String id = tag.getAttributeValue(ATTR_ID, ANDROID_URI);
                if (id != null) {
                    // TODO: Consider unifying @+id/ and @id/ references here
                    // (though it's unlikely for this to change across component
                    // synchronization operations)
                    NlComponent component = oldIds.get(id);
                    if (component != null) {
                        recordComponentMapping(tag, component);
                        remaining.remove(component.getTag());
                        missingIterator.remove();
                    }
                }
            }

            if (missing.isEmpty() || remaining.isEmpty()) {
                // We've now resolved everything
                return;
            }

            // Next attempt to correlate components based on tag snapshots

            // First compute fingerprints of the old components
            Multimap<Long, TagSnapshot> snapshotIds = ArrayListMultimap.create();
            for (XmlTag old : remaining) {
                NlComponent component = myTagToComponentMap.get(old);
                if (component != null) { // this *should* be the case
                    TagSnapshot snapshot = component.getSnapshot();
                    if (snapshot != null) {
                        snapshotIds.put(snapshot.getSignature(), snapshot);
                    }
                }
            }

            // Note that we're using a multimap rather than a map for these keys,
            // so if you have the same exact element and attributes multiple times,
            // they'll be found and matched in the same order. (This works because
            // we're also tracking the missing xml tags in iteration order by using a
            // list instead of a set.)
            missingIterator = missing.listIterator();
            while (missingIterator.hasNext()) {
                XmlTag tag = missingIterator.next();
                TagSnapshot snapshot = myTagToSnapshot.get(tag);
                if (snapshot != null) {
                    long signature = snapshot.getSignature();
                    Collection<TagSnapshot> snapshots = snapshotIds.get(signature);
                    if (!snapshots.isEmpty()) {
                        TagSnapshot first = snapshots.iterator().next();
                        NlComponent component = mySnapshotToComponent.get(first);
                        if (component != null) {
                            recordComponentMapping(tag, component);
                            remaining.remove(component.getTag());
                            snapshotIds.remove(tag, first);
                            missingIterator.remove();
                        }
                    }
                }
            }

            // Finally, if there's just a single tag in question, it might have been
            // that we changed an attribute of a tag (so the fingerprint no longer matches).
            // If the tag name is identical, we'll go ahead.
            if (missing.size() == 1 && remaining.size() == 1) {
                XmlTag oldTag = remaining.iterator().next();
                NlComponent component = myTagToComponentMap.get(oldTag);
                if (component != null) {
                    XmlTag newTag = missing.get(0);
                    TagSnapshot snapshot = component.getSnapshot();
                    if (snapshot != null) {
                        if (snapshot.tagName.equals(newTag.getName())) {
                            recordComponentMapping(newTag, component);
                        }
                    }
                }
            }
        }

        /**
         * Processes through the XML tag hierarchy recursively, and checks
         * whether the tag is in the remaining set, and if so removes it,
         * otherwise adds it to the missing set.
         */
        private static void checkMissing(XmlTag tag, Set<XmlTag> remaining, List<XmlTag> missing) {
            boolean found = remaining.remove(tag);
            if (!found) {
                missing.add(tag);
            }
            for (XmlTag child : tag.getSubTags()) {
                checkMissing(child, remaining, missing);
            }
        }

        private void gatherTagsAndSnapshots(@NotNull NlComponent component) {
            XmlTag tag = component.getTag();

            recordComponentMapping(tag, component);
            mySnapshotToComponent.put(component.getSnapshot(), component);

            for (NlComponent child : component.getChildren()) {
                gatherTagsAndSnapshots(child);
            }
        }

        private static void gatherTagsAndSnapshots(ViewInfo view, Map<XmlTag, TagSnapshot> map) {
            Object cookie = view.getCookie();
            if (cookie instanceof TagSnapshot) {
                TagSnapshot snapshot = (TagSnapshot) cookie;
                map.put(snapshot.tag, snapshot);
            }

            for (ViewInfo child : view.getChildren()) {
                gatherTagsAndSnapshots(child, map);
            }
        }

        @NotNull
        private NlComponent createTree(XmlTag tag) {
            NlComponent component = myTagToComponentMap.get(tag);
            if (component == null) {
                // New component: tag didn't exist in the previous component hierarchy,
                // and no similar tag was found
                component = new NlComponent(myModel, tag);
                recordComponentMapping(tag, component);
            }

            XmlTag[] subTags = tag.getSubTags();
            if (subTags.length > 0) {
                if (CHECK_MODEL_INTEGRITY) {
                    Set<NlComponent> seen = Sets.newHashSet();
                    Set<XmlTag> seenTags = Sets.newHashSet();
                    for (XmlTag t : subTags) {
                        if (seenTags.contains(t)) {
                            assert false : t;
                        }
                        seenTags.add(t);
                        NlComponent registeredComponent = myTagToComponentMap.get(t);
                        if (registeredComponent != null) {
                            if (seen.contains(registeredComponent)) {
                                assert false : registeredComponent;
                            }
                            seen.add(registeredComponent);
                        }
                    }
                }

                List<NlComponent> children = new ArrayList<>(subTags.length);
                for (XmlTag subtag : subTags) {
                    NlComponent child = createTree(subtag);
                    children.add(child);
                }
                component.setChildren(children);
            } else {
                component.setChildren(null);
            }

            return component;
        }

        private void updateHierarchy(ViewInfo view, int parentX, int parentY) {
            ViewInfo bounds = RenderService.getSafeBounds(view);
            Object cookie = view.getCookie();
            NlComponent component = null;
            if (cookie != null) {
                if (cookie instanceof MergeCookie) {
                    cookie = ((MergeCookie) cookie).getCookie();
                }
                if (cookie instanceof TagSnapshot) {
                    TagSnapshot snapshot = (TagSnapshot) cookie;
                    component = mySnapshotToComponent.get(snapshot);
                    if (component == null) {
                        component = myTagToComponentMap.get(snapshot.tag);
                        if (component != null) {
                            component.setSnapshot(snapshot);
                            assert snapshot.tag != null;
                            component.setTag(snapshot.tag);
                        }
                    } else {
                        component.setSnapshot(snapshot);
                        assert snapshot.tag != null;
                        component.setTag(snapshot.tag);
                    }
                }
            }

            if (component != null) {
                component.viewInfo = view;
                int left = parentX + bounds.getLeft();
                int top = parentY + bounds.getTop();
                int width = bounds.getRight() - bounds.getLeft();
                int height = bounds.getBottom() - bounds.getTop();

                component.setBounds(left, top, Math.max(width, VISUAL_EMPTY_COMPONENT_SIZE),
                        Math.max(height, VISUAL_EMPTY_COMPONENT_SIZE));
            }

            parentX += bounds.getLeft();
            parentY += bounds.getTop();

            for (ViewInfo child : view.getChildren()) {
                updateHierarchy(child, parentX, parentY);
            }
        }
    }

    @Nullable
    public List<NlComponent> findByOffset(int offset) {
        XmlTag tag = PsiTreeUtil.findElementOfClassAtOffset(myFile, offset, XmlTag.class, false);
        return (tag != null) ? findViewsByTag(tag) : null;
    }

    /**
     * Looks up the point at the given pixel coordinates in the Android screen coordinate system, and
     * finds the leaf component there and returns it, if any. If the point is outside the screen bounds,
     * it will either return null, or the root view if {@code useRootOutsideBounds} is set and there is
     * precisely one parent.
     *
     * @param x                    the x pixel coordinate
     * @param y                    the y pixel coordinate
     * @param useRootOutsideBounds if true, return the root component when pointing outside the screen, otherwise null
     * @return the leaf component at the coordinate
     */
    @Nullable
    public NlComponent findLeafAt(@AndroidCoordinate int x, @AndroidCoordinate int y,
            boolean useRootOutsideBounds) {
        // Search BACKWARDS such that if the children are painted on top of each
        // other (as is the case in a FrameLayout) I pick the last one which will
        // be topmost!
        for (int i = myComponents.size() - 1; i >= 0; i--) {
            NlComponent component = myComponents.get(i);
            NlComponent leaf = component.findLeafAt(x, y);
            if (leaf != null) {
                return leaf;
            }
        }

        if (useRootOutsideBounds) {
            // If dragging outside of the screen, associate it with the
            // root widget (if there is one, and at most one (e.g. not a <merge> tag)
            List<NlComponent> components = myComponents;
            if (components.size() == 1) {
                return components.get(0);
            } else {
                return null;
            }
        }

        return null;
    }

    @Nullable
    private NlComponent findViewByTag(@NotNull XmlTag tag) {
        // TODO: Consider using lookup map
        for (NlComponent component : myComponents) {
            NlComponent match = component.findViewByTag(tag);
            if (match != null) {
                return match;
            }
        }

        return null;
    }

    @Nullable
    private List<NlComponent> findViewsByTag(@NotNull XmlTag tag) {
        List<NlComponent> result = null;
        for (NlComponent view : myComponents) {
            List<NlComponent> matches = view.findViewsByTag(tag);
            if (matches != null) {
                if (result != null) {
                    result.addAll(matches);
                } else {
                    result = matches;
                }
            }
        }

        return result;
    }

    @Nullable
    public NlComponent findViewByPsi(@Nullable PsiElement element) {
        assert ApplicationManager.getApplication().isReadAccessAllowed();

        while (element != null) {
            if (element instanceof XmlTag) {
                return findViewByTag((XmlTag) element);
            }
            element = element.getParent();
        }

        return null;
    }

    /**
     * Finds any components that overlap the given rectangle.
     *
     * @param x      The top left x corner defining the selection rectangle.
     * @param y      The top left y corner defining the selection rectangle.
     * @param width  The w of the selection rectangle
     * @param height The h of the selection rectangle
     */
    public List<NlComponent> findWithin(@AndroidCoordinate int x, @AndroidCoordinate int y,
            @AndroidCoordinate int width, @AndroidCoordinate int height) {
        List<NlComponent> within = Lists.newArrayList();
        for (NlComponent component : myComponents) {
            addWithin(within, component, x, y, width, height);
        }
        return within;
    }

    private static boolean addWithin(@NotNull List<NlComponent> result, @NotNull NlComponent component,
            @AndroidCoordinate int x, @AndroidCoordinate int y, @AndroidCoordinate int width,
            @AndroidCoordinate int height) {
        if (component.x + component.w <= x || x + width <= component.x || component.y + component.h <= y
                || y + height <= component.y) {
            return false;
        }

        boolean found = false;
        for (NlComponent child : component.getChildren()) {
            found |= addWithin(result, child, x, y, width, height);
        }
        if (!found) {
            result.add(component);
        }
        return true;
    }

    public void delete(final Collection<NlComponent> components) {
        // Group by parent and ask each one to participate
        WriteCommandAction<Void> action = new WriteCommandAction<Void>(myFacet.getModule().getProject(),
                "Delete Component", myFile) {
            @Override
            protected void run(@NotNull Result<Void> result) throws Throwable {
                handleDeletion(components);
            }
        };
        action.execute();

        List<NlComponent> remaining = Lists.newArrayList(mySelectionModel.getSelection());
        remaining.removeAll(components);
        mySelectionModel.setSelection(remaining);
        notifyModified(ChangeType.DELETE);
    }

    private void handleDeletion(@NotNull Collection<NlComponent> components) {
        // Segment the deleted components into lists of siblings
        Map<NlComponent, List<NlComponent>> siblingLists = groupSiblings(components);

        ViewHandlerManager viewHandlerManager = ViewHandlerManager.get(myFacet);

        // Notify parent components about children getting deleted
        for (Map.Entry<NlComponent, List<NlComponent>> entry : siblingLists.entrySet()) {
            NlComponent parent = entry.getKey();
            if (parent == null) {
                continue;
            }
            List<NlComponent> children = entry.getValue();
            boolean finished = false;

            ViewHandler handler = viewHandlerManager.getHandler(parent);
            if (handler instanceof ViewGroupHandler) {
                finished = ((ViewGroupHandler) handler).deleteChildren(parent, children);
            }

            if (!finished) {
                for (NlComponent component : children) {
                    NlComponent p = component.getParent();
                    if (p != null) {
                        p.removeChild(component);
                    }
                    component.getTag().delete();
                }
            }
        }
    }

    /**
     * Partitions the given list of components into a map where each value is a list of siblings,
     * in the same order as in the original list, and where the keys are the parents (or null
     * for the components that do not have a parent).
     * <p/>
     * The value lists will never be empty. The parent key will be null for components without parents.
     *
     * @param components the components to be grouped
     * @return a map from parents (or null) to a list of components with the corresponding parent
     */
    @NotNull
    public static Map<NlComponent, List<NlComponent>> groupSiblings(
            @NotNull Collection<? extends NlComponent> components) {
        Map<NlComponent, List<NlComponent>> siblingLists = new HashMap<>();

        if (components.isEmpty()) {
            return siblingLists;
        }
        if (components.size() == 1) {
            NlComponent component = components.iterator().next();
            siblingLists.put(component.getParent(), Collections.singletonList(component));
            return siblingLists;
        }

        for (NlComponent component : components) {
            NlComponent parent = component.getParent();
            List<NlComponent> children = siblingLists.get(parent);
            if (children == null) {
                children = new ArrayList<>();
                siblingLists.put(parent, children);
            }
            children.add(component);
        }

        return siblingLists;
    }

    /**
     * Creates a new component of the given type. It will optionally insert it as a child of the given parent (and optionally
     * right before the given sibling or null to append at the end.)
     * <p/>
     * Note: This operation can only be called when the caller is already holding a write lock. This will be the
     * case from {@link ViewHandler} callbacks such as {@link ViewHandler#onCreate} and {@link DragHandler#commit}.
     *
     * @param screenView The target screen, if known. Used to handle pixel to dp computations in view handlers, etc.
     * @param fqcn       The fully qualified name of the widget to insert, such as {@code android.widget.LinearLayout}.
     *                   You can also pass XML tags here (this is typically the same as the fully qualified class name
     *                   of the custom view, but for Android framework views in the android.view or android.widget packages,
     *                   you can omit the package.)
     * @param parent     The optional parent to add this component to
     * @param before     The sibling to insert immediately before, or null to append
     * @param insertType The type of insertion
     */
    public NlComponent createComponent(@Nullable ScreenView screenView, @NotNull String fqcn,
            @Nullable NlComponent parent, @Nullable NlComponent before, @NotNull InsertType insertType) {
        String tagName = NlComponent.viewClassToTag(fqcn);

        XmlTag tag;
        if (parent != null) {
            // Creating a component intended to be inserted into an existing layout
            tag = parent.getTag().createChildTag(tagName, null, null, false);
        } else {
            // Creating a component not yet inserted into a layout. Typically done when trying to perform
            // a drag from palette, etc.
            XmlElementFactory elementFactory = XmlElementFactory.getInstance(getProject());
            String text = "<" + fqcn + " xmlns:android=\"http://schemas.android.com/apk/res/android\"/>"; // SIZES?
            tag = elementFactory.createTagFromText(text);
        }

        return createComponent(screenView, tag, parent, before, insertType);
    }

    public NlComponent createComponent(@Nullable ScreenView screenView, @NotNull XmlTag tag,
            @Nullable NlComponent parent, @Nullable NlComponent before, @NotNull InsertType insertType) {
        if (parent != null) {
            // Creating a component intended to be inserted into an existing layout
            XmlTag parentTag = parent.getTag();
            if (before != null) {
                tag = (XmlTag) parentTag.addBefore(tag, before.getTag());
            } else {
                tag = parentTag.addSubTag(tag, false);
            }

            // Required attribute for all views; drop handlers can adjust as necessary
            if (tag.getAttribute(ATTR_LAYOUT_WIDTH, ANDROID_URI) == null) {
                tag.setAttribute(ATTR_LAYOUT_WIDTH, ANDROID_URI, VALUE_WRAP_CONTENT);
            }
            if (tag.getAttribute(ATTR_LAYOUT_HEIGHT, ANDROID_URI) == null) {
                tag.setAttribute(ATTR_LAYOUT_HEIGHT, ANDROID_URI, VALUE_WRAP_CONTENT);
            }
        } else {
            // No namespace yet: use the default prefix instead
            if (tag.getAttribute(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_WIDTH) == null) {
                tag.setAttribute(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_WIDTH, VALUE_WRAP_CONTENT);
            }
            if (tag.getAttribute(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_HEIGHT) == null) {
                tag.setAttribute(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_HEIGHT, VALUE_WRAP_CONTENT);
            }
        }

        NlComponent child = new NlComponent(this, tag);

        if (parent != null) {
            parent.addChild(child, before);
        }

        // Notify view handlers
        ViewHandlerManager viewHandlerManager = ViewHandlerManager.get(getProject());
        ViewHandler childHandler = viewHandlerManager.getHandler(child);
        if (childHandler != null && screenView != null) {
            ViewEditor editor = new ViewEditorImpl(screenView);
            boolean ok = childHandler.onCreate(editor, parent, child, insertType);
            if (!ok) {
                if (parent != null) {
                    parent.removeChild(child);
                }
                tag.delete();
                return null;
            }
        }
        if (parent != null) {
            ViewHandler parentHandler = viewHandlerManager.getHandler(parent);
            if (parentHandler instanceof ViewGroupHandler) {
                ((ViewGroupHandler) parentHandler).onChildInserted(parent, child, insertType);
            }
        }

        return child;
    }

    @NotNull
    public Transferable getSelectionAsTransferable() {
        return mySelectionModel.getTransferable(myId);
    }

    /**
     * Returns true if the specified components can be added to the specified receiver.
     */
    public boolean canAddComponents(@Nullable List<NlComponent> toAdd, @NotNull NlComponent receiver,
            @Nullable NlComponent before) {
        if (before != null && before.getParent() != receiver) {
            return false;
        }

        Object parentHandler = receiver.getViewHandler();

        if (!(parentHandler instanceof ViewGroupHandler)) {
            return false;
        }
        final ViewGroupHandler groupHandler = (ViewGroupHandler) parentHandler;

        if (toAdd == null || toAdd.isEmpty()) {
            return false;
        }
        for (NlComponent component : toAdd) {
            if (!groupHandler.acceptsChild(receiver, component)) {
                return false;
            }

            ViewHandler handler = ViewHandlerManager.get(getProject()).getHandler(component);

            if (handler != null && !handler.acceptsParent(receiver, component)) {
                return false;
            }

            // If the receiver is a (possibly indirect) child of any of the dragged components, then reject the operation
            NlComponent same = receiver;
            while (same != null) {
                if (same == component) {
                    return false;
                }
                same = same.getParent();
            }
        }

        return true;
    }

    /**
     * Adds components to the specified receiver before the given sibling.
     * If insertType is a move the components specified should be components from this model.
     */
    public void addComponents(@Nullable final List<NlComponent> toAdd, @NotNull final NlComponent receiver,
            @Nullable final NlComponent before, @NotNull final InsertType insertType) {
        if (!canAddComponents(toAdd, receiver, before)) {
            return;
        }
        assert toAdd != null;

        WriteCommandAction<Void> action = new WriteCommandAction<Void>(getProject(),
                insertType.getDragType().getDescription(), myFile) {
            @Override
            protected void run(@NotNull Result<Void> result) throws Throwable {
                handleAddition(toAdd, receiver, before, insertType);
            }
        };
        action.execute();
        notifyModified(ChangeType.ADD_COMPONENTS);
    }

    private void handleAddition(@NotNull List<NlComponent> added, @NotNull NlComponent receiver,
            @Nullable NlComponent before, @NotNull InsertType insertType) {
        Set<String> ids = Sets.newHashSet(NlComponent.getIds(this));

        ViewGroupHandler groupHandler = (ViewGroupHandler) receiver.getViewHandler();
        assert groupHandler != null;

        for (NlComponent component : added) {
            if (insertType.isMove()) {
                insertType = component.getParent() == receiver ? InsertType.MOVE_WITHIN : InsertType.MOVE_INTO;
            }
            if (component.needsDefaultId() && (StringUtil.isEmpty(component.getId()) || !insertType.isMove())) {
                ids.add(NlComponent.assignId(component, ids));
            }
            groupHandler.onChildInserted(receiver, component, insertType);

            NlComponent parent = component.getParent();
            if (parent != null) {
                parent.removeChild(component);
            }
            receiver.addChild(component, before);
            if (receiver.getTag() != component.getTag()) {
                XmlTag prev = component.getTag();
                transferNamespaces(prev);
                if (before != null) {
                    component.setTag((XmlTag) receiver.getTag().addBefore(component.getTag(), before.getTag()));
                } else {
                    component.setTag(receiver.getTag().addSubTag(component.getTag(), false));
                }
                if (insertType.isMove()) {
                    prev.delete();
                }
            }
            removeNamespaceAttributes(component);
            TemplateUtils.reformatAndRearrange(getProject(), component.getTag());
        }
    }

    /**
     * Given a root tag which is not yet part of the current document, (1) look up any namespaces defined on that root tag, transfer
     * those to the current document, and (2) update all attribute prefixes for namespaces to match those in the current document
     */
    private void transferNamespaces(@NotNull XmlTag tag) {
        // Transfer namespace attributes
        XmlDocument xmlDocument = myFile.getDocument();
        assert xmlDocument != null;
        XmlTag rootTag = xmlDocument.getRootTag();
        assert rootTag != null;
        Map<String, String> prefixToNamespace = rootTag.getLocalNamespaceDeclarations();
        Map<String, String> namespaceToPrefix = Maps.newHashMap();
        for (Map.Entry<String, String> entry : prefixToNamespace.entrySet()) {
            namespaceToPrefix.put(entry.getValue(), entry.getKey());
        }
        Map<String, String> oldPrefixToPrefix = Maps.newHashMap();

        for (Map.Entry<String, String> entry : tag.getLocalNamespaceDeclarations().entrySet()) {
            String namespace = entry.getValue();
            String prefix = entry.getKey();
            String currentPrefix = namespaceToPrefix.get(namespace);
            if (currentPrefix == null) {
                // The namespace isn't used in the document. Import it.
                String newPrefix = AndroidResourceUtil.ensureNamespaceImported(myFile, namespace, prefix);
                if (!prefix.equals(newPrefix)) {
                    // We imported the namespace, but the prefix used in the new document isn't available
                    // so we need to update all attribute references to the new name
                    oldPrefixToPrefix.put(prefix, newPrefix);
                    namespaceToPrefix.put(namespace, newPrefix);
                }
            } else if (!prefix.equals(currentPrefix)) {
                // The namespace is already imported, but using a different prefix. We need
                // to switch the prefixes.
                oldPrefixToPrefix.put(prefix, currentPrefix);
            }
        }

        if (!oldPrefixToPrefix.isEmpty()) {
            updatePrefixes(tag, oldPrefixToPrefix);
        }
    }

    /**
     * Recursively update all attributes such that XML attributes with prefixes in the {@code oldPrefixToPrefix} key set
     * are replaced with the corresponding values
     */
    private static void updatePrefixes(@NotNull XmlTag tag, @NotNull Map<String, String> oldPrefixToPrefix) {
        for (XmlAttribute attribute : tag.getAttributes()) {
            String prefix = attribute.getNamespacePrefix();
            if (!prefix.isEmpty()) {
                if (prefix.equals(XMLNS)) {
                    String newPrefix = oldPrefixToPrefix.get(attribute.getLocalName());
                    if (newPrefix != null) {
                        attribute.setName(XMLNS_PREFIX + newPrefix);
                    }
                } else {
                    String newPrefix = oldPrefixToPrefix.get(prefix);
                    if (newPrefix != null) {
                        attribute.setName(newPrefix + ':' + attribute.getLocalName());
                    }
                }
            }
        }

        for (XmlTag child : tag.getSubTags()) {
            updatePrefixes(child, oldPrefixToPrefix);
        }
    }

    private static void removeNamespaceAttributes(NlComponent component) {
        for (XmlAttribute attribute : component.getTag().getAttributes()) {
            if (attribute.getName().startsWith(XMLNS_PREFIX)) {
                attribute.delete();
            }
        }
    }

    @Nullable
    public static DnDTransferItem getTransferItem(@NotNull Transferable transferable, boolean allowPlaceholder) {
        DnDTransferItem item = null;
        try {
            if (transferable.isDataFlavorSupported(ItemTransferable.DESIGNER_FLAVOR)) {
                item = (DnDTransferItem) transferable.getTransferData(ItemTransferable.DESIGNER_FLAVOR);
            } else if (transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) {
                String xml = (String) transferable.getTransferData(DataFlavor.stringFlavor);
                if (!StringUtil.isEmpty(xml)) {
                    item = new DnDTransferItem(new DnDTransferComponent("", xml, 200, 100));
                }
            }
        } catch (InvalidDnDOperationException ex) {
            if (!allowPlaceholder) {
                return null;
            }
            String defaultXml = "<placeholder xmlns:android=\"http://schemas.android.com/apk/res/android\"/>";
            item = new DnDTransferItem(new DnDTransferComponent("", defaultXml, 200, 100));
        } catch (IOException | UnsupportedFlavorException ex) {
            LOG.warn(ex);
        }
        return item;
    }

    @Nullable
    public List<NlComponent> createComponents(@NotNull ScreenView screenView, @NotNull DnDTransferItem item,
            @NotNull InsertType insertType) {
        List<NlComponent> components = new ArrayList<>(item.getComponents().size());
        for (DnDTransferComponent dndComponent : item.getComponents()) {
            XmlTag tag = createTag(screenView.getModel().getProject(), dndComponent.getRepresentation());
            NlComponent component = createComponent(screenView, tag, null, null, insertType);
            if (component == null) {
                return null; // User may have cancelled
            }
            component.w = dndComponent.getWidth();
            component.h = dndComponent.getHeight();
            components.add(component);
        }
        return components;
    }

    @NotNull
    @VisibleForTesting
    public static XmlTag createTag(@NotNull Project project, @NotNull String text) {
        XmlElementFactory elementFactory = XmlElementFactory.getInstance(project);
        XmlTag tag = null;
        if (XmlUtils.parseDocumentSilently(text, false) != null) {
            try {
                tag = elementFactory.createTagFromText(text);

                setNamespaceUri(tag, ANDROID_NS_NAME, ANDROID_URI);
                setNamespaceUri(tag, APP_PREFIX, AUTO_URI);
            } catch (IncorrectOperationException ignore) {
                // Thrown by XmlElementFactory if you try to parse non-valid XML. User might have tried
                // to drop something like plain text -- insert this as a text view instead.
                // However, createTagFromText may not always throw this for invalid XML, so we perform the above parseDocument
                // check first instead.
            }
        }
        if (tag == null) {
            tag = elementFactory.createTagFromText(
                    "<TextView xmlns:android=\"http://schemas.android.com/apk/res/android\" " + " android:text=\""
                            + XmlUtils.toXmlAttributeValue(text) + "\"" + " android:layout_width=\"wrap_content\""
                            + " android:layout_height=\"wrap_content\"" + "/>");
        }
        return tag;
    }

    private static void setNamespaceUri(@NotNull XmlTag tag, @NotNull String prefix, @NotNull String uri) {
        boolean anyMatch = Arrays.stream(tag.getAttributes())
                .anyMatch(attribute -> attribute.getNamespacePrefix().equals(prefix));

        if (anyMatch) {
            tag.setAttribute("xmlns:" + prefix, uri);
        }
    }

    @NotNull
    public InsertType determineInsertType(@NotNull DragType dragType, @Nullable DnDTransferItem item,
            boolean asPreview) {
        if (item != null && item.isFromPalette()) {
            return asPreview ? InsertType.CREATE_PREVIEW : InsertType.CREATE;
        }
        switch (dragType) {
        case CREATE:
            return asPreview ? InsertType.CREATE_PREVIEW : InsertType.CREATE;
        case MOVE:
            return item != null && myId != item.getModelId() ? InsertType.COPY : InsertType.MOVE_INTO;
        case COPY:
            return InsertType.COPY;
        case PASTE:
        default:
            return InsertType.PASTE;
        }
    }

    @Override
    public void dispose() {
        deactivate(); // ensure listeners are unregistered if necessary

        synchronized (myListeners) {
            myListeners.clear();
        }

        // dispose is called by the project close using the read lock. Invoke the render task dispose later without the lock.
        ApplicationManager.getApplication().invokeLater(() -> {
            synchronized (RENDERING_LOCK) {
                if (myRenderTask != null) {
                    myRenderTask.dispose();
                    myRenderTask = null;
                }
            }
            myRenderResultLock.writeLock().lock();
            try {
                myRenderResult = null;
            } finally {
                myRenderResultLock.writeLock().unlock();
            }
        });
    }

    @Override
    public String toString() {
        return NlModel.class.getSimpleName() + " for " + myFile;
    }

    // ---- Implements ResourceNotificationManager.ResourceChangeListener ----

    @Override
    public void resourcesChanged(@NotNull Set<ResourceNotificationManager.Reason> reason) {
        for (ResourceNotificationManager.Reason r : reason) {
            switch (r) {
            case RESOURCE_EDIT:
                notifyModified(ChangeType.RESOURCE_EDIT);
                break;
            case EDIT:
                notifyModified(ChangeType.EDIT);
                break;
            case IMAGE_RESOURCE_CHANGED:
                RefreshRenderAction.clearCache(mySurface);
                break;
            case GRADLE_SYNC:
            case PROJECT_BUILD:
            case VARIANT_CHANGED:
            case SDK_CHANGED:
                notifyModified(ChangeType.BUILD);
                break;
            case CONFIGURATION_CHANGED:
                notifyModified(ChangeType.CONFIGURATION_CHANGE);
                break;
            }
        }
    }

    // ---- Implements ModificationTracker ----

    public enum ChangeType {
        RESOURCE_EDIT, EDIT, RESOURCE_CHANGED, ADD_COMPONENTS, DELETE, DND_COMMIT, DND_END, DROP, RESIZE_END, RESIZE_COMMIT, REQUEST_RENDER, UPDATE_HIERARCHY, BUILD, CONFIGURATION_CHANGE
    }

    /**
     * Maintains multiple counter depending on what did change in the model
     */
    static class ModelVersion {
        private final AtomicLong myVersion = new AtomicLong();
        private final AtomicLong myResourceVersion = new AtomicLong();
        private final AtomicLong myHierarchyVersion = new AtomicLong();
        @SuppressWarnings("unused")
        ChangeType mLastReason;

        public void increase(ChangeType reason) {
            myVersion.incrementAndGet();
            mLastReason = reason;
            switch (reason) {
            case RESOURCE_EDIT:
            case EDIT:
            case RESOURCE_CHANGED:
            case DELETE:
            case RESIZE_END:
            case RESIZE_COMMIT:
            case ADD_COMPONENTS: {
                myResourceVersion.incrementAndGet();
            }
                break;
            default:
                myHierarchyVersion.incrementAndGet();
            }
        }

        public long getVersion() {
            return myVersion.get();
        }

        public long getResourceVersion() {
            return myResourceVersion.get();
        }
    }

    @Override
    public long getModificationCount() {
        return myModelVersion.getVersion();
    }

    public long getResourceVersion() {
        return myModelVersion.getResourceVersion();
    }

    public void notifyModified(ChangeType reason) {
        String theme = myConfiguration.getTheme();
        if (theme != null && !theme.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)
                && !myProjectResourceRepository.hasResourceItem(theme)) {
            myConfiguration
                    .setTheme(myConfiguration.getConfigurationManager().computePreferredTheme(myConfiguration));
        }
        myModelVersion.increase(reason);
        myModificationTrigger = reason;
        requestModelUpdate();
    }

    /**
     * Updates the saved values that are used to log user changes to the configuration toolbar.
     */
    private void updateTrackingConfiguration() {
        myPreviousDeviceName = myConfiguration.getDevice() != null ? myConfiguration.getDevice().getDisplayName()
                : null;
        myPreviousVersion = myConfiguration.getTarget() != null ? myConfiguration.getTarget().getVersionName()
                : null;
        myPreviousLocale = myConfiguration.getLocale();
        myPreviousTheme = myConfiguration.getTheme();
    }

    private class AndroidPreviewProgressIndicator extends ProgressIndicatorBase {
        private final Object myLock = new Object();

        @Override
        public void start() {
            super.start();
            UIUtil.invokeLaterIfNeeded(() -> {
                final Timer timer = UIUtil.createNamedTimer("Android rendering progress timer", 0, event -> {
                    synchronized (myLock) {
                        if (isRunning()) {
                            mySurface.registerIndicator(this);
                        }
                    }
                });
                timer.setRepeats(false);
                timer.start();
            });
        }

        @Override
        public void stop() {
            synchronized (myLock) {
                super.stop();
                ApplicationManager.getApplication().invokeLater(() -> mySurface.unregisterIndicator(this));
            }
        }
    }
}