// JarLoader.java

import java.io.*;
import java.util.*;
import java.util.jar.*;

// for main test only
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

/**
 * A JarLoader object is able to load all the jar files in a given
 * directory containing classes of a given type. It can also be set
 * to continuously track that directory for new jar files that are
 * added later. 
 * Listeners can be added to JarLoader objects which will be notified 
 * when the list of valid jar files in that directory changes.
 *<br>
 * Also contains a static method for dynamically loading a given jar file.
 *
 * @author Melinda Green - Superliminal Software http://www.superliminal.com
 */
public class JarLoader  {

    /**
     * When added as JarLoader listeners, objects implementing this interface
     * are informed whenever the contained list of loaded classes changes.
     */
    public interface ListListener {
        /**
         * Notification method implemented by jarLoader list listeners.
         * These implementations typically follow up with calls to 
         * <code>getLoadedClassNames()</code> and/or
         * <code>getClass(String name)</code> to discover the changes.
         */
        public void listChanged(JarLoader loader);
    }

    private Class target;
    final private String jarClassKey;
    private boolean watchForChanges;
    private HashMap name2class = new HashMap();
    private int last_jar_file_count = -1; //tracks when listeners need to be notified.
    private File directory;
    public File getDirectory() { return directory; }


    /**
     * Used to discover the names of all classes currently
     * loaded by this JarLoader instance.
     * @return an array of the names of all the 
     * classes currently loaded by this JarLoader.
     * Note, this list is suitable for display in a JComboBox.
     */
    public String[] getLoadedClassNames() {
        Object keys[] = null;
        keys = name2class.keySet().toArray(new String[0]);
        return (String[])keys;
    }

    /**
     * Retrives the Class object for a previously loaded class.
     * @param name is the name of a previously loaded class
     * presumably reported in a previous call to getLoadedClassNames.
     * @return the class object corrisponding to the loaded
     * class with the given name, or null if not found.
     */
    public Class getClass(String name) {
        return (Class)name2class.get(name);
    }
    
    /**
     * Creates a JarLoader to which loads from and possibly tracks jar
     * files of a given type in a directory. 
     * The loaded classes are available via <code>getClass(String name)</code>
     * where "name" was presumably gotten from <code>getLoadedClassNames()</code>.
     * @param jarClassKey is the key to look for in each jar's manifest file.
     * JarLoader will load one class from each jar file who's manifest file
     * contains a key/value attribute pair of the following form:
     * <jarClassKey>: <Class name>
     * <br>Example: <code>new JarLoader("My-Component", MyComponent.class, "plugins", false)</code><br>
     * will load all classes of type MyComponent identified by the 
     * "My-Component" key in each jar file's manifest file for each jar file
     * found in the "plugins" directory.
     * @param containing is a base class or interface which loaded classes 
     * must inherit from or implement in order to be loaded.
     * @param dir is the directory in which to look for jar files.
     * @param watchDir is a flag stating whether this JarLoader should
     * continuously monitor the given directory for changes in the list of
     * loadable jar files found there.
     */
    public JarLoader(String jarClassKey, Class containing, String dir, boolean watchDir) {
        directory = new File(dir);
        if(!directory.isDirectory())
            throw new java.security.InvalidParameterException(directory + " not a directory");
        this.jarClassKey = jarClassKey;
        target = containing;
        watchForChanges = watchDir;
        load(); //always loads jar files in given directory at least once.
        if(watchForChanges) {
            new Thread() {
                public void run() {
                    //System.out.println("Starting watcher");
                    while(true) {
                        try { Thread.sleep(1000); }
                        catch(InterruptedException ie) {}
                        load(); //reload jar files if directory changed
                    }
                }
            }.start();
        }
    }

    /**
     * The directory version of <code>load(File...)</code> which loads all conforming
     * Classes found in the jar files of the directory with which this JarLoader
     * instance was created.
     */
    private void load() {
        File jar_files[] = directory.listFiles(new FileFilter()  {
            public boolean accept(File pathname) {
                return pathname.getName().endsWith(".jar");
            }
        });
        if(jar_files.length == last_jar_file_count)
            return; //nothing to do so go back to sleep
        name2class.clear();
        //System.out.println("found " + jar_files.length + " jar files");
        last_jar_file_count = jar_files.length; // remember for next time
        for(int i=0; i<jar_files.length; i++)  {
            Class loaded_class = load(jar_files[i], jarClassKey, name2class);
            if(loaded_class!=null && target.isAssignableFrom(loaded_class)) {                  
                name2class.put(loaded_class.getName(), loaded_class);
            }
        }
        fireListChanged();
    }


    /**
     * The workhorse of JarLoader which performs the actual dynamic loading
     * of a single jar file.
     * @param jarFile is a java File object representing the jar file to load.
     * @param jarClassKey is the key to look for in each jar's manifest file.
     * @param name2class is an optional HashMap which, if supplied, will return
     * null if the class in the given jar file has the same name as a String
     * key in the HashMap. In all cases the given HashMap is left unchanged.
     * @return The Class loaded from the jar file or null on error.
     */
     public static Class load(File jarFile, String jarClassKey, HashMap name2class) {
        JarFile the_jar = null;
        try {
            final JarFile ajar = new JarFile(jarFile);
            the_jar = ajar; // so it can be closed regardless of exceptions
            Manifest manifest = ajar.getManifest();
            Map map = manifest.getEntries();
            Attributes att = manifest.getMainAttributes();
            final String loaded_class_name = att.getValue(jarClassKey);
            if(loaded_class_name == null) {
                System.out.println("can't find class to load in manifest at the key: " +
                    jarClassKey);
                return null;
            }
            if(name2class!=null && name2class.get(loaded_class_name) != null)
                return (Class)name2class.get(loaded_class_name); // already have this one
            //System.out.println("loading " + loaded_class_name);
            Class loaded_class = new ClassLoader()  {
                public Class findClass(String name) {
                    JarEntry loaded_class_entry = ajar.getJarEntry(name);
                    if(loaded_class_entry == null)
                        return(null); 
                    try {  
                        InputStream is = ajar.getInputStream(loaded_class_entry);
                        int available = is.available();
                        byte data[] = new byte[available];
                        is.read(data);
                        return defineClass(name, data, 0, data.length);
                    }
                    catch(IOException ioe)  {
                        System.out.println("Exception: " + ioe);
                        return(null);
                    }
                }
            }.loadClass(loaded_class_name);
            return loaded_class;
        }
        catch(Exception e) {
            System.out.println("Exception: " + e);
            return null;
        }
        finally { //insures jar file is always closed regardless of exceptions
            if(the_jar != null) {
                try { the_jar.close(); }
                catch(IOException ioe) {}
            }
        }
    } // end load(File...)

    //Listener handling methods
    private Vector listListeners = new Vector();
    public void removeListListener(JarLoader.ListListener ll) { listListeners.remove(ll); }
    private void fireListChanged() {
        for(Enumeration e=listListeners.elements(); e.hasMoreElements(); ) {
            ((ListListener)e.nextElement()).listChanged(this);
        }
    }
    /**
     * Adds a listener to be notified when conforming jar files are added
     * or removed from the given directory.
     * The given listeners are notified immediately in order to let them
     * do any initializations involving the currently loaded classes.
     */
    public void addListListener(JarLoader.ListListener ll) {
        listListeners.add(ll);
        ll.listChanged(this);
    }


    //EVERYTHING FROM HERE DOWN IN THIS FILE IS FOR TESTING ONLY
    //
    //INSTRUCTIONS:
    //In order to run this test you will need to compile this file and then
    //create two jar files for the two concrete "loadable" classes
    //defined below. To do that, you can use Sun's "jar" tool to package
    //each loadable class into a jar file. To make that simple, extract
    //the following text into the named files but without indentation:
    //
    //LoadableEntry1.txt:
    //    Manifest-Version: 1.0
    //    Loadable-Class: JarLoader$TestLoadable1
    //    
    //LoadableEntry1.txt:
    //    Manifest-Version: 1.0
    //    Loadable-Class: JarLoader$TestLoadable2
    //    
    //Note the extra blank line at the end of each file (three lines total).
    //Next, create a file named maketest.bat" (for Windows) containing
    //the following command:
    //    c:/jdk1.3/bin/jar cfm test1.jar LoadableEntry1.txt *TestLoadable1.class
    //    c:/jdk1.3/bin/jar cfm test2.jar LoadableEntry2.txt *TestLoadable2.class
    //With your correct path to the "jar" program.
    //That batch file will create proper jar files which you can move in and
    //out of the program directory while running the test program resulting
    //in controll buttons being created and deleted in response.
    //You can then rerun the batch file if you change the names of your
    //loadable classes or make other changes that affect the jar files.

    /**
     * A loadable test base class.
     * Jar files containing classes derived from this base class.
     * will be loaded by the main method.
     */
    private interface TestLoadableBase {
        public void print();
    }
    /**
     * An example loadable test class.
     * NOTE: Since this one happens to be an inner class,
     * it's important that it be static. 
     * Otherwise it would not be instantiatiable outside of a parent instance.
     */
    private static class TestLoadable1 implements TestLoadableBase {
        public TestLoadable1() {} //empty constructor required for class to be accessible
        public void print() { System.out.println("print called on a TestLoadable1"); }
    }
    /**
     * Another example loadable test class.
     */
    private static class TestLoadable2 implements TestLoadableBase {
        public TestLoadable2() {} //empty constructor required for class to be accessible
        public void print() { System.out.println("print called on a TestLoadable2"); }
    }

    /**
     * A test application for JarLoader which loads and monitors the jar
     * files in the current directory. A GUI is presented with interactive
     * objects dynamically loaded jar files. Adding and removing those jar files
     * from the current directory while the program is running will create and 
     * delete those GUI representations.
     * @param args unused.
     */
    public static void main(String args[]) {
        final int WIN_WIDTH = 500, WIN_HEIGHT = 300, BUTT_WIDTH = 200, BUTT_HEIGHT = 30;
        final HashMap butt2position = new HashMap();
        JarLoader the_loader = new JarLoader("Loadable-Class", TestLoadableBase.class, ".", true);
        final JPanel class_list = new JPanel(null);
        class_list.setSize(new Dimension(WIN_WIDTH, WIN_HEIGHT));
        JarLoader.ListListener loader_listener = new ListListener()  {
            public void listChanged(final JarLoader loader) {
                SwingUtilities.invokeLater(new Runnable() {
                    public void run() {
                        //System.out.println("List changed");
                        class_list.removeAll();
                        String available_classes[] = loader.getLoadedClassNames();
                        for(int i=0; i<available_classes.length; i++)  {
                            //Note that all final variables in this block are 
                            //local to the button being constructed.
                            final String class_name = available_classes[i];
                            final JButton my_butt = new JButton(class_name);
                            final Point bounds_start = new Point();
                            final Point drag_start = new Point();
                            Point pos = (Point)butt2position.get(class_name);
                            if(pos == null) {
                                pos = new Point(
                                    (int)(Math.random() * (WIN_WIDTH  - BUTT_WIDTH)), 
                                    (int)(Math.random() * (WIN_HEIGHT - BUTT_HEIGHT)));
                                butt2position.put(class_name, pos); // so visible don't move when others are added or removed
                            }
                            my_butt.setBounds(pos.x, pos.y, BUTT_WIDTH, BUTT_HEIGHT);
                            my_butt.addActionListener(new ActionListener() {
                                public void actionPerformed(ActionEvent ae) {
                                    Class loadable_class = loader.getClass(class_name);
                                    try {
                                        TestLoadableBase loadable = (TestLoadableBase)loadable_class.newInstance();
                                        loadable.print();
                                    }
                                    catch(Exception e) {System.out.println("Exception: " + e);}
                                }
                            });
                            my_butt.addMouseListener(new MouseAdapter() {
                                public void mousePressed(MouseEvent me) {
                                    drag_start.x = me.getX();
                                    drag_start.y = me.getY();
                                    bounds_start.x = my_butt.getBounds().x;
                                    bounds_start.y = my_butt.getBounds().y;
                                }                                
                            });
                            my_butt.addMouseMotionListener(new MouseMotionAdapter() {
                                public void mouseDragged(MouseEvent me) {
                                    //NOTE: I can't figure out why the reported positions on
                                    //the next line are wrong and get worse the more you drag!
                                    //System.out.println("MouseEvent position = " + me.getPoint());
                                    int xdif = me.getX() - drag_start.x;
                                    int ydif = me.getY() - drag_start.y;
                                    Point pos = (Point)butt2position.get(class_name);
                                    pos.x = bounds_start.x + xdif;
                                    pos.y = bounds_start.y + ydif;
                                    my_butt.setBounds(pos.x, pos.y, BUTT_WIDTH, BUTT_HEIGHT);
                                }
                            });
                            class_list.add(my_butt);
                        }
                        class_list.repaint();
                    }
                });
            }
        };
        the_loader.addListListener(loader_listener);
        JPanel content = new JPanel();
        content.setLayout(new BorderLayout());
        JTextArea info = new JTextArea(
            "The following combo box contains a list of all currently loaded " +
            "classes of type 'TestLoadableBase' from the jar files found in this directory. " +
            "Adding and removing TestLoadableBase jar files will cause the contents " +
            "of the combo box to change immediately to reflect the available " +
            "jar files. \n\n" +
            "Selecting a TestLoadable class in the combo box causes an instance " +
            "of that class to be created and its 'print' method to be called.\n\n" +
            "Drag the buttons around to adjust their positions."
        );
        info.setLineWrap(true);
        info.setWrapStyleWord(true);
        content.add("North", info);
        content.add("Center", class_list);
        JFrame frame = new JFrame("JarLoader Test");
        frame.getContentPane().add(content);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(new Dimension(500, 500));
        frame.show();
    } //end main

}