package com.knutejohnson.pi;

import java.awt.*;
import java.awt.event.*;
import java.awt.image.*;
import java.io.*;
import java.nio.charset.*;
import java.text.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
import static java.util.stream.Collectors.*;
import javax.activation.*;
import javax.imageio.*;
import javax.mail.*;
import javax.mail.internet.*;
import javax.net.ssl.*;
import javax.swing.*;

/**
 * <div>
 * MotionDetection is a program for the RaspberryPi computer and camera to take
 * a series of photos and detect motion in the images.  It does this by cutting
 * the image up into a series of boxes of about 1% of the image dimensions.
 * These boxes are then compared for an average brightness and if the
 * brightness difference between images exceeds the threshold in a minimum
 * number of boxes the image is marked as having motion.  The marked images may
 * be saved to storage.  Either the last image taken or a composite image made
 * up of the last two images and a set of blue boxes marking the areas that
 * exceeded the brightness threshold may be displayed.  The dimensions of the
 * captured images and the image display on the program may be selected from
 * program menus.  All program parameters are saved to a file stored in the
 * user's home directory.
 * </div>
 * <div style="margin-top: 30px;">
 * </div>
 * <table style="margin-top: 30px; width: 100%; border-collapse: collapse;" border cellspacing="2" summary="Version info">
 * <tr><th>Version<th>Date<th>Modification
 * <tr><td>0.10.0beta<td style="white-space: nowrap;">08 Jan 2017 <td>incept
 * <tr><td>0.12.0beta<td>09 Jan 2017<td>fix bug in directory file chooser
 * <tr><td>0.13.0beta<td>10 Jan 2017<td>rewrite directory selection code and make minor changes to comments and directions
 * <tr><td>0.20.0<td>12 Jan 2017<td>move the image processing code off to a separate thread
 * <tr><td>0.21.0<td>13 Jan 2017<td>correct bad index in image processing code and get rid of one thread
 * <tr><td>0.21.1<td>15 Jan 2017<td>clean up javadocs and comments
 * <tr><td>0.22.0<td>08 Feb 2017<td>set the raspistill timeout to 1000ms to get the exposure correct on the images
 * <tr><td>0.23.0<td>09 Feb 2017<td>make sure to close the InputStream from the process running raspistill
 * <tr><td>0.23.1<td>16 Feb 2017<td>minor changes to the options passed to raspistill
 * <tr><td>0.24.0<td>23 Mar 2017<td>add alligator to main thread to destroy process if it hangs
 * <tr><td>0.25.0<td>05 Apr 2017<td>change the command line options to raspistill to get the image slightly faster
 * <tr><td>0.30.0<td style="white-space: nowrap;">05 May 2017<td>change the way we are using raspistill to run it in timelapse mode and collect the image files to a ramdisk then read those files off the disk and put them in the processing queue - this allows us to collect images from raspistill every 500ms even with large image sizes
 * <tr><td>0.31.0<td>06 May 2017<td>clean up and simplify the ProcessBuilder code
 * <tr><td>0.32.0<td>07 May 2017<td>more code cleanup
 * <tr><td>0.33.0<td>08 May 2017<td>simplify ProcessManager code, organize code in CaptureSizeAction and stop(), and increase the image processing threads to four
 * <tr><td>0.40.0<td>09 May 2017<td>get rid of the transfer queue and associated code and clean up the processImage method
 * <tr><td>0.41.0<td>10 May 2017<td>clean up the code that keeps track of the display aspect ratio and add some 3x2 aspect ratio capture sizes
 * <tr><td>0.42.0<td>12 May 2017<td>store properties whenever data changes rather than just when the program stops
 * <tr><td>0.50.0<td>14 May 2017<td>add option to email captured photos
 * <tr><td>0.51.0<td>15 May 2017<td>add code to default the SSL email protocols
 * <tr><td>0.51.1<td>15 May 2017<td>clean up javadocs and code comments
 * <tr><td>0.52.0<td>15 May 2017<td>fixed a bug where the image about to be saved or emailed was overwritten by the composite image and clean up the status messages to the console
 * <tr><td>0.60.0<td>18 May 2017<td>change storeProperties method to run in new thread and do a lot of cleanup in processImage
 * <tr><td>0.60.1<td>19 May 2017<td>remove some redundant variables
 * <tr><td>0.70.0<td>09 Jun 2017<td>rewrite of the image scan code provided by Federico Pedemonte
 * </table>
 *
 * @author  Knute Johnson
 * @version 0.70.0 - 9 Jun 2017
 */
public class MotionDetection extends JFrame implements Runnable {
    /** Program version */
    public static final String VERSION = "0.70.0";

    /** Program date */
    public static final String DATE = "9 June 2017";

    /** Capture size labels */
    private static final String[] CAPTURE_SIZE_LABELS = {
     "640x480     (4x3)",
     "640x360     (16x9)",
     "720x540     (4x3)",
     "720x405     (16x9)",
     "750x500     (3x2)",
     "800x600     (4x3)",
     "800x450     (16x9)",
     "900x600     (3x2)",
     "1024x768   (4x3)",
     "1024x576   (16x9)",
     "1200x800   (3x2)",
     "1366x1024 (4x3)",
     "1366x768   (16x9)",
     "1500x1000 (3x2)",
     "1600x1200 (4x3)",
     "1600x900   (16x9)",
     "1800x1200 (3x2)",
     "1920x1440 (4x3)",
     "1920x1080 (16x9)",
     "2250x1500 (3x2)",
     "2592x1944 (4x3)",
     "2592x1458 (16x9)",
    };

    /** Display size labels */
    private static final String[] DISPLAY_SIZE_LABELS = {
     "640","800","1024","1200" };

    /** INI file name */
    private static final String INI_FILE_NAME = ".motiondetection";

    /** Date pattern for saved image files */
    private static final String DATE_PATTERN = "yyyyMMddHHmmss";

    /** Transfer directory */
    private static final File TXFER_DIR = new File("/mnt/ramdisk");

    /** Number of bits to rotate password string */
    private static final int ROT = 4;

    /** Number of processors */
    private static final int PROCESSORS =
     Runtime.getRuntime().availableProcessors();

    /** Flag to print processing times on console */
    private static boolean timesFlag;

    /** Date format for saved image files */
    private final SimpleDateFormat dateFormat;

    /** Main program thread */
    private final Thread cameraThread;

    /** Image processor thread */
    private final Thread processorThread;

    /** Main thread run flag */
    private volatile boolean runFlag;

    /** Capture image width */
    private volatile int captureWidth = 640;

    /** Capture image height */
    private volatile int captureHeight = 480;

    /** Display aspect ratio */
    private double aspectRatio = (double)captureWidth / captureHeight;

    /** Display image width */
    private int displayWidth = 640;

    /** Display image height */
    private int displayHeight = 480;

    /** Flag if images are to be saved */
    private volatile boolean saveFile;

    /** Flag if images are to be emailed */
    private volatile boolean emailImage;

    /** Flag if composite images are to be displayed */
    private volatile boolean showComposite;

    /** File chooser for saved images directory */
    private final JFileChooser fileChooser;

    /** Directory where captured images are written */
    private volatile File directory = new File(System.getProperty("user.dir"));

    /** Display panel for images */
    private final ImageJPanel imagePanel;

    /** RenderingHints for all image drawing */
    private final RenderingHints hints;

    /** Threshold value */
    private volatile double threshold = 10;

    /** Minimum boxes value */
    private volatile double minBoxes = 2;

    /** Maximum boxes value */
    private volatile double maxBoxes = 40;

    /** Properties to store values set from menus */
    private final Properties properties = new Properties();

    /** Previous image */
    private BufferedImage imageOld;

    /** Current image */
    private BufferedImage imageNew;

    /** Previous image box values */
    private int [][] brightOld;

    /** Current image box values */
    private int [][] brightNew;

    /**
     * Creates a new MotionDetection program
     */
    public MotionDetection() {
        super("MotionDetection - " + VERSION + " - " + DATE);

        System.out.printf("available processors: %d%n",PROCESSORS);

        // read the properties file
        try (FileReader reader = new FileReader(new File(
         System.getProperty("user.home"),INI_FILE_NAME))) {
            properties.load(reader);
        } catch (IOException ioe) {
            JOptionPane.showMessageDialog(null,
             "Error reading properties file!\n" + ioe + "\nUsing defaults.\n" +
             "NOTE: This normal the first time the program is run.",
             "MotionDetection",JOptionPane.WARNING_MESSAGE);
        }
        properties.setProperty("mail.smtp.ssl.trust","*");

        // set values from the properties
        directory = new File(properties.getProperty("captureDirectory",
         directory.getPath()));
        saveFile = Boolean.parseBoolean(properties.getProperty("saveFile",
         Boolean.toString(saveFile)));
        emailImage = Boolean.parseBoolean(properties.getProperty("emailImage",
         Boolean.toString(emailImage)));
        showComposite = Boolean.parseBoolean(properties.getProperty(
         "showComposite",Boolean.toString(showComposite)));
        threshold = Double.parseDouble(properties.getProperty("threshold",
         Double.toString(threshold)));
        minBoxes = Double.parseDouble(properties.getProperty("minBoxes",
         Double.toString(minBoxes)));
        maxBoxes = Double.parseDouble(properties.getProperty("maxBoxes",
         Double.toString(maxBoxes)));
        captureWidth = Integer.parseInt(properties.getProperty("captureWidth",
         Integer.toString(captureWidth)));
        captureHeight = Integer.parseInt(properties.getProperty("captureHeight",
         Integer.toString(captureHeight)));
        displayWidth = Integer.parseInt(properties.getProperty("displayWidth",
         Integer.toString(displayWidth)));
        displayHeight = Integer.parseInt(properties.getProperty("displayHeight",
         Integer.toString(displayHeight)));
        aspectRatio = (double)captureWidth / captureHeight;

        // set up the file chooser to display only directories
        fileChooser = new JFileChooser(directory);
        fileChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
        fileChooser.setAcceptAllFileFilterUsed(false);
        fileChooser.addChoosableFileFilter(
         new javax.swing.filechooser.FileFilter() {
             @Override public boolean accept(File f) {
                 return f.isDirectory();
             }
             @Override public String getDescription() {
                return "All Directories";
             }
        });

        dateFormat = new SimpleDateFormat(DATE_PATTERN);

        // set up rendering hints for image drawing
        hints = new RenderingHints(RenderingHints.KEY_ANTIALIASING,
         RenderingHints.VALUE_ANTIALIAS_ON);
        hints.add(new RenderingHints(RenderingHints.KEY_RENDERING,
         RenderingHints.VALUE_RENDER_QUALITY));
        hints.add(new RenderingHints(RenderingHints.KEY_INTERPOLATION,
         RenderingHints.VALUE_INTERPOLATION_BICUBIC));

        cameraThread = new Thread(this,"Camera Thread");
        processorThread = new Thread(() -> processImage(),"Processor Thread");

        addWindowListener(new WindowAdapter() {
            // start the program threads on window open
            @Override public void windowOpened(WindowEvent we) { start(); };
            // window is diposed if X is clicked
            @Override public void windowClosing(WindowEvent we) { dispose(); }
            // main thread is stopped when window is closed
            @Override public void windowClosed(WindowEvent we) { stop();}
        });

        // prevent the window from being maximized
        addWindowStateListener(new WindowStateListener() {
            @Override public void windowStateChanged(WindowEvent we) {
                if ((we.getNewState() & Frame.MAXIMIZED_BOTH) > 0)
                    ((Frame)we.getSource()).setExtendedState(Frame.NORMAL);
            }
        });

        // prevent the window from being resized
        addComponentListener(new ComponentAdapter() {
            @Override public void componentResized(ComponentEvent ce) {
                pack();
            }
        });

        JMenuBar menuBar = new JMenuBar();
        setJMenuBar(menuBar);

        JMenu file = menuBar.add(new JMenu("File"));
        JMenu settings = menuBar.add(new JMenu("Settings"));
        JMenu view = menuBar.add(new JMenu("View"));
        JMenu help = menuBar.add(new JMenu("Help"));

        JMenuItem mi;

        mi = file.add(new JCheckBoxMenuItem("Save Images",saveFile));
        mi.addActionListener(ae -> {
            saveFile = ((JCheckBoxMenuItem)ae.getSource()).isSelected();
            properties.setProperty("saveFile",Boolean.toString(saveFile));
            storeProperties();
        });

        mi = file.add(new JCheckBoxMenuItem("Email Images",emailImage));
        mi.addActionListener(ae -> {
            emailImage = ((JCheckBoxMenuItem)ae.getSource()).isSelected();
            properties.setProperty("emailImage",Boolean.toString(emailImage));
            storeProperties();
        });

        mi = file.add("Directory");
        mi.addActionListener(ae -> {
            int state = fileChooser.showDialog(this,"Select Directory");
            if (state == JFileChooser.APPROVE_OPTION) {
                File dir = fileChooser.getSelectedFile();
                if (dir.exists()) {
                    directory = dir;
                } else {
                    int option = JOptionPane.showConfirmDialog(this,
                     dir.getPath() + "\nDoesn't exist, create it?","Directory",
                     JOptionPane.OK_CANCEL_OPTION,JOptionPane.QUESTION_MESSAGE);
                    if (option == JOptionPane.OK_OPTION) {
                        if (dir.mkdir()) {
                            directory = dir;
                        } else {
                            JOptionPane.showMessageDialog(this,
                             "Can't Create Directory!","Directory",
                             JOptionPane.WARNING_MESSAGE);
                        }
                    }
                }
                fileChooser.setCurrentDirectory(directory);
                properties.setProperty("captureDirectory",directory.getPath());
                storeProperties();
            }
        });

        file.addSeparator();

        mi = file.add("Exit");
        mi.addActionListener(ae -> {
            dispose();
        });

        mi = settings.add("Capture Levels");
        mi.addActionListener(ae -> {
            JPanel p = new JPanel(new GridBagLayout());
            GridBagConstraints c = new GridBagConstraints();
            c.insets = new Insets(2,2,2,2);

            c.gridy = 0;
            JLabel l = new JLabel("Threshold");
            p.add(l,c);

            JSpinner thresholdSpinner = new JSpinner(new SpinnerNumberModel(
             threshold,0.0,100.0,1.0));
            p.add(thresholdSpinner,c);

            ++c.gridy;
            l = new JLabel("Minimum");
            p.add(l,c);

            JSpinner minSpinner = new JSpinner(new SpinnerNumberModel(
             minBoxes,0.0,100.0,0.1));
            p.add(minSpinner,c);

            ++c.gridy;
            l = new JLabel("Maximum");
            p.add(l,c);

            JSpinner maxSpinner = new JSpinner(new SpinnerNumberModel(
             maxBoxes,0.0,100.0,1.0));
            p.add(maxSpinner,c);

            int option = JOptionPane.showConfirmDialog(this,p,"CaptureLevels",
             JOptionPane.OK_CANCEL_OPTION,JOptionPane.QUESTION_MESSAGE);

            if (option == JOptionPane.OK_OPTION) {
                threshold = (Double)thresholdSpinner.getValue();
                properties.setProperty("threshold",Double.toString(threshold));
                minBoxes = (Double)minSpinner.getValue();
                properties.setProperty("minBoxes",Double.toString(minBoxes));
                maxBoxes = (Double)maxSpinner.getValue();
                properties.setProperty("maxBoxes",Double.toString(maxBoxes));
                storeProperties();
            }
        });

        JMenu captureSize = new JMenu("Capture Image Size");
        settings.add(captureSize);

        mi = settings.add("Email Settings");
        mi.addActionListener(ae -> {
            EmailSettings es = new EmailSettings(properties);
            int option = JOptionPane.showConfirmDialog(this,es,"Email Settings",
             JOptionPane.OK_CANCEL_OPTION,JOptionPane.INFORMATION_MESSAGE);

            if (option == JOptionPane.OK_OPTION) {
                es.updateProperties();
                storeProperties();
            }
        });

        ButtonGroup group = new ButtonGroup();
        for (String label : CAPTURE_SIZE_LABELS) {
            JRadioButtonMenuItem ri =
             new JRadioButtonMenuItem(new CaptureSizeAction(label));
            group.add((JRadioButtonMenuItem)captureSize.add(ri));
            String dim = String.format("%dx%d",captureWidth,captureHeight);
            if (label.startsWith(dim))
                ri.setSelected(true);
        }

        mi = view.add(new JCheckBoxMenuItem("Show Composite",showComposite));
        mi.addActionListener(ae -> {
            showComposite = ((JCheckBoxMenuItem)ae.getSource()).isSelected();
            properties.setProperty("showComposite",
             Boolean.toString(showComposite));
            storeProperties();
        });

        JMenu displaySize = new JMenu("Display Width");
        view.add(displaySize);

        group = new ButtonGroup();
        for (String label : DISPLAY_SIZE_LABELS) {
            JRadioButtonMenuItem ri = new JRadioButtonMenuItem(
             new DisplaySizeAction(label));
            group.add((JRadioButtonMenuItem)displaySize.add(ri));
            ri.setSelected(displayWidth == Integer.parseInt(label));
        }

        try (InputStream is =
         getClass().getResourceAsStream("mdDirections.html")) {
            if (is != null) {
                try (BufferedReader br = new BufferedReader(
                 new InputStreamReader(is))) {
                    String html = br.lines().collect(joining("\n"));
                    JScrollPane pane = new JScrollPane(new JLabel(html),
                     JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,
                     JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
                    pane.setPreferredSize(new Dimension(
                     pane.getPreferredSize().width,250));
                    mi = help.add("Directions");
                    mi.addActionListener(ae ->
                    JOptionPane.showMessageDialog(this,pane,
                     "MotionDetection",JOptionPane.PLAIN_MESSAGE));
                    help.addSeparator();
                }
            }
        } catch (IOException ioe) {
            JOptionPane.showMessageDialog(null,
             "Error occured loading directions file\n" + ioe,
             "MotionDetection",JOptionPane.WARNING_MESSAGE);
        }

        mi = help.add("About");
        mi.addActionListener(ae -> JOptionPane.showMessageDialog(this,
         "MotionDetection\n" + "Version: " + VERSION + "\nDate: " + DATE +
         "\nWritten by: Knute Johnson" + "\nContributers: Federico Pedemonte",
         "About",
         JOptionPane.INFORMATION_MESSAGE));

        imagePanel = new ImageJPanel();
        imagePanel.setPreferredSize(new Dimension(displayWidth,displayHeight));
        add(imagePanel,BorderLayout.CENTER);

        pack();
        setVisible(true);
    }

    /**
     * Starts the camera thread and the image processing thread
     */
    private void start() {
        runFlag = true;
        cameraThread.start();
        processorThread.start();
    }

    /**
     * The camera thread controls the process that runs the raspistill program.
     */
    @Override public void run() {
        System.out.println("camera thread started");
        while (runFlag) {
            try {
                ProcessManager.createHandledProcess(
                 "raspistill",
                 "--timeout","0",
                 "--nopreview",
                 "--timelapse",PROCESSORS == 4 ? "600" : "1000",
                 "--width",Integer.toString(captureWidth),
                 "--height",Integer.toString(captureHeight),
                 "--output",TXFER_DIR.getPath().concat("/%06d.jpg")).
                 waitFor();

                System.out.println("camera process stopped");

                // this delay is to give CaptureSizeAction time to get
                //  everything done before we start taking pictures again
                // on a call to stop() this thread is interrupted so this sleep
                //  will never happen
                Thread.sleep(1000);
            } catch (IOException|InterruptedException ex) {
                if (runFlag)
                    ex.printStackTrace();
            }
        }
        System.out.println("camera thread stopped");
    }

    /**
     * Read the latest image file from the ramdisk and process it for motion
     * detection.  The processing is split into 4 threads to speed things up.
     */
    private void processImage() {
        System.out.println("processor thread started");
        while (runFlag) {
            try {
                long procStartTime = System.currentTimeMillis();

                // get an array of the jpg files on the ramdisk
                File[] files = TXFER_DIR.listFiles(new FileFilter() {
                    public boolean accept(File file) {
                        return file.getName().endsWith(".jpg");
                    }
                });

                // find the most recent file by comparing file names
                Optional<File> mostRecentFile =
                 Arrays.stream(files).max(new Comparator<File>() {
                    public int compare(File f1,File f2) {
                        return -f1.getName().compareTo(f2.getName());
                    }
                });

                // if there are no files on the ramdisk, hesitate and continue
                if (!mostRecentFile.isPresent()) {
                    Thread.sleep(50);
                    continue;
                }

                if (Thread.interrupted())
                    throw new InterruptedException("before file read");

                // new is now old
                imageOld = imageNew;
                long readStartTime = System.currentTimeMillis();
                // read the most recent image from the ramdisk
                imageNew = ImageIO.read(mostRecentFile.get());
                long readStopTime = System.currentTimeMillis();
                if (timesFlag)
                    System.out.printf("image read time: %dms%n",
                     readStopTime - readStartTime);

                if (Thread.interrupted())
                    throw new InterruptedException("after file read");

                // delete all of the image files from the ramdisk
                for (File file : files)
                    file.delete();

                int boxWidth = imageNew.getWidth() / 100;
                int boxHeight = imageNew.getHeight() / 100;
                int width = imageNew.getWidth() / boxWidth;
                int height = imageNew.getHeight() / boxHeight;
                int boxes = width * height;

                // swap pointer for old brightness array
                brightOld = brightNew;
                // create new brightness array
                brightNew = new int[width][height];

                long scanStartTime = System.currentTimeMillis();
                // total up the brightness of all the pixels by box and take
                // the average - this very elegant piece of code contributed by
                // Federico Pedemonte
                IntStream.range(0, boxes).
                 parallel().forEach(box -> {
                    int row = box / width;
                    int col = box - (row * width);
                    int totalNew = 0;
                    for (int x = col * boxWidth; x < col * boxWidth + boxWidth;
                     x++) {
                        for (int y = row * boxHeight;
                         y < row * boxHeight + boxHeight; y++) {
                            int rgb = imageNew.getRGB(x, y);
                            totalNew += (rgb >> 32 & 0xff) +
                             (rgb >> 16 & 0xff) +
                             (rgb & 0xff);
                        }
                    }
                    brightNew[col][row] = totalNew / (boxWidth * boxHeight);
                });
                long scanStopTime = System.currentTimeMillis();
                if (timesFlag)
                    System.out.printf("image scan: %dms%n",
                     scanStopTime - scanStartTime);

                if (Thread.interrupted())
                    throw new InterruptedException("after scan");

                // if there isn't an old brightness array or the new and old
                // arrays have different dimensions, go back and get another
                // image
                if (brightOld == null ||
                 brightOld.length != brightNew.length ||
                 brightOld[0].length != brightNew[0].length)
                    continue;

                // search for boxes with average brightness greater than the
                // threshold and store them in the points list
                java.util.List<Point> points = new ArrayList<>();
                for (int i=0; i<width; i++)
                    for (int j=0; j<height; j++)
                        if (Math.abs(brightOld[i][j] - brightNew[i][j]) >
                          threshold / 100.0 * 765.0)
                            points.add(new Point(i,j));

                if (Thread.interrupted())
                    throw new InterruptedException("after points");

                // create a transparent color for the dot
                Color dotColor = new Color(0,0,0,0);
                // if the number of boxes over the threshold is > than minBoxes
                // set the dot color to a transparent green
                if (points.size() >= boxes * (minBoxes / 100.0) &&
                 points.size() <= boxes * (maxBoxes / 100.0)) {
                    dotColor = new Color(0,255,0,160);
                    // if the Save Image check box is checked, save the image
                    if (saveFile)
                        saveImage(imageNew,directory);
                    // if the Email Image check box is checked, email the image
                    if (emailImage)
                        sendImage(imageNew);
                // else if the number of boxes over the threshold is > than
                // maxBoxes set the dot color to a transparent red
                } else if (points.size() > boxes * (maxBoxes / 100.0)) {
                    dotColor = new Color(255,0,0,160);
                }

                // if the Show Composite check box is checked display the
                // composite image
                if (showComposite) {
                    BufferedImage compositeImage = createCompositeImage(
                     imageOld,imageNew,points,boxWidth,boxHeight);
                    imagePanel.setImage(compositeImage,dotColor);
                // otherwise display the latest image
                } else
                    imagePanel.setImage(imageNew,dotColor);

                long procStopTime = System.currentTimeMillis();
                if (timesFlag)
                    System.out.printf("image process time: %dms%n",
                     procStopTime - procStartTime);
            } catch (IOException ioe) {
                ioe.printStackTrace();
            } catch (InterruptedException ie) {
                System.out.printf("processor thread: %s%n",ie);
            }
        }
        System.out.println("processor thread stopped");
    }

    /**
     * Kill the raspistill program, stop the camera thread, the image
     * processing thread, delete any remaining files on the ramdisk, unmount
     * the ramdisk and remove it.
     */
    private void stop() {
        runFlag = false;
        try {
            cameraThread.interrupt();

            System.out.println("stopping raspistill process");
            ProcessManager.createHandledProcess(
             "sudo","pkill","-HUP","raspistill").waitFor();

            processorThread.interrupt();
            processorThread.join();

            System.out.println("deleting all files on the ramdisk");
            File[] files = TXFER_DIR.listFiles();
            for (File file : files)
                file.delete();

            System.out.println("unmounting the ramdisk");
            ProcessManager.createHandledProcess(
             "sudo","umount",TXFER_DIR.getPath()).waitFor();

            System.out.println("removing the ramdisk mount point");
            ProcessManager.createHandledProcess(
             "sudo","rmdir",TXFER_DIR.getPath()).waitFor();
        } catch (IOException|InterruptedException ex) {
            ex.printStackTrace();
        }
    }

    /**
     * Store the program properties to the INI file
     */
    private void storeProperties() {
        new Thread(() -> {
            System.out.println("storing properties");

            try (FileWriter writer = new FileWriter(new File(
             System.getProperty("user.home"),INI_FILE_NAME))) {
                properties.store(writer,"MotionDetection");
            } catch (IOException ioe) {
                ioe.printStackTrace();
            }
        }).start();
    }

    /**
     * Writes an image to the specified directory with the file name created
     * from the local date and time in a background thread.
     *
     * @param   image   the image to save
     * @param   directory   the directory to save that image file in
     */
    private void saveImage(BufferedImage image, File directory) {
        new Thread(() -> {
            long saveStartTime = System.currentTimeMillis();
            System.out.println("saving file");

            String fname = dateFormat.format(new Date()) + ".jpg";
            try {
                ImageIO.write(image,"JPEG",new File(directory,fname));
            } catch (IOException ioe) {
                ioe.printStackTrace();
            }

            long saveStopTime = System.currentTimeMillis();
            if (timesFlag)
                System.out.printf("save time: %dms%n",
                 saveStopTime - saveStartTime);
        }).start();
    }

    /**
     * Sends the captured image via email using the entered settings.
     *
     * @param   image   the image to send
     */
    private void sendImage(BufferedImage image) {
        new Thread(() -> {
            long sendStartTime = System.currentTimeMillis();
            System.out.println("sending email");

            try {
                Session session = Session.getInstance(
                 properties,new Auth(properties));
                MimeMessage mime = new MimeMessage(session);
                mime.setFrom(properties.getProperty("mail.from"));
                mime.setSubject(properties.getProperty("mail.subject"));
                mime.setSentDate(new Date());
                mime.setRecipients(Message.RecipientType.TO,
                 properties.getProperty("mail.to"));
                mime.setDataHandler(new DataHandler(
                 new BufferedImageSource(image)));
                Transport.send(mime);
            } catch (MessagingException|IOException ex) {
                ex.printStackTrace();
            }

            long sendStopTime = System.currentTimeMillis();
            if (timesFlag)
                System.out.printf("email time: %dms%n",
                 sendStopTime - sendStartTime);
        }).start();
    }

    /**
     * Action for the Capture Image Size JRadioMenuItems to set the capture
     * size and adjust the display aspect ratio appropriately for the image.
     */
    private class CaptureSizeAction extends AbstractAction {
        /**
         * Create a new CaptureSizeAction
         *
         * @param   label   the capture size label (eg. 640x480 (4x3))
         */
        public CaptureSizeAction(String label) {
            putValue(NAME,label);
        }

        /**
         * Called when one of the Capture Image Size radio buttons is clicked.
         * Stops the raspistill program process, interrupts the image
         * processing thread and updates the GUI presentation.
         *
         * @param   ae  the ActionEvent passed in from the JRadioMenuItem
         */
        @Override public void actionPerformed(ActionEvent ae) {
            String ac = ae.getActionCommand();

            // stop the raspistill program and delete any left over files
            try {
                ProcessManager.createHandledProcess(
                 "sudo","pkill","-TERM","raspistill").waitFor();

                File[] files = TXFER_DIR.listFiles();
                for (File file : files)
                    file.delete();

                processorThread.interrupt();
            } catch (IOException|InterruptedException ex) {
                ex.printStackTrace();
            }

            // eg. 640x480   (4x3)
            // split it on the spaces
            String[] split = ac.split("\\s+");
            System.out.println(split[0]);
            // split the size on the x
            String[] dims = split[0].split("x");
            captureWidth = Integer.parseInt(dims[0]);
            properties.setProperty("captureWidth",
             Integer.toString(captureWidth));
            captureHeight = Integer.parseInt(dims[1]);
            properties.setProperty("captureHeight",
             Integer.toString(captureHeight));
            aspectRatio = (double)captureWidth / captureHeight;
            displayHeight = (int)(displayWidth / aspectRatio);
            properties.setProperty("displayHeight",
             Integer.toString(displayHeight));
            storeProperties();
            imagePanel.setPreferredSize(new Dimension(
             displayWidth,displayHeight));
            imagePanel.setSize(imagePanel.getPreferredSize());
            pack();
        }
    }

    /**
     * Called when one of the Display Width radio buttons is clicked.  Sets the
     * dimensions of the image displayed in the program window to the selected
     * size.
     */
    private class DisplaySizeAction extends AbstractAction {
        /**
         * Creates a new DisplaySizeAction
         *
         * @param   label   the width label (eg. 800 or 1024)
         */
        public DisplaySizeAction(String label) {
            putValue(NAME,label);
        }

        /**
         * Called when the JRadioMenuItem is clicked.  Changes the program
         * display dimensions setting the height from the width and aspect
         * ratio of the capture image.
         *
         * @param   ae  the ActionEvent passed in from the JRadioMenuItem
         */
        @Override public void actionPerformed(ActionEvent ae) {
            String ac = ae.getActionCommand();

            displayWidth = Integer.parseInt(ac);
            properties.setProperty("displayWidth",
             Integer.toString(displayWidth));
            displayHeight = (int)(displayWidth / aspectRatio);
            properties.setProperty("displayHeight",
             Integer.toString(displayHeight));
            imagePanel.setPreferredSize(new Dimension(
             displayWidth,displayHeight));
            imagePanel.setSize(imagePanel.getPreferredSize());
            pack();
            storeProperties();

            System.out.printf("display: %dx%d\n",displayWidth,displayHeight);
        }
    }

    /**
     * A specilized JPanel used to display the latest captured image and draw
     * a dot in the upper right corner to signal a capture or an overload
     */
    private class ImageJPanel extends JPanel {
        /** Image to display */
        private BufferedImage image;

        /** Color of the dot to display in the upper right corner */
        private Color dotColor;

        /**
         * Create a new ImageJPanel with the specified image and dot color
         *
         * @param   image   image to display
         * @param   dotColor    color of the dot to draw in the upper right
         *                      corner
         */
        public void setImage(BufferedImage image, Color dotColor) {
            this.image = image;
            this.dotColor = dotColor;
            repaint();
        }

        /**
         * Performs the actual drawing of the image on the panel
         *
         * @param   g2D graphics context
         */
        @Override public void paintComponent(Graphics g2D) {
            if (image != null) {
                Graphics2D g = (Graphics2D)g2D;
                g.setRenderingHints(hints);

                g.drawImage(image,0,0,getWidth(),getHeight(),null);
                // draw the dot in the upper right corner
                g.setColor(dotColor);
                g.fillOval(getWidth()-50,20,30,30);
            }
        }
    }

    /**
     * Draws img2 and blue boxes specified by the List points onto img1
     *
     * @param   img1    the older image
     * @param   img2    the new image
     * @param   points  list of upper left corner points
     * @param   boxWidth    width of the boxes
     * @param   boxHeight   height of the boxes
     *
     * @return  the older image with the newer image and boxes drawn over it
     */
    private BufferedImage createCompositeImage(BufferedImage img1,
     BufferedImage img2, java.util.List<Point> points,int boxWidth,
     int boxHeight) {
        long compositeStartTime = System.currentTimeMillis();
        BufferedImage composite = new BufferedImage(img1.getWidth(),
         img1.getHeight(),img1.getType());
        Graphics2D g = composite.createGraphics();
        g.setRenderingHints(hints);
        g.drawImage(img1,0,0,null);
        Composite comp = g.getComposite();
        AlphaComposite aComp =
         AlphaComposite.getInstance(AlphaComposite.SRC_OVER,0.75f);
        g.setComposite(aComp);
        g.drawImage(img2,0,0,null);
        g.setComposite(comp);
        g.setColor(Color.BLUE);
        for (Point p : points)
            g.drawRect(p.x*boxWidth,p.y*boxHeight,boxWidth,boxHeight);
        g.dispose();
        long compositeStopTime = System.currentTimeMillis();
        if (timesFlag)
            System.out.printf("composite time: %dms%n",
             compositeStopTime - compositeStartTime);
        return composite;
    }

    /**
     * An authenticator class used to obtain user name a password for sending
     * email.
     */
    private class Auth extends javax.mail.Authenticator {
        /** Properties containing the mail.user and mail.passwd properties */
        private final Properties props;

        /**
         * Create a new Auth object with the specified properties.
         *
         * @param   props   the Properties that contain the mail.user and
         *              mail.passwd properties
         */
        public Auth(Properties props) {
            this.props = props;
        }

        /**
         * Gets a PasswordAuthentication object.
         *
         * @return  a PasswordAuthentication object that returns the user name
         *          and the de-obfuscated password
         */
        public PasswordAuthentication getPasswordAuthentication() {
            return new PasswordAuthentication(props.getProperty("mail.user"),
             rotateLeft(props.getProperty("mail.passwd"),ROT));
        }
    }

    /**
     * A DataSource to prepare a BufferedImage for emailing.
     */
    private class BufferedImageSource implements DataSource {
        /** A temporary place to hold the data from the BufferedImage */
        private final ByteArrayOutputStream baos;

        /**
         * Creates a new BufferedImageSource with the specified BufferedImage
         *
         * @param   image the BufferedImage to source
         *
         * @throws  IOException if an error occurs writing the image to the
         *          temporary ByteArrayOutputStream
         */
        public BufferedImageSource(BufferedImage image) throws
         IOException {
            baos = new ByteArrayOutputStream();
            ImageIO.write(image,"JPEG",baos);
        }

        /**
         * Gets the content type of the converted image, in this case image/jpeg
         *
         * @return  a String with the mime type of the data source
         */
        @Override public String getContentType() {
            return "image/jpeg";
        }

        /**
         * Gets an InputStream from which to read the image data
         *
         * @return  the InputStream containing the image data
         */
        @Override public InputStream getInputStream() {
            return new ByteArrayInputStream(baos.toByteArray());
        }

        /**
         * Gets the name of the data, usually a file name but in this case a
         * generic file name of "image.jpg".
         *
         * @return  the String containing the name of the data
         */
        @Override public String getName() {
            return "image.jpg";
        }

        /**
         * Get the OutputStream associated with this data source, in this case
         * there is no OutputStream and calling this method just throws an
         * IOException.
         *
         * @return  the OutputStream for the data source
         *
         * @throws  IOException if this method is called
         */
        @Override public OutputStream getOutputStream() throws IOException {
            throw new IOException("no output stream available");
        }
    }

    /**
     * A JPanel containing the GUI components to input email settings.
     */
    private class EmailSettings extends JPanel {
        /** Properties object that holds the email settings */
        private final Properties properties;

        /** JTextField to input the email server host address */
        private final JTextField hostField;

        /** JTextfield to input the email server port */
        private final JTextField portField;

        /** JCheckbox to enable authentication with the email server */
        private final JCheckBox authBox;

        /** JTextField to input the email user name */
        private final JTextField userField;

        /** JPasswordField to input the email user's password */
        private final JPasswordField passwdField;

        /** JCheckBox to enable StartTLS communication with the email server */
        private final JCheckBox starttlsBox;

        /** JTextField to input the enabled SSL protocols */
        private final JTextField protocolsField;

        /** JCheckbox to enable JavaMail debug messages to be displayed */
        private final JCheckBox debugBox;

        /** JTextField for the sender's email address */
        private final JTextField fromField;

        /** JTextField for the receiver's email address */
        private final JTextField toField;

        /** JTextField for the subject of the email */
        private final JTextField subjectField;

        /**
         * Create a new EmailSettings JPanel with the specified Properties.
         *
         * @param   properties the Properties containing the email settings
         */
        public EmailSettings(Properties properties) {
            super(new GridBagLayout());

            this.properties = properties;

            GridBagConstraints c = new GridBagConstraints();
            c.gridy = 0;  c.insets = new Insets(1,2,2,1);
            c.anchor = GridBagConstraints.WEST;
            c.fill = GridBagConstraints.HORIZONTAL;

            JLabel l = new JLabel("From:");
            add(l,c);

            fromField = new JTextField(
             properties.getProperty("mail.from",""),16);
            add(fromField,c);

            ++c.gridy;
            l = new JLabel("To:");
            add(l,c);

            toField = new JTextField(properties.getProperty("mail.to",""),16);
            add(toField,c);

            ++c.gridy;
            l = new JLabel("Subject:");
            add(l,c);

            subjectField = new JTextField(
             properties.getProperty("mail.subject","MotionDetection"),16);
            add(subjectField,c);

            ++c.gridy;
            l = new JLabel("Host:");
            add(l,c);

            hostField = new JTextField(
             properties.getProperty("mail.smtp.host",""),16);
            add(hostField,c);

            ++c.gridy;
            l = new JLabel("Port:");
            add(l,c);

            c.fill = GridBagConstraints.NONE;
            portField = new JTextField(
             properties.getProperty("mail.smtp.port","25"),4);
            add(portField,c);

            ++c.gridy;  c.fill = GridBagConstraints.HORIZONTAL;
            l = new JLabel("Use Auth:");
            add(l,c);

            authBox = new JCheckBox("",Boolean.parseBoolean(
             properties.getProperty("mail.smtp.auth","false")));
            authBox.setBorder(BorderFactory.createEmptyBorder());
            add(authBox,c);

            ++c.gridy;
            l = new JLabel("User:");
            add(l,c);

            userField = new JTextField(
             properties.getProperty("mail.user",""),16);
            add(userField,c);

            ++c.gridy;
            l = new JLabel("Password:");
            add(l,c);

            passwdField = new JPasswordField(
             rotateLeft(properties.getProperty("mail.passwd",""),ROT),16);
            add(passwdField,c);

            ++c.gridy;
            l = new JLabel("StartTTLS:");
            add(l,c);

            starttlsBox = new JCheckBox("",Boolean.parseBoolean(
             properties.getProperty("mail.smtp.starttls.enable","false")));
            starttlsBox.setBorder(BorderFactory.createEmptyBorder());
            add(starttlsBox,c);

            // get the supported SSL protocols
            String protocols;
            SSLServerSocketFactory factory =
             (SSLServerSocketFactory)SSLServerSocketFactory.getDefault();
            try (SSLServerSocket serverSocket =
             (SSLServerSocket)factory.createServerSocket()) {
                protocols = Arrays.
                 stream(serverSocket.getSupportedProtocols()).
                 collect(joining(" "));
            } catch (IOException ioe) {
                protocols = "";
            }

            ++c.gridy;
            l = new JLabel("Protocols:");
            add(l,c);

            protocolsField = new JTextField(properties.getProperty(
             "mail.smtp.ssl.protocols",protocols),16);
            protocolsField.setToolTipText(protocols);
            add(protocolsField,c);

            ++c.gridy;
            l = new JLabel("Debug:");
            add(l,c);

            debugBox = new JCheckBox("",Boolean.parseBoolean(
             properties.getProperty("mail.debug","false")));
            debugBox.setBorder(BorderFactory.createEmptyBorder());
            add(debugBox,c);
        }

        /**
         * Method to extract the new email settings from the GUI and store them
         * in the passed in Properties.
         */
        public void updateProperties() {
            properties.setProperty("mail.from",fromField.getText());
            properties.setProperty("mail.to",toField.getText());
            properties.setProperty("mail.subject",subjectField.getText());
            properties.setProperty("mail.smtp.host",hostField.getText());
            properties.setProperty("mail.smtp.port",portField.getText());
            properties.setProperty("mail.smtp.auth",
             Boolean.toString(authBox.isSelected()));
            properties.setProperty("mail.user",userField.getText());
            properties.setProperty("mail.passwd",
             rotateRight(new String(passwdField.getPassword()),ROT));
            properties.setProperty("mail.smtp.starttls.enable",
             Boolean.toString(starttlsBox.isSelected()));
            properties.setProperty("mail.smtp.ssl.protocols",
             protocolsField.getText());
            properties.setProperty("mail.debug",
             Boolean.toString(debugBox.isSelected()));
        }
    }

    /**
     * A method to obfuscate String data by rotating the bits in each character
     * of the String to the left.  Used to obfuscate the user's password.
     *
     * @param   str the String to be obfuscated
     * @param   shift the number of bits to rotate in the character
     *
     * @return  the left rotated/obfuscated String
     */
    private String rotateLeft(String str, int shift) {
        byte[] bytes = str.getBytes(StandardCharsets.UTF_8);

        for (int i=0; i<bytes.length; i++)
            bytes[i] = rotl7(bytes[i],shift);

        return new String(bytes,StandardCharsets.UTF_8);
    }

    /**
     * A method to obfuscate String data by rotating the bits in each character
     * of the String to the right.  Used to obfuscate the user's password.
     *
     * @param   str the String to be obfuscated
     * @param   shift the number of bits to rotate in the character
     *
     * @return  the right rotated/obfuscated String
     */
    private String rotateRight(String str, int shift) {
        byte[] bytes = str.getBytes(StandardCharsets.UTF_8);

        for (int i=0; i<bytes.length; i++)
            bytes[i] = rotr7(bytes[i],shift);

        return new String(bytes,StandardCharsets.UTF_8);
    }

    /**
     * Method to rotate the seven lower order bits of a byte by the specified
     * number of bits to the left.
     *
     * @param   b the byte to rotate
     * @param   shift the number of bits to rotate
     *
     * @return  the byte with the lower order bits rotated left
     *
     * @throws  IllegalArgumentException if the shift value is negative
     */
    private byte rotl7(byte b, int shift) {
        if (shift < 0)
            throw new IllegalArgumentException("negative shift");

        shift %= 7;

        int i = b & 0x7f;

        return (byte)(((i << shift) & 0x7f) | (i >>> (7 - shift)));
    }

    /**
     * Method to rotate the seven lower order bits of a byte by the specified
     * number of bits to the right.
     *
     * @param   b the byte to rotate
     * @param   shift the number of bits to rotate
     *
     * @return  the byte with the lower order bits rotated right
     *
     * @throws  IllegalArgumentException if the shift value is negative
     */
    private byte rotr7(byte b, int shift) {
        if (shift < 0)
            throw new IllegalArgumentException("negative shift");

        shift %= 7;

        int i = b & 0x7f;

        return (byte)(((i >>> shift) | ((i << (7 - shift)))) & 0x7f);
    }

    /**
     * Main program entry point
     *
     * @param   args    command line arguments (not used)
     */
    public static void main(String... args) {
        for (String arg : args)
            if (arg.equalsIgnoreCase("times"))
                timesFlag = true;

        try {
            // if the transfer directory mount point doesn't exist
            if (!(TXFER_DIR.exists() && TXFER_DIR.isDirectory())) {
                ProcessManager mgr = ProcessManager.createHandledProcess(
                 "sudo","mkdir",TXFER_DIR.getPath());

                if (mgr.waitFor() == 0) {
                    System.out.println("ramdisk mount point created");
                } else {
                    System.out.println(
                     "fatal error - failed to create ramdisk mount point");
                    System.exit(0);
                }
            }

            // create ramdisk
            ProcessManager mgr = ProcessManager.createHandledProcess(
             "sudo","mount","-t","tmpfs","-o","size=64m","tmpfs",
             TXFER_DIR.getPath());

            if (mgr.waitFor() == 0) {
                System.out.println("ramdisk created successfully");
            } else {
                System.out.println("fatal error - failed to create ramdisk");
                System.exit(0);
            }

            // delete any left over files on the ramdisk
            File[] files = TXFER_DIR.listFiles();
            for (File file : files)
                file.delete();

            // start the program
            EventQueue.invokeLater(() -> new MotionDetection());
        } catch (IOException|InterruptedException ex) {
            ex.printStackTrace();
        }
    }

    /**
     * ProcessManager is a class that combines the features of ProcessBuilder
     * and Process and adds methods to handle the input and error streams.
     *
     * This is an abbreviated version of this class used to simplify the code
     * used in several places in the MotionDetection program.
     */
    private static class ProcessManager {
        /** ProcessBuilder */
        private final ProcessBuilder builder;

        /** Process */
        private volatile Process process;

        /**
         * Creates a new ProcessManager with the specified commands
         *
         * @param   command a string array containing the program name and its
         *          arguments
         */
        public ProcessManager(String... command) {
            builder = new ProcessBuilder(command);
        }

        /**
         * Creates a ProcessManager with the error stream redirected to the
         * input stream and starts that manager.
         *
         * @param   command a string array containing the program name and its
         *          arguments
         *
         * @return  the created ProcessManager
         *
         * @throws  IOException if an error occurs in starting the process
         */
        public static ProcessManager createHandledProcess(String... command)
         throws IOException {
            ProcessManager manager = new ProcessManager(command);
            manager.redirectErrorStream(true).start();
            manager.handleInput();

            return manager;
        }

        /**
         * Sets the redirectErrorStream property, if true stdout and stderr
         * from this process will be merged.
         *
         * @param   redirect    the new property value
         *
         * @return  the ProcessBuilder of this manager
         */
        public ProcessManager redirectErrorStream(boolean redirect) {
            builder.redirectErrorStream(redirect);

            return this;
        }

        /**
         * Creates a thread to read the output of the Process and send it to
         * standard output.
         *
         * @throws  IllegalStateException if the process associated with this
         *          ProcessManager has not yet been created
         */
        public void handleInput() {
            if (process == null)
                throw new IllegalStateException("process not yet created");

            new Thread(() -> {
                try (BufferedReader reader = new BufferedReader(
                     new InputStreamReader(process.getInputStream()))) {
                    String str;
                    while ((str = reader.readLine()) != null)
                        System.out.println(str);
                } catch (IOException ioe) {
                    System.out.printf("handleInput: %s%n",ioe);
                }
            },"ProcessManager handleInput").start();
        }

        /**
         * Starts the process using the commands set by the constructor.
         *
         * @return  the Process
         *
         * @throws  IOException if an I/O error occurs
         * @throws  NullPointerException if the command list is null
         * @throws  IndexOutOfBoundsException if the command list is empty
         */
        public Process start() throws IOException {
            process = builder.start();

            return process;
        }

        /**
         * Causes the current thread to wait until the process has terminated.
         *
         * @return  the exit value of the process
         *
         * @throws  InterruptedException if the current thread is interrupted
         */
        public int waitFor() throws InterruptedException {
            if (process == null)
                throw new IllegalStateException("process not yet created");

            return process.waitFor();
        }
    }
}