org.cytoscape.app.internal.manager.App.java Source code

Java tutorial

Introduction

Here is the source code for org.cytoscape.app.internal.manager.App.java

Source

package org.cytoscape.app.internal.manager;

/*
 * #%L
 * Cytoscape App Impl (app-impl)
 * $Id:$
 * $HeadURL:$
 * %%
 * Copyright (C) 2008 - 2013 The Cytoscape Consortium
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as 
 * published by the Free Software Foundation, either version 2.1 of the 
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Lesser Public License for more details.
 * 
 * You should have received a copy of the GNU General Lesser Public 
 * License along with this program.  If not, see
 * <http://www.gnu.org/licenses/lgpl-2.1.html>.
 * #L%
 */

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;

import org.apache.commons.io.FileUtils;
import org.cytoscape.app.AbstractCyApp;
import org.cytoscape.app.CyAppAdapter;
import org.cytoscape.app.internal.exception.AppDisableException;
import org.cytoscape.app.internal.exception.AppInstallException;
import org.cytoscape.app.internal.exception.AppInstanceException;
import org.cytoscape.app.internal.exception.AppUninstallException;
import org.cytoscape.app.internal.net.WebQuerier;
import org.cytoscape.app.internal.util.DebugHelper;
import org.cytoscape.app.swing.CySwingAppAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class represents an app, and contains all needed information about the app such as its name, version, 
 * authors list, description, and file path (if present).
 */
public abstract class App {

    private static final Logger logger = LoggerFactory.getLogger(App.class);

    private String appName;
    private String version;
    private String authors;
    private String description;

    /**
     * The file containing the app, may be a jar file.
     */
    private File appFile;

    /**
     * The temporary file corresponding to the app that is used to load classes from.
     */
    private File appTemporaryInstallFile;

    /**
     * The fully-qualified name of the app's class that extends {@link AbstractCyApp} to be instantiated when the app is loaded.
     */
    private String entryClassName;

    /**
     * The major versions of Cytoscape that the app is known to be compatible for, listed in comma-delimited form. Example: "2, 3"
     */
    private String compatibleVersions;

    private URL appStoreUrl;

    /**
     * A reference to the instance of the app's class that extends {@link AbstractCyApp}.
     */
    private AbstractCyApp appInstance;

    /**
     * Whether this App object represents an app that has been checked to have valid packaging (such as containing
     * necessary tags in its manifest file) and contains valid fields, making it loadable by the {@link AppManager} service.
     */
    private boolean appValidated;

    /**
     * Whether we've found the official name of the app as opposed to using an inferred name.
     */
    private boolean officialNameObtained;

    /**
     * The SHA-512 checksum of the app file, in format sha512:0a516c..
     */
    private String sha512Checksum;

    public static class Dependency {
        final String name;
        final String version;

        public Dependency(final String name, final String version) {
            this.name = name;
            this.version = version;
        }

        public String getName() {
            return name;
        }

        public String getVersion() {
            return version;
        }

        public String toString() {
            return name + " " + version;
        }
    }

    private List<Dependency> dependencies = null;

    private AppStatus status;

    /**
     * An enumeration that indicates the status of a given app, such as whether it is installed or uninstalled.
     */
    public enum AppStatus {
        INSTALLED("Installed"), DISABLED("Disabled"), UNINSTALLED("Uninstalled"), TO_BE_INSTALLED(
                "Install on Restart"), FILE_MOVED("File Moved (Uninstalled)"), FAILED_TO_START("Failed to Start");

        String readableStatus;

        private AppStatus(String readableStatus) {
            this.readableStatus = readableStatus;
        }

        @Override
        public String toString() {
            return readableStatus;
        }
    }

    public App() {
        this.appName = "";
        this.version = "";
        this.authors = "";
        this.description = null;
        this.appFile = null;

        appValidated = false;
        officialNameObtained = false;
        this.status = null;
    }

    /**
     * Loosely, a detached app is no longer associated with the main program.
     * 
     * This is a useful method for knowing which apps not to display in an "all apps" GUI listing.
     */
    public boolean isDetached() {

        // Following above Javadoc, e.g. Completely uninstalled, and/or file is completely gone
        if ((status == AppStatus.UNINSTALLED && appInstance == null)
                || status == AppStatus.FILE_MOVED && appInstance == null) {

            return true;
        } else {
            return false;
        }
    }

    /**
     * Creates an instance of this app, such as by instancing the app's class that extends AbstractCyApp,
     * and returns an instance to it.
     * @param appAdapter A reference to the {@link CyAppAdapter} service used to provide the newly
     * created app instance with access to the Cytoscape API
     * @return A reference to the instance of the app's class that extends AbstractCyApp.
     * @throws AppInstanceException If there was an error while instancing the app, such as not being able to
     * locate the class to be instanced.
     */
    public abstract Object createAppInstance(CySwingAppAdapter appAdapter) throws AppInstanceException;

    /**
     * Installs this app by creating an instance of its class that extends AbstractCyApp, copying itself
     * over to the local Cytoscape app storage directory using the directory path obtained from the given 
     * {@link AppManager} if needed, and registering it to the {@link AppManager}.
     * @param appManager The AppManager used to register this app.
     * @throws AppInstallException If there was an error while installing the app such as being unable to copy
     * over the app file.
     */
    public abstract void install(AppManager appManager) throws AppInstallException;

    /**
     * Uninstalls this app by unloading its classes if possible, and copying itself over to
     * the local Cytoscape app storage directory for uninstalled apps using the path obtained from the
     * given {@link AppManager}.
     * @param appManager The AppManager used to register this app.
     * @throws AppUninstallException If there was an error while uninstalling the app, such as attemping
     * to uninstall an app that isn't installed, or being unable to move the app file to the uninstalled
     * apps directory.
     */
    public abstract void uninstall(AppManager appManager) throws AppUninstallException;

    public abstract void disable(AppManager appManager) throws AppDisableException;

    /**
     * Default app installation method that can be used by classes extending this class.
     * 
     * Attempts to install an app by copying it to the installed apps directory,
     * creating an instance of the app's class that extends the {@link AbstractCyApp} class,
     * and registering it with the given {@link AppManager} object. The app is instanced by
     * calling its createAppInstance() method.
     * 
     * @param appManager The AppManager used to register this app with.
     * @throws AppInstallException If there was an error while attempting to install the app such
     * as improper app packaging, failure to copy the file to the installed apps directory, 
     * or failure to create an instance of the app.
     */
    protected void defaultInstall(AppManager appManager) throws AppInstallException {
        // Check if the app has been verified to contain proper packaging.
        if (!this.isAppValidated()) {

            // If the app is not packaged properly or is missing fields in its manifest file, do not install the app
            // as the install operation will fail.
            throw new AppInstallException(
                    "Cannot install app; app file has not been checked to have proper metadata");
        }

        // Check if the app has already been installed.
        if (this.getStatus() == AppStatus.INSTALLED) {

            // Do nothing if it is already installed
            throw new AppInstallException("This app has already been installed.");
        }

        for (App app : appManager.getApps()) {
            if (this.heuristicEquals(app) && this != app) {

                // If we already have an App object registered to the app manager
                // that represents this app, re-use that app object
                app.setAppFile(this.appFile);
                app.install(appManager);
                //appManager.installApp(app);

                return;
            }
        }

        // Obtain the paths to the local storage directories for holding installed and uninstalled apps.
        String installedAppsPath = appManager.getInstalledAppsPath();
        String uninstalledAppsPath = appManager.getUninstalledAppsPath();

        // Attempt to copy the app to the directory for installed apps.
        try {
            File appFile = this.getAppFile();

            if (!appFile.exists()) {
                DebugHelper.print("Install aborted: file " + appFile.getCanonicalPath() + " does not exist");
                return;
            }

            // Make sure no app with the same filename and app name is already installed
            File installedDirectoryTargetFile = new File(installedAppsPath + File.separator + appFile.getName());
            File uninstalledDirectoryTargetFile = new File(
                    uninstalledAppsPath + File.separator + appFile.getName());

            String copyDestinationFileName = appFile.getName();

            // Check for filename collisions in both the installed apps directory as well as the 
            // uninstalled apps directory
            if (installedDirectoryTargetFile.exists() || uninstalledDirectoryTargetFile.exists()) {
                Set<App> registeredApps = appManager.getApps();

                // The app registered to the app manager that happens to have the same filename
                App conflictingApp = null;
                File registeredAppFile;

                for (App registeredApp : registeredApps) {
                    registeredAppFile = registeredApp.getAppFile();

                    if (registeredAppFile != null
                            && registeredAppFile.getName().equalsIgnoreCase(appFile.getName())) {
                        conflictingApp = registeredApp;
                    }
                }

                // Only prevent the overwrite if the filename conflict is with an app registered
                // to the app manager
                if (conflictingApp != null) {

                    // Check if the apps have the same name
                    if (this.getAppName().equalsIgnoreCase(conflictingApp.getAppName())) {

                        // Same filename, same app name found

                        // Forgive the collision if we are copying from the uninstalled apps directory
                        if (appFile.getParentFile().getCanonicalPath().equals(uninstalledAppsPath)) {

                            // Forgive collision if other app is not installed
                        } else if (conflictingApp.getStatus() != AppStatus.INSTALLED) {

                            // Ignore collisions with self
                            // } else if (conflictingApp.getAppFile().equals(appFile)) {

                        } else {
                            /*
                            for (App app : appManager.getApps()) {
                               if (this.heuristicEquals(app) && this != app) {
                                  DebugHelper.print("Install aborted: heuristic finds app already installed");
                                  DebugHelper.print("conflict app status: " + app.getStatus());
                                  DebugHelper.print("conflict app name: " + app.getAppName());
                                      
                                  appManager.installApp(app);
                                      
                                  return;
                               }
                            }
                            */

                            // Skip installation, suspected that a copy of the app is already installed

                            // return;
                        }

                    } else {

                        // Same filename, different app name found
                        // Rename file
                        Collection<String> directoryPaths = new LinkedList<String>();
                        directoryPaths.add(installedAppsPath);
                        directoryPaths.add(uninstalledAppsPath);

                        copyDestinationFileName = suggestFileName(directoryPaths, appFile.getName());
                    }

                }
            }

            // Only perform the copy if the app was not already in the target directory
            if (!appFile.getParentFile().getCanonicalPath().equals(installedAppsPath)) {

                // Uses Apache Commons library; overwrites files with the same name.
                // FileUtils.copyFileToDirectory(appFile, new File(installedAppsPath));

                // If we copied it from the uninstalled apps directory, remove it from that directory
                File targetFile = new File(installedAppsPath + File.separator + copyDestinationFileName);
                if (appFile.getParentFile().getCanonicalPath().equals(uninstalledAppsPath)) {
                    FileUtils.moveFile(appFile, targetFile);
                } else {
                    FileUtils.copyFile(appFile, targetFile);
                }

                // Update the app's path
                this.setAppFile(new File(installedAppsPath + File.separator + copyDestinationFileName));
            }
        } catch (IOException e) {
            throw new AppInstallException("Unable to copy app file to installed apps directory: " + e.getMessage());
        }

        // Make a second copy to be used to load the actual classes
        // This is used to prevent errors associated with moving jar files that have classes loaded from them.
        if (this.getAppTemporaryInstallFile() == null) {
            String temporaryInstallPath = appManager.getTemporaryInstallPath();
            List<String> temporaryInstallPathCollection = new LinkedList<String>();
            temporaryInstallPathCollection.add(temporaryInstallPath);

            // Rename the file if necessary to avoid overwrites
            File temporaryInstallTargetFile = new File(temporaryInstallPath + File.separator
                    + suggestFileName(temporaryInstallPathCollection, appFile.getName()));
            try {
                FileUtils.copyFile(appFile, temporaryInstallTargetFile);

                this.setAppTemporaryInstallFile(temporaryInstallTargetFile);
            } catch (IOException e) {
                logger.warn("Failed to make copy of app file to be used for loading classes. The problem was: "
                        + e.getMessage());
            }
        }

        // Create an app instance only if one was not already created
        if (this.getAppInstance() == null) {
            Object appInstance;
            try {
                appInstance = createAppInstance(appManager.getSwingAppAdapter());
            } catch (AppInstanceException e) {
                throw new AppInstallException("Unable to create app instance: " + e.getMessage());
            }

            // Keep a reference to the newly created instance
            this.setAppInstance((AbstractCyApp) appInstance);
        }

        this.setStatus(AppStatus.INSTALLED);
        appManager.addApp(this);
    }

    /**
     * Given a set of canonical directory paths, find a name for a given file that does not 
     * collide with names of files in any of the given directories.
     * 
     * For example, if the name file.txt is taken, this method will return file-2.txt. If the
     * latter is taken, it will return file-3.txt, and so on.
     * 
     * @param directoryPaths A collection of canonical directory paths used to check for files
     * that have colliding names
     * @param desiredFileName The desired name for the given file, used as a base to which the
     * number tag is added.
     * @return A new name of the file that does not collide with any non-directory file in the given
     * paths. If the given filename had no collisions, then an identical filename is returned.
     */
    protected String suggestFileName(Collection<String> directoryPaths, String desiredFileName) {

        int postfixNumber = 1;
        boolean nameCollision = false;
        File file;

        for (String directoryPath : directoryPaths) {
            file = new File(directoryPath + File.separator + desiredFileName);

            nameCollision = nameCollision || (file.exists() && !file.isDirectory());
        }

        String fileBaseName = desiredFileName;
        String fileFullExtension = "";
        int lastPeriodIndex = desiredFileName.lastIndexOf(".");

        if (lastPeriodIndex != -1) {
            fileBaseName = desiredFileName.substring(0, lastPeriodIndex);
            fileFullExtension = desiredFileName.substring(lastPeriodIndex, desiredFileName.length());
        }

        String newFileName = desiredFileName;

        while (nameCollision) {
            postfixNumber++;
            nameCollision = false;

            for (String directoryPath : directoryPaths) {
                // If the old name is basename.extension, then the new name is basename-postfixNumber.extension
                newFileName = fileBaseName + "-" + postfixNumber + fileFullExtension;
                file = new File(directoryPath + File.separator + newFileName);

                nameCollision = nameCollision || (file.exists() && !file.isDirectory());
            }
        }

        return newFileName;
    }

    /**
     * Default app uninstallation method that can be used by classes extending this class.
     * 
     * The default app uninstallation procedure consists of simply moving the app to the uninstalled apps
     * directory.
     * 
     * @param appManager The app manager responsible for managing apps, which is used to obtain
     * the path of the storage directories containing the installed and uninstalled apps
     * @throws AppUninstallException If there was an error while uninstalling the app, such as
     * attempting to uninstall an app that is not installed, or failure to move the app to
     * the uninstalled apps directory
     */
    protected void defaultUninstall(AppManager appManager) throws AppUninstallException {
        // Check if the app is installed before attempting to uninstall.
        if (this.getStatus() != AppStatus.INSTALLED) {
            // If it is not installed, do not attempt to uninstall it.
            throw new AppUninstallException("App is not installed; cannot uninstall.");
        }

        // For an installed app whose file has been moved or is no longer available, do not
        // perform file moving. Instead, attempt to try to complete the uninstallation without 
        // regard to app's file.
        if (this.getAppFile() != null) {

            if (!this.getAppFile().exists()) {

                // Skip file moving if the file has been moved
                return;
            }

            // Check if the app is inside the directory containing currently installed apps.
            // If so, prepare to move it to the uninstalled directory.
            File appParentDirectory = this.getAppFile().getParentFile();
            try {
                // Obtain the path of the "uninstalled apps" subdirectory.
                String uninstalledAppsPath = appManager.getUninstalledAppsPath();

                if (appParentDirectory.getCanonicalPath().equals(appManager.getInstalledAppsPath())) {

                    // Use the Apache commons library to copy over the file, overwriting existing files.
                    try {
                        FileUtils.moveFileToDirectory(this.getAppFile(), new File(uninstalledAppsPath), true);
                    } catch (IOException e) {
                        throw new AppUninstallException("Unable to move file: " + e.getMessage());
                    }

                    // Delete the source file after the copy operation
                    String fileName = this.getAppFile().getName();

                    //System.gc();
                    //System.out.println("Deleting " + this.getAppFile().getPath() + ": " + App.delete(this.getAppFile()));
                    this.setAppFile(new File(uninstalledAppsPath + File.separator + fileName));
                }
            } catch (IOException e) {
                throw new AppUninstallException("Unable to obtain path: " + e.getMessage());
            }
        }
    }

    /**
     * Checks if this app is known to be compatible with a given version of Cytoscape.
     * @param cytoscapeVersion The version of Cytoscape to be checked, in the form major.minor.patch[-tag].
     * @return <code>true</code> if this app is known to be compatible with the given version, or
     * <code>false</code> otherwise.
     */
    public boolean isCompatible(String cytoscapeVersion) {
        // Get the major version of Cytoscape
        String majorVersion = cytoscapeVersion.substring(0, cytoscapeVersion.indexOf(".")).trim();

        if (compatibleVersions.matches("(.*,|^)\\s*(" + majorVersion + ")\\s*(,.*|$)")) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Uses heuristics to check if another App represents the same Cytoscape app as this App, 
     * ignoring filename differences. Specifically, it returns true only if the app names
     * and app versions are equal.
     * 
     * @param other The app to compare against.
     * @return <code>true</code> if the apps are suspected to be the same Cytoscape app,
     * <code>false</code> otherwise.
     */
    public boolean heuristicEquals(App other) {

        // Return false if different app names
        if (appName.equalsIgnoreCase(other.appName) && WebQuerier.compareVersions(version, other.version) == 0) {

            if (sha512Checksum != null && other.sha512Checksum != null) {
                return (sha512Checksum.equalsIgnoreCase(other.sha512Checksum));
            }

            return true;
        }

        return false;
    }

    /*
    /**
     * Returns true only if the argument is an {@link App} with the same app name and version.
     */
    /*
    @Override
    public boolean equals(Object other) {
       if (other == null) {
     return false;
       }
           
       if (other instanceof App) {
     return (this.heuristicEquals((App) other));
       }
           
       return false;
    }
    */

    public String getReadableStatus() {
        return this.getStatus().toString();
    }

    public String getAppName() {
        return appName;
    }

    public String getVersion() {
        return version;
    }

    public String getAuthors() {
        return authors;
    }

    public String getDescription() {
        return description;
    }

    /**
     * Return the file containing the app.
     * @return The file containing the app, such as a jar, zip, or kar file.
     */
    public File getAppFile() {
        return appFile;
    }

    /**
     * Return the temporary file associated with the app that is used to load classes from.
     * @return The temporary file corresponding to the app used to load classes from
     */
    public File getAppTemporaryInstallFile() {
        return appTemporaryInstallFile;
    }

    public String getEntryClassName() {
        return entryClassName;
    }

    /**
     * Return the major versions of Cytoscape the app is known to be compatible with, in comma-delimited form, eg. "2, 3"
     * @return The major versions of Cytoscape that the app is known to be compatible with
     */
    public String getCompatibleVersions() {
        return compatibleVersions;
    }

    public String getSha512Checksum() {
        return sha512Checksum;
    }

    public URL getAppStoreUrl() {
        return appStoreUrl;
    }

    public AbstractCyApp getAppInstance() {
        return appInstance;
    }

    public boolean isAppValidated() {
        return appValidated;
    }

    public boolean isOfficialNameObtained() {
        return officialNameObtained;
    }

    public AppStatus getStatus() {
        return status;
    }

    public List<Dependency> getDependencies() {
        return dependencies;
    }

    public void setAppName(String appName) {
        this.appName = appName;
    }

    public void setVersion(String version) {
        this.version = version;
    }

    public void setAuthors(String authors) {
        this.authors = authors;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public void setAppFile(File appFile) {
        this.appFile = appFile;
    }

    public void setAppTemporaryInstallFile(File appTemporaryInstallFile) {
        this.appTemporaryInstallFile = appTemporaryInstallFile;
    }

    public void setEntryClassName(String entryClassName) {
        this.entryClassName = entryClassName;
    }

    public void setCompatibleVersions(String compatibleVersions) {
        this.compatibleVersions = compatibleVersions;
    }

    public void setSha512Checksum(String sha512Checksum) {
        this.sha512Checksum = sha512Checksum;
    }

    public void setAppStoreUrl(URL appStoreURL) {
        this.appStoreUrl = appStoreURL;
    }

    public void setAppInstance(AbstractCyApp appInstance) {
        this.appInstance = appInstance;
    }

    public void setAppValidated(boolean appValidated) {
        this.appValidated = appValidated;
    }

    public void setOfficialNameObtained(boolean officialNameObtained) {
        this.officialNameObtained = officialNameObtained;
    }

    public void setStatus(AppStatus status) {
        this.status = status;
    }

    public void setDependencies(List<Dependency> deps) {
        this.dependencies = deps;
    }

    public static boolean delete(File f) {
        if (!f.exists()) {
            System.err.println("Cannot delete, file does not exist: " + f.getPath());
            return false;
        }
        f.setReadable(true);
        f.setWritable(true);
        if (!f.canWrite()) {
            System.err.println("Cannot delete, file is read-only: " + f.getPath());
            return false;
        }

        // Hack attempt  
        File parent = f.getParentFile();
        parent.setReadable(true);
        parent.setWritable(true);
        if (!parent.canWrite()) {
            System.err.println("Cannot delete, parent folder read-only: " + parent.getPath());
            return false;
        }

        try {
            (new SecurityManager()).checkDelete(f.getPath());
        } catch (Exception ex) {
            System.err.println("Cannot delete file, " + ex.getMessage());
            return false;
        }

        boolean ret = f.delete();
        if (!ret)
            System.err.println("Delete failed: " + f.getPath());
        return ret;
    }

    /**
     * Moves an app file to the given directory, copying the app if it is outside one of the local app storage directories
     * and moving if it is not. Also assigns filename that does not colliide with any from the local app storage directories.
     * 
     * Will also add postfix to filename if desired filename already exists in target directory when
     * moving app to a directory other than the 3 local app storage directories.
     * 
     * @param appManager A reference to the app manager
     * @param targetDirectory The local storage directory to move to, such as the local sotrage directory
     * containing installed apps obtained via the app manager
     * @throws IOException If there was an error while moving/copying the file
     */
    public void moveAppFile(AppManager appManager, File targetDirectory) throws IOException {
        File parentPath = this.getAppFile().getParentFile();
        File installDirectoryPath = new File(appManager.getInstalledAppsPath());
        File disabledDirectoryPath = new File(appManager.getDisabledAppsPath());
        File uninstallDirectoryPath = new File(appManager.getUninstalledAppsPath());

        // Want to make sure the app file's name does not collide with another name in these directories
        LinkedList<String> uniqueNameDirectories = new LinkedList<String>();

        if (!parentPath.equals(installDirectoryPath))
            uniqueNameDirectories.add(installDirectoryPath.getCanonicalPath());

        if (!parentPath.equals(disabledDirectoryPath))
            uniqueNameDirectories.add(disabledDirectoryPath.getCanonicalPath());

        if (!parentPath.equals(uninstallDirectoryPath))
            uniqueNameDirectories.add(uninstallDirectoryPath.getCanonicalPath());

        if (!parentPath.equals(targetDirectory) && !installDirectoryPath.equals(targetDirectory)
                && !disabledDirectoryPath.equals(targetDirectory)
                && !uninstallDirectoryPath.equals(targetDirectory))
            uniqueNameDirectories.add(targetDirectory.getCanonicalPath());

        // If the app file is in one of these directories, do a move instead of a copy
        LinkedList<File> moveDirectories = new LinkedList<File>();
        moveDirectories.add(installDirectoryPath);
        moveDirectories.add(disabledDirectoryPath);
        moveDirectories.add(uninstallDirectoryPath);

        File targetFile = new File(targetDirectory.getCanonicalPath() + File.separator
                + suggestFileName(uniqueNameDirectories, this.getAppFile().getName()));

        if (!targetDirectory.equals(parentPath)) {
            if (moveDirectories.contains(parentPath)) {
                FileUtils.moveFile(this.getAppFile(), targetFile);
                //System.out.println("Moving: " + this.getAppFile() + " -> " + targetFile);

                // ** Disabled to let directory observers assign file reference
                // this.setAppFile(targetFile);
            } else {
                FileUtils.copyFile(this.getAppFile(), targetFile);
                //System.out.println("Copying: " + this.getAppFile() + " -> " + targetFile);

                // ** Disabled to let directory observers assign file reference
                // this.setAppFile(targetFile);
            }
        }
    }

    protected boolean checkAppAlreadyInstalled(AppManager appManager) {
        boolean appAlreadyInstalled = false;
        for (App app : appManager.getApps()) {
            if (app.heuristicEquals(this)) {
                appAlreadyInstalled = true;
            }
        }

        return appAlreadyInstalled;
    }
}