package com.knutejohnson.pi;

import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.awt.image.*;
import java.io.*;
import java.nio.charset.*;
import java.time.*;
import java.time.format.*;
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
 * dividing 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 the same area on the two 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 or emailed.  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 size of the
 * image displayed 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 class="striped" style="border: 1px solid black; border-collapse: collapse; padding: 4px;">
 * <caption>Program Revisions</caption>
 * <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
 * <tr><td>0.71.0<td>10 Jun 2017<td>make the dot colors static variables and clean up the ramdisk file deletion code
 * <tr><td>0.72.0<td>19 Feb 2018<td>rewrite sendImage() to use a multipart message and replace several for loops with streams
 * <tr><td>0.73.0<td>07 Apr 2018<td>make some minor speed improvements in the processImage method
 * <tr><td>0.74.0<td>08 Apr 2018<td>improve timing code for testing
 * <tr><td>0.75.0<td>17 Apr 2018<td>add option to time stamp the images
 * <tr><td>0.76.0<td>18 Apr 2018<td>queue images for emailing rather than running multiple emailling threads simultaneously and add the milliseconds to image file names to prevent images being overwritten when more than one was taken in a given second
 * <tr><td>0.77.0<td>19 Apr 2018<td>modify time stamp code to draw in white or black depending on the brightness of the image
 * <tr><td>0.77.1<td>19 Apr 2018<td>update directions for the time stamp option
 * <tr><td>0.78.0<td>19 Apr 2018<td>change the time stamp code to adjust the font for the width of the image instead of the height
 * <tr><td>0.78.1<td>20 Apr 2018<td>rearrange the capture image sizes by aspect ratio and make minor comment and variable changes
 * <tr><td>0.78.2<td>21 Apr 2018<td>update program description in the javadocs and in the directions
 * <tr><td>0.78.3<td>21 Apr 2018<td>make a small optimization in the processImage method when testing for points
 * <tr><td>0.78.4<td>21 Apr 2018<td>minor changes to the stop method
 * <tr><td>0.80.0<td>23 Apr 2018<td>raspistill now writes to one file on the ramedisk, the processImage method has been changed to read the one file and then delete it, this simplifies the image handling and reduces the storage requirements on the ramdisk, in the createComposite method use the old image as the drawing surface instead of creating a new BufferedImage
 * <tr><td>0.81.0<td>24 Apr 2018<td>fix a bug I introduced into the createComposite method with version 0.80
 * </table>
 *
 * @author  Knute Johnson
 * @version 0.81.0 - 24 April 2018
 */
public class MotionDetection extends JFrame implements Runnable {
    /** Program version */
    public static final String VERSION = "0.81.0";

    /** Program date */
    public static final String DATE = "24 April 2018";

    /** Capture size labels */
    private static final String[] CAPTURE_SIZE_LABELS = {
     "640x480     (4x3)",
     "720x540     (4x3)",
     "800x600     (4x3)",
     "1024x768   (4x3)",
     "1366x1024 (4x3)",
     "1600x1200 (4x3)",
     "1920x1440 (4x3)",
     "2592x1944 (4x3)",
     "3280x2464 (4x3)",
     "600x400     (3x2)",
     "720x480     (3x2)",
     "900x600     (3x2)",
     "1200x800   (3x2)",
     "1350x900   (3x2)",
     "1500x1000 (3x2)",
     "1800x1200 (3x2)",
     "2592x1728 (3x2)",
     "3210x2140 (3x2)",
     "640x360     (16x9)",
     "720x405     (16x9)",
     "800x450     (16x9)",
     "1024x576   (16x9)",
     "1366x768   (16x9)",
     "1600x900   (16x9)",
     "1920x1080 (16x9)",
     "2592x1458 (16x9)",
     "3280x1845 (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";

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

    private static final File IMAGE_FILE = new File(TXFER_DIR,"image.jpg");

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

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

    /** Transparent black color for dot */
    private static final Color TRANSPARENT_BLACK = new Color(0,0,0,0);

    /** Translucent red color for dot */
    private static final Color TRANSLUCENT_RED = new Color(255,0,0,160);

    /** Translucent green color for dot */
    private static final Color TRANSLUCENT_GREEN = new Color(0,255,0,160);

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

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

    /** Date format for saved image files */
    private static final DateTimeFormatter IMAGE_FILE_FORMATTER =
     DateTimeFormatter.ofPattern(DATE_PATTERN);

    /** Date patter for image time stamps */
    private static final String TIME_STAMP_PATTERN = "yyyy MM dd HH:mm:ss zzz";

    /** Date format for image time stamps */
    private static final DateTimeFormatter TIME_STAMP_FORMATTER =
     DateTimeFormatter.ofPattern(TIME_STAMP_PATTERN);

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

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

    /** Email thread */
    private final Thread emailThread;

    /** 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 images are to be time stamped */
    private volatile boolean timeStampImage;

    /** Default time stamp font */
    private static final Font timeStampFont =
     new Font(Font.SANS_SERIF,Font.PLAIN,14);

    /** Alpha composite used to create composite image for the display */
    private static final AlphaComposite ALPHA_COMP =
         AlphaComposite.getInstance(AlphaComposite.SRC_OVER,0.60f);

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

    /** Queue for images to be emailed */
    private final BlockingQueue<BufferedImage> imageQueue =
     new LinkedBlockingQueue<>();

    /** Timing data map keys, used for testing */
    private enum Keys { PROC,READ,SCAN,STMP,SAVE,SEND,COMP };

    /** Timing data map, used for testing */
    private final Map<Keys,java.util.List<Long>> timingMap = new HashMap<>();

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

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

        // create the list used to hold the timing values and store them with
        // the appropriate key in the timing hash map
        if (timesFlag) {
            for (Keys key : Keys.values())
                timingMap.put(key,new ArrayList<Long>());
        }

        // 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)));
        timeStampImage = Boolean.parseBoolean(properties.getProperty(
         "timeStampImage",Boolean.toString(timeStampImage)));
        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";
             }
        });

        // 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));
//        hints.add(new RenderingHints(RenderingHints.KEY_COLOR_RENDERING,
//         RenderingHints.VALUE_RENDER_QUALITY));

        cameraThread = new Thread(this,"Camera Thread");
        processorThread = new Thread(() -> processImage(),"Processor Thread");
        emailThread = new Thread(() -> emailImage(),"Email 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();
                if (timesFlag)
                    timingMap.forEach((key,value) -> {
                        LongSummaryStatistics lss = value.stream().
                         collect(summarizingLong(l -> l));

                        if (lss.getCount() > 0)
                            System.out.printf(
                             "%s : count=%d min=%d avg=%.1f max=%d%n",key,
                             lss.getCount(),lss.getMin(),lss.getAverage(),
                             lss.getMax());
                    });
            }
        });

        // 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().concat("\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);

        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 = 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();
            }
        });

        mi = settings.add(new JCheckBoxMenuItem("Time Stamp Image",
         timeStampImage));
        mi.addActionListener(ae -> {
            timeStampImage = ((JCheckBoxMenuItem)ae.getSource()).isSelected();
            properties.setProperty("timeStampImage",
             Boolean.toString(timeStampImage));
            storeProperties();
        });

        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,300));
                    pane.getVerticalScrollBar().setUnitIncrement(10);
                    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();
        emailThread.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 ? "650" : "1000",
                 "--width",Integer.toString(captureWidth),
                 "--height",Integer.toString(captureHeight),
                 "--output",IMAGE_FILE.getPath()).
                 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");
        int t = 0;
        while (runFlag) {
            try {
                long procStartTime = System.currentTimeMillis();

                // new is now old
                imageOld = imageNew;

                // if the image file doesn't exist, pause for 50ms
                while (!IMAGE_FILE.exists())
                    Thread.sleep(50);

                long readStartTime = System.currentTimeMillis();
                // read the image file
                imageNew = ImageIO.read(IMAGE_FILE);
                long readStopTime = System.currentTimeMillis();
                if (timesFlag) {
                    System.out.printf("image read time: %dms%n",
                     readStopTime - readStartTime);
                    timingMap.get(Keys.READ).add(readStopTime - readStartTime);
                }

                // delete the image file
                IMAGE_FILE.delete();

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

                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
//                if (PROCESSORS > 1) {
                    IntStream.range(0,boxes).parallel().forEach(box -> {
                        int row = box / width;
                        // faster than box % width
                        int col = box - (row * width);

                        int[] array = imageNew.getRGB(col*boxWidth,
                         row*boxHeight,boxWidth,boxHeight,
                         new int[boxWidth*boxHeight],0,boxWidth);

                        int totalNew = IntStream.of(array).map(rgb ->
                         (rgb >> 32 & 0xff)+(rgb >> 16 & 0xff)+(rgb & 0xff)).
                         sum();

                        brightNew[col][row] = totalNew / (boxWidth * boxHeight);
                    });
                    /*
                } else {
                    for (int box=0; box<boxes; box++) {
                        int row = box / width;
                        // faster than box % width
                        int col = box - (row * width);
    
                        int[] array = imageNew.getRGB(col*boxWidth,
                         row*boxHeight,boxWidth,boxHeight,
                         new int[boxWidth*boxHeight],0,boxWidth);

                        int totalNew = IntStream.of(array).map(rgb -> 
                         (rgb >> 32 & 0xff)+(rgb >> 16 & 0xff)+(rgb & 0xff)).
                         sum();
    
                        brightNew[col][row] = totalNew / (boxWidth * boxHeight);
                    };
                }
                */
                long scanStopTime = System.currentTimeMillis();
                if (timesFlag) {
                    System.out.printf("image scan: %dms%n",
                     scanStopTime - scanStartTime);
                    timingMap.get(Keys.SCAN).add(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 is a percentage
                          // this is faster than threshold / 100.0 * 765.0
                          threshold * 7.65)
                            points.add(new Point(i,j));

                // create a transparent color for the dot
                Color dotColor;
                // 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 = TRANSLUCENT_GREEN;
                    // 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)
                        imageQueue.put(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 = TRANSLUCENT_RED;
                } else
                    dotColor = TRANSPARENT_BLACK;

                // if the Time Stamp check box is checked draw the current
                // date and time on the image
                if (timeStampImage) {
                    long timeStampStartTime = System.currentTimeMillis();
                    // font multiplier for images wider than 640 pixels
                    float multiplier = imageNew.getWidth() / 640.0f;
                    Font font = timeStampFont.deriveFont(
                     timeStampFont.getSize2D() * multiplier);
                    Graphics2D g = imageNew.createGraphics();
                    g.setRenderingHints(hints);
                    g.setFont(font);
                    // get the average brightness of the area underneath where
                    // we are going to write the time stamp
                    FontMetrics fm = g.getFontMetrics();
                    int w = fm.stringWidth(TIME_STAMP_PATTERN);
                    int y = fm.getHeight();
                    int x = (int)(10 * multiplier);
                    int[] pixels = imageNew.getRGB(x,y,w,y,new int[w*y],0,w);
                    try {
                        OptionalDouble avg = IntStream.of(pixels).
                         map(p -> (p >> 32 & 0xff) + (p >> 16 & 0xff) +
                         (p & 0xff)).average();
                        // if the average color is brighter than middle gray
                        // then set the color to black else white
                        g.setColor(avg.getAsDouble() > 384.0 ?
                         Color.BLACK : Color.WHITE);
                    } catch (NoSuchElementException nse) {
                        g.setColor(Color.WHITE);
                    }
                    g.drawString(ZonedDateTime.now().
                     format(TIME_STAMP_FORMATTER),x,y);
                    g.dispose();

                    long timeStampStopTime = System.currentTimeMillis();
                    if (timesFlag) {
                        System.out.printf("image time stamp time: %dms%n",
                         timeStampStopTime - timeStampStartTime);
                        timingMap.get(Keys.STMP).add(
                         timeStampStopTime - timeStampStartTime);
                    }
                }

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

                // 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);
                    timingMap.get(Keys.PROC).add(procStopTime - procStartTime);
                }
            } catch (IOException|InterruptedException ex) {
                System.out.printf("processor thread: %s%n",ex);
            }
        }
        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();
            emailThread.interrupt();

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

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

            // unmount the ramdisk
            System.out.println("unmounting the ramdisk");
            ProcessManager.createHandledProcess(
             "sudo","umount",TXFER_DIR.getPath()).waitFor();
            // remove the ramdisk mount point
            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 = LocalDateTime.now().format(IMAGE_FILE_FORMATTER).
             concat(".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);
                timingMap.get(Keys.SAVE).add(saveStopTime - saveStartTime);
            }
        }).start();
    }

    /**
     * Sends the captured image(s) via email using the entered settings.
     */
    private void emailImage() {
        while (runFlag) {
            try {
                // get an image from the queue
                BufferedImage image = imageQueue.take();
                long sendStartTime = System.currentTimeMillis();
                System.out.println("emailling image(s)");

                int count = 0;
                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"));

                    MimeMultipart multi = new MimeMultipart();

                    MimeBodyPart textPart = new MimeBodyPart();
                    textPart.setContent(ZonedDateTime.now().
                     format(TIME_STAMP_FORMATTER),"text/plain");
                    textPart.setDisposition(Part.INLINE);
                    multi.addBodyPart(textPart);

                    // attach the image to the email
                    do {
                        MimeBodyPart imagePart = new MimeBodyPart();
                        imagePart.setDataHandler(new DataHandler(
                         new BufferedImageSource(image)));
                        imagePart.setFileName(LocalDateTime.now().
                         format(IMAGE_FILE_FORMATTER).concat(".jpg"));
                        imagePart.setDisposition(Part.ATTACHMENT);
                        multi.addBodyPart(imagePart);
                        ++count;
                    // repeat while there up to 5 times if there are more
                    // images in the queue
                    } while (count < 5 && (image = imageQueue.poll()) != null) ;

                    mime.setContent(multi);

                    Transport.send(mime);
                } catch (SendFailedException sfe) {
                    System.out.printf(
                     "message could not be sent to some/all recipients: %s%n",
                     sfe);
                } catch (MessagingException|IOException ex) {
                    ex.printStackTrace();
                }
                System.out.printf("%d image(s) emailed%n",count);

                long sendStopTime = System.currentTimeMillis();
                if (timesFlag) {
                    System.out.printf("email time: %dms%n",
                     sendStopTime - sendStartTime);
                    timingMap.get(Keys.SEND).add(sendStopTime - sendStartTime);
                }
            } catch (InterruptedException ie) {
                if (runFlag)
                    ie.printStackTrace();
            }
        }
        System.out.println("email thread stopped");
    }

    /**
     * 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, deletes all the files on the
         * ram disk, 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();

                Stream.of(TXFER_DIR.listFiles()).forEach(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 synchronized 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 in pixels
     * @param   boxHeight   height of the boxes in pixels
     *
     * @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();
        g.setComposite(ALPHA_COMP);
        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);
            timingMap.get(Keys.COMP).add(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) {
        timesFlag = Stream.of(args).anyMatch(a -> a.equalsIgnoreCase("times"));

        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=16m","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);
            }

            // create the program GUI
            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();
        }
    }
}