001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.geoimage;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.BorderLayout;
008import java.awt.Cursor;
009import java.awt.Dimension;
010import java.awt.FlowLayout;
011import java.awt.GraphicsEnvironment;
012import java.awt.GridBagConstraints;
013import java.awt.GridBagLayout;
014import java.awt.event.ActionEvent;
015import java.awt.event.ActionListener;
016import java.awt.event.FocusEvent;
017import java.awt.event.FocusListener;
018import java.awt.event.ItemEvent;
019import java.awt.event.ItemListener;
020import java.awt.event.WindowAdapter;
021import java.awt.event.WindowEvent;
022import java.io.File;
023import java.io.FileInputStream;
024import java.io.IOException;
025import java.io.InputStream;
026import java.text.DateFormat;
027import java.text.ParseException;
028import java.text.SimpleDateFormat;
029import java.util.ArrayList;
030import java.util.Collection;
031import java.util.Collections;
032import java.util.Comparator;
033import java.util.Date;
034import java.util.Dictionary;
035import java.util.Hashtable;
036import java.util.List;
037import java.util.Locale;
038import java.util.Objects;
039import java.util.TimeZone;
040import java.util.zip.GZIPInputStream;
041
042import javax.swing.AbstractAction;
043import javax.swing.AbstractListModel;
044import javax.swing.BorderFactory;
045import javax.swing.JButton;
046import javax.swing.JCheckBox;
047import javax.swing.JFileChooser;
048import javax.swing.JLabel;
049import javax.swing.JList;
050import javax.swing.JOptionPane;
051import javax.swing.JPanel;
052import javax.swing.JScrollPane;
053import javax.swing.JSeparator;
054import javax.swing.JSlider;
055import javax.swing.ListSelectionModel;
056import javax.swing.MutableComboBoxModel;
057import javax.swing.SwingConstants;
058import javax.swing.event.ChangeEvent;
059import javax.swing.event.ChangeListener;
060import javax.swing.event.DocumentEvent;
061import javax.swing.event.DocumentListener;
062import javax.swing.event.ListSelectionEvent;
063import javax.swing.event.ListSelectionListener;
064import javax.swing.filechooser.FileFilter;
065
066import org.openstreetmap.josm.Main;
067import org.openstreetmap.josm.actions.DiskAccessAction;
068import org.openstreetmap.josm.data.gpx.GpxConstants;
069import org.openstreetmap.josm.data.gpx.GpxData;
070import org.openstreetmap.josm.data.gpx.GpxTrack;
071import org.openstreetmap.josm.data.gpx.GpxTrackSegment;
072import org.openstreetmap.josm.data.gpx.WayPoint;
073import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
074import org.openstreetmap.josm.gui.ExtendedDialog;
075import org.openstreetmap.josm.gui.layer.GpxLayer;
076import org.openstreetmap.josm.gui.layer.Layer;
077import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
078import org.openstreetmap.josm.gui.widgets.JosmComboBox;
079import org.openstreetmap.josm.gui.widgets.JosmTextField;
080import org.openstreetmap.josm.io.GpxReader;
081import org.openstreetmap.josm.io.JpgImporter;
082import org.openstreetmap.josm.tools.ExifReader;
083import org.openstreetmap.josm.tools.GBC;
084import org.openstreetmap.josm.tools.ImageProvider;
085import org.openstreetmap.josm.tools.Pair;
086import org.openstreetmap.josm.tools.Utils;
087import org.openstreetmap.josm.tools.date.DateUtils;
088import org.xml.sax.SAXException;
089
090/**
091 * This class displays the window to select the GPX file and the offset (timezone + delta).
092 * Then it correlates the images of the layer with that GPX file.
093 */
094public class CorrelateGpxWithImages extends AbstractAction {
095
096    private static List<GpxData> loadedGpxData = new ArrayList<>();
097
098    private final transient GeoImageLayer yLayer;
099    private transient Timezone timezone;
100    private transient Offset delta;
101
102    /**
103     * Constructs a new {@code CorrelateGpxWithImages} action.
104     * @param layer The image layer
105     */
106    public CorrelateGpxWithImages(GeoImageLayer layer) {
107        super(tr("Correlate to GPX"), ImageProvider.get("dialogs/geoimage/gpx2img"));
108        this.yLayer = layer;
109    }
110
111    private final class SyncDialogWindowListener extends WindowAdapter {
112        private static final int CANCEL = -1;
113        private static final int DONE = 0;
114        private static final int AGAIN = 1;
115        private static final int NOTHING = 2;
116
117        private int checkAndSave() {
118            if (syncDialog.isVisible())
119                // nothing happened: JOSM was minimized or similar
120                return NOTHING;
121            int answer = syncDialog.getValue();
122            if (answer != 1)
123                return CANCEL;
124
125            // Parse values again, to display an error if the format is not recognized
126            try {
127                timezone = Timezone.parseTimezone(tfTimezone.getText().trim());
128            } catch (ParseException e) {
129                JOptionPane.showMessageDialog(Main.parent, e.getMessage(),
130                        tr("Invalid timezone"), JOptionPane.ERROR_MESSAGE);
131                return AGAIN;
132            }
133
134            try {
135                delta = Offset.parseOffset(tfOffset.getText().trim());
136            } catch (ParseException e) {
137                JOptionPane.showMessageDialog(Main.parent, e.getMessage(),
138                        tr("Invalid offset"), JOptionPane.ERROR_MESSAGE);
139                return AGAIN;
140            }
141
142            if (lastNumMatched == 0 && new ExtendedDialog(
143                        Main.parent,
144                        tr("Correlate images with GPX track"),
145                        new String[] {tr("OK"), tr("Try Again")}).
146                        setContent(tr("No images could be matched!")).
147                        setButtonIcons(new String[] {"ok", "dialogs/refresh"}).
148                        showDialog().getValue() == 2)
149                return AGAIN;
150            return DONE;
151        }
152
153        @Override
154        public void windowDeactivated(WindowEvent e) {
155            int result = checkAndSave();
156            switch (result) {
157            case NOTHING:
158                break;
159            case CANCEL:
160                if (yLayer != null) {
161                    if (yLayer.data != null) {
162                        for (ImageEntry ie : yLayer.data) {
163                            ie.discardTmp();
164                        }
165                    }
166                    yLayer.updateBufferAndRepaint();
167                }
168                break;
169            case AGAIN:
170                actionPerformed(null);
171                break;
172            case DONE:
173                Main.pref.put("geoimage.timezone", timezone.formatTimezone());
174                Main.pref.put("geoimage.delta", delta.formatOffset());
175                Main.pref.put("geoimage.showThumbs", yLayer.useThumbs);
176
177                yLayer.useThumbs = cbShowThumbs.isSelected();
178                yLayer.startLoadThumbs();
179
180                // Search whether an other layer has yet defined some bounding box.
181                // If none, we'll zoom to the bounding box of the layer with the photos.
182                boolean boundingBoxedLayerFound = false;
183                for (Layer l: Main.map.mapView.getAllLayers()) {
184                    if (l != yLayer) {
185                        BoundingXYVisitor bbox = new BoundingXYVisitor();
186                        l.visitBoundingBox(bbox);
187                        if (bbox.getBounds() != null) {
188                            boundingBoxedLayerFound = true;
189                            break;
190                        }
191                    }
192                }
193                if (!boundingBoxedLayerFound) {
194                    BoundingXYVisitor bbox = new BoundingXYVisitor();
195                    yLayer.visitBoundingBox(bbox);
196                    Main.map.mapView.zoomTo(bbox);
197                }
198
199                if (yLayer.data != null) {
200                    for (ImageEntry ie : yLayer.data) {
201                        ie.applyTmp();
202                    }
203                }
204
205                yLayer.updateBufferAndRepaint();
206
207                break;
208            default:
209                throw new IllegalStateException();
210            }
211        }
212    }
213
214    private static class GpxDataWrapper {
215        private final String name;
216        private final GpxData data;
217        private final File file;
218
219        GpxDataWrapper(String name, GpxData data, File file) {
220            this.name = name;
221            this.data = data;
222            this.file = file;
223        }
224
225        @Override
226        public String toString() {
227            return name;
228        }
229    }
230
231    private ExtendedDialog syncDialog;
232    private final transient List<GpxDataWrapper> gpxLst = new ArrayList<>();
233    private JPanel outerPanel;
234    private JosmComboBox<GpxDataWrapper> cbGpx;
235    private JosmTextField tfTimezone;
236    private JosmTextField tfOffset;
237    private JCheckBox cbExifImg;
238    private JCheckBox cbTaggedImg;
239    private JCheckBox cbShowThumbs;
240    private JLabel statusBarText;
241
242    // remember the last number of matched photos
243    private int lastNumMatched;
244
245    /** This class is called when the user doesn't find the GPX file he needs in the files that have
246     * been loaded yet. It displays a FileChooser dialog to select the GPX file to be loaded.
247     */
248    private class LoadGpxDataActionListener implements ActionListener {
249
250        @Override
251        public void actionPerformed(ActionEvent arg0) {
252            FileFilter filter = new FileFilter() {
253                @Override
254                public boolean accept(File f) {
255                    return f.isDirectory() || Utils.hasExtension(f, "gpx", "gpx.gz");
256                }
257
258                @Override
259                public String getDescription() {
260                    return tr("GPX Files (*.gpx *.gpx.gz)");
261                }
262            };
263            AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, false, null, filter, JFileChooser.FILES_ONLY, null);
264            if (fc == null)
265                return;
266            File sel = fc.getSelectedFile();
267
268            try {
269                outerPanel.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));
270
271                for (int i = gpxLst.size() - 1; i >= 0; i--) {
272                    GpxDataWrapper wrapper = gpxLst.get(i);
273                    if (wrapper.file != null && sel.equals(wrapper.file)) {
274                        cbGpx.setSelectedIndex(i);
275                        if (!sel.getName().equals(wrapper.name)) {
276                            JOptionPane.showMessageDialog(
277                                    Main.parent,
278                                    tr("File {0} is loaded yet under the name \"{1}\"", sel.getName(), wrapper.name),
279                                    tr("Error"),
280                                    JOptionPane.ERROR_MESSAGE
281                            );
282                        }
283                        return;
284                    }
285                }
286                GpxData data = null;
287                try (InputStream iStream = createInputStream(sel)) {
288                    GpxReader reader = new GpxReader(iStream);
289                    reader.parse(false);
290                    data = reader.getGpxData();
291                    data.storageFile = sel;
292
293                } catch (SAXException x) {
294                    Main.error(x);
295                    JOptionPane.showMessageDialog(
296                            Main.parent,
297                            tr("Error while parsing {0}", sel.getName())+": "+x.getMessage(),
298                            tr("Error"),
299                            JOptionPane.ERROR_MESSAGE
300                    );
301                    return;
302                } catch (IOException x) {
303                    Main.error(x);
304                    JOptionPane.showMessageDialog(
305                            Main.parent,
306                            tr("Could not read \"{0}\"", sel.getName())+'\n'+x.getMessage(),
307                            tr("Error"),
308                            JOptionPane.ERROR_MESSAGE
309                    );
310                    return;
311                }
312
313                MutableComboBoxModel<GpxDataWrapper> model = (MutableComboBoxModel<GpxDataWrapper>) cbGpx.getModel();
314                loadedGpxData.add(data);
315                if (gpxLst.get(0).file == null) {
316                    gpxLst.remove(0);
317                    model.removeElementAt(0);
318                }
319                GpxDataWrapper elem = new GpxDataWrapper(sel.getName(), data, sel);
320                gpxLst.add(elem);
321                model.addElement(elem);
322                cbGpx.setSelectedIndex(cbGpx.getItemCount() - 1);
323            } finally {
324                outerPanel.setCursor(Cursor.getDefaultCursor());
325            }
326        }
327
328        private InputStream createInputStream(File sel) throws IOException {
329            if (Utils.hasExtension(sel, "gpx.gz")) {
330                return new GZIPInputStream(new FileInputStream(sel));
331            } else {
332                return new FileInputStream(sel);
333            }
334        }
335    }
336
337    /**
338     * This action listener is called when the user has a photo of the time of his GPS receiver. It
339     * displays the list of photos of the layer, and upon selection displays the selected photo.
340     * From that photo, the user can key in the time of the GPS.
341     * Then values of timezone and delta are set.
342     * @author chris
343     *
344     */
345    private class SetOffsetActionListener implements ActionListener {
346        private JPanel panel;
347        private JLabel lbExifTime;
348        private JosmTextField tfGpsTime;
349        private JosmComboBox<String> cbTimezones;
350        private ImageDisplay imgDisp;
351        private JList<String> imgList;
352
353        @Override
354        public void actionPerformed(ActionEvent arg0) {
355            SimpleDateFormat dateFormat = (SimpleDateFormat) DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM);
356
357            panel = new JPanel(new BorderLayout());
358            panel.add(new JLabel(tr("<html>Take a photo of your GPS receiver while it displays the time.<br>"
359                    + "Display that photo here.<br>"
360                    + "And then, simply capture the time you read on the photo and select a timezone<hr></html>")),
361                    BorderLayout.NORTH);
362
363            imgDisp = new ImageDisplay();
364            imgDisp.setPreferredSize(new Dimension(300, 225));
365            panel.add(imgDisp, BorderLayout.CENTER);
366
367            JPanel panelTf = new JPanel(new GridBagLayout());
368
369            GridBagConstraints gc = new GridBagConstraints();
370            gc.gridx = gc.gridy = 0;
371            gc.gridwidth = gc.gridheight = 1;
372            gc.weightx = gc.weighty = 0.0;
373            gc.fill = GridBagConstraints.NONE;
374            gc.anchor = GridBagConstraints.WEST;
375            panelTf.add(new JLabel(tr("Photo time (from exif):")), gc);
376
377            lbExifTime = new JLabel();
378            gc.gridx = 1;
379            gc.weightx = 1.0;
380            gc.fill = GridBagConstraints.HORIZONTAL;
381            gc.gridwidth = 2;
382            panelTf.add(lbExifTime, gc);
383
384            gc.gridx = 0;
385            gc.gridy = 1;
386            gc.gridwidth = gc.gridheight = 1;
387            gc.weightx = gc.weighty = 0.0;
388            gc.fill = GridBagConstraints.NONE;
389            gc.anchor = GridBagConstraints.WEST;
390            panelTf.add(new JLabel(tr("Gps time (read from the above photo): ")), gc);
391
392            tfGpsTime = new JosmTextField(12);
393            tfGpsTime.setEnabled(false);
394            tfGpsTime.setMinimumSize(new Dimension(155, tfGpsTime.getMinimumSize().height));
395            gc.gridx = 1;
396            gc.weightx = 1.0;
397            gc.fill = GridBagConstraints.HORIZONTAL;
398            panelTf.add(tfGpsTime, gc);
399
400            gc.gridx = 2;
401            gc.weightx = 0.2;
402            panelTf.add(new JLabel(" ["+dateFormat.toLocalizedPattern()+']'), gc);
403
404            gc.gridx = 0;
405            gc.gridy = 2;
406            gc.gridwidth = gc.gridheight = 1;
407            gc.weightx = gc.weighty = 0.0;
408            gc.fill = GridBagConstraints.NONE;
409            gc.anchor = GridBagConstraints.WEST;
410            panelTf.add(new JLabel(tr("I am in the timezone of: ")), gc);
411
412            String[] tmp = TimeZone.getAvailableIDs();
413            List<String> vtTimezones = new ArrayList<>(tmp.length);
414
415            for (String tzStr : tmp) {
416                TimeZone tz = TimeZone.getTimeZone(tzStr);
417
418                String tzDesc = new StringBuilder(tzStr).append(" (")
419                .append(new Timezone(tz.getRawOffset() / 3600000.0).formatTimezone())
420                .append(')').toString();
421                vtTimezones.add(tzDesc);
422            }
423
424            Collections.sort(vtTimezones);
425
426            cbTimezones = new JosmComboBox<>(vtTimezones.toArray(new String[0]));
427
428            String tzId = Main.pref.get("geoimage.timezoneid", "");
429            TimeZone defaultTz;
430            if (tzId.isEmpty()) {
431                defaultTz = TimeZone.getDefault();
432            } else {
433                defaultTz = TimeZone.getTimeZone(tzId);
434            }
435
436            cbTimezones.setSelectedItem(new StringBuilder(defaultTz.getID()).append(" (")
437                    .append(new Timezone(defaultTz.getRawOffset() / 3600000.0).formatTimezone())
438                    .append(')').toString());
439
440            gc.gridx = 1;
441            gc.weightx = 1.0;
442            gc.gridwidth = 2;
443            gc.fill = GridBagConstraints.HORIZONTAL;
444            panelTf.add(cbTimezones, gc);
445
446            panel.add(panelTf, BorderLayout.SOUTH);
447
448            JPanel panelLst = new JPanel(new BorderLayout());
449
450            imgList = new JList<>(new AbstractListModel<String>() {
451                @Override
452                public String getElementAt(int i) {
453                    return yLayer.data.get(i).getFile().getName();
454                }
455
456                @Override
457                public int getSize() {
458                    return yLayer.data != null ? yLayer.data.size() : 0;
459                }
460            });
461            imgList.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
462            imgList.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
463
464                @Override
465                public void valueChanged(ListSelectionEvent arg0) {
466                    int index = imgList.getSelectedIndex();
467                    Integer orientation = ExifReader.readOrientation(yLayer.data.get(index).getFile());
468                    imgDisp.setImage(yLayer.data.get(index).getFile(), orientation);
469                    Date date = yLayer.data.get(index).getExifTime();
470                    if (date != null) {
471                        DateFormat df = DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM);
472                        lbExifTime.setText(df.format(date));
473                        tfGpsTime.setText(df.format(date));
474                        tfGpsTime.setCaretPosition(tfGpsTime.getText().length());
475                        tfGpsTime.setEnabled(true);
476                        tfGpsTime.requestFocus();
477                    } else {
478                        lbExifTime.setText(tr("No date"));
479                        tfGpsTime.setText("");
480                        tfGpsTime.setEnabled(false);
481                    }
482                }
483            });
484            panelLst.add(new JScrollPane(imgList), BorderLayout.CENTER);
485
486            JButton openButton = new JButton(tr("Open another photo"));
487            openButton.addActionListener(new ActionListener() {
488
489                @Override
490                public void actionPerformed(ActionEvent ae) {
491                    AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, false, null,
492                            JpgImporter.FILE_FILTER_WITH_FOLDERS, JFileChooser.FILES_ONLY, "geoimage.lastdirectory");
493                    if (fc == null)
494                        return;
495                    File sel = fc.getSelectedFile();
496
497                    Integer orientation = ExifReader.readOrientation(sel);
498                    imgDisp.setImage(sel, orientation);
499
500                    Date date = ExifReader.readTime(sel);
501                    if (date != null) {
502                        lbExifTime.setText(DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM).format(date));
503                        tfGpsTime.setText(DateUtils.getDateFormat(DateFormat.SHORT).format(date)+' ');
504                        tfGpsTime.setEnabled(true);
505                    } else {
506                        lbExifTime.setText(tr("No date"));
507                        tfGpsTime.setText("");
508                        tfGpsTime.setEnabled(false);
509                    }
510                }
511            });
512            panelLst.add(openButton, BorderLayout.PAGE_END);
513
514            panel.add(panelLst, BorderLayout.LINE_START);
515
516            boolean isOk = false;
517            while (!isOk) {
518                int answer = JOptionPane.showConfirmDialog(
519                        Main.parent, panel,
520                        tr("Synchronize time from a photo of the GPS receiver"),
521                        JOptionPane.OK_CANCEL_OPTION,
522                        JOptionPane.QUESTION_MESSAGE
523                );
524                if (answer == JOptionPane.CANCEL_OPTION)
525                    return;
526
527                long delta;
528
529                try {
530                    delta = dateFormat.parse(lbExifTime.getText()).getTime()
531                    - dateFormat.parse(tfGpsTime.getText()).getTime();
532                } catch (ParseException e) {
533                    JOptionPane.showMessageDialog(Main.parent, tr("Error while parsing the date.\n"
534                            + "Please use the requested format"),
535                            tr("Invalid date"), JOptionPane.ERROR_MESSAGE);
536                    continue;
537                }
538
539                String selectedTz = (String) cbTimezones.getSelectedItem();
540                int pos = selectedTz.lastIndexOf('(');
541                tzId = selectedTz.substring(0, pos - 1);
542                String tzValue = selectedTz.substring(pos + 1, selectedTz.length() - 1);
543
544                Main.pref.put("geoimage.timezoneid", tzId);
545                tfOffset.setText(Offset.milliseconds(delta).formatOffset());
546                tfTimezone.setText(tzValue);
547
548                isOk = true;
549
550            }
551            statusBarUpdater.updateStatusBar();
552            yLayer.updateBufferAndRepaint();
553        }
554    }
555
556    @Override
557    public void actionPerformed(ActionEvent arg0) {
558        // Construct the list of loaded GPX tracks
559        Collection<Layer> layerLst = Main.map.mapView.getAllLayers();
560        GpxDataWrapper defaultItem = null;
561        for (Layer cur : layerLst) {
562            if (cur instanceof GpxLayer) {
563                GpxLayer curGpx = (GpxLayer) cur;
564                GpxDataWrapper gdw = new GpxDataWrapper(curGpx.getName(), curGpx.data, curGpx.data.storageFile);
565                gpxLst.add(gdw);
566                if (cur == yLayer.gpxLayer) {
567                    defaultItem = gdw;
568                }
569            }
570        }
571        for (GpxData data : loadedGpxData) {
572            gpxLst.add(new GpxDataWrapper(data.storageFile.getName(),
573                    data,
574                    data.storageFile));
575        }
576
577        if (gpxLst.isEmpty()) {
578            gpxLst.add(new GpxDataWrapper(tr("<No GPX track loaded yet>"), null, null));
579        }
580
581        JPanel panelCb = new JPanel();
582
583        panelCb.add(new JLabel(tr("GPX track: ")));
584
585        cbGpx = new JosmComboBox<>(gpxLst.toArray(new GpxDataWrapper[0]));
586        if (defaultItem != null) {
587            cbGpx.setSelectedItem(defaultItem);
588        }
589        cbGpx.addActionListener(statusBarUpdaterWithRepaint);
590        panelCb.add(cbGpx);
591
592        JButton buttonOpen = new JButton(tr("Open another GPX trace"));
593        buttonOpen.addActionListener(new LoadGpxDataActionListener());
594        panelCb.add(buttonOpen);
595
596        JPanel panelTf = new JPanel(new GridBagLayout());
597
598        String prefTimezone = Main.pref.get("geoimage.timezone", "0:00");
599        if (prefTimezone == null) {
600            prefTimezone = "0:00";
601        }
602        try {
603            timezone = Timezone.parseTimezone(prefTimezone);
604        } catch (ParseException e) {
605            timezone = Timezone.ZERO;
606        }
607
608        tfTimezone = new JosmTextField(10);
609        tfTimezone.setText(timezone.formatTimezone());
610
611        try {
612            delta = Offset.parseOffset(Main.pref.get("geoimage.delta", "0"));
613        } catch (ParseException e) {
614            delta = Offset.ZERO;
615        }
616
617        tfOffset = new JosmTextField(10);
618        tfOffset.setText(delta.formatOffset());
619
620        JButton buttonViewGpsPhoto = new JButton(tr("<html>Use photo of an accurate clock,<br>"
621                + "e.g. GPS receiver display</html>"));
622        buttonViewGpsPhoto.setIcon(ImageProvider.get("clock"));
623        buttonViewGpsPhoto.addActionListener(new SetOffsetActionListener());
624
625        JButton buttonAutoGuess = new JButton(tr("Auto-Guess"));
626        buttonAutoGuess.setToolTipText(tr("Matches first photo with first gpx point"));
627        buttonAutoGuess.addActionListener(new AutoGuessActionListener());
628
629        JButton buttonAdjust = new JButton(tr("Manual adjust"));
630        buttonAdjust.addActionListener(new AdjustActionListener());
631
632        JLabel labelPosition = new JLabel(tr("Override position for: "));
633
634        int numAll = getSortedImgList(true, true).size();
635        int numExif = numAll - getSortedImgList(false, true).size();
636        int numTagged = numAll - getSortedImgList(true, false).size();
637
638        cbExifImg = new JCheckBox(tr("Images with geo location in exif data ({0}/{1})", numExif, numAll));
639        cbExifImg.setEnabled(numExif != 0);
640
641        cbTaggedImg = new JCheckBox(tr("Images that are already tagged ({0}/{1})", numTagged, numAll), true);
642        cbTaggedImg.setEnabled(numTagged != 0);
643
644        labelPosition.setEnabled(cbExifImg.isEnabled() || cbTaggedImg.isEnabled());
645
646        boolean ticked = yLayer.thumbsLoaded || Main.pref.getBoolean("geoimage.showThumbs", false);
647        cbShowThumbs = new JCheckBox(tr("Show Thumbnail images on the map"), ticked);
648        cbShowThumbs.setEnabled(!yLayer.thumbsLoaded);
649
650        int y = 0;
651        GBC gbc = GBC.eol();
652        gbc.gridx = 0;
653        gbc.gridy = y++;
654        panelTf.add(panelCb, gbc);
655
656        gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 12);
657        gbc.gridx = 0;
658        gbc.gridy = y++;
659        panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc);
660
661        gbc = GBC.std();
662        gbc.gridx = 0;
663        gbc.gridy = y;
664        panelTf.add(new JLabel(tr("Timezone: ")), gbc);
665
666        gbc = GBC.std().fill(GBC.HORIZONTAL);
667        gbc.gridx = 1;
668        gbc.gridy = y++;
669        gbc.weightx = 1.;
670        panelTf.add(tfTimezone, gbc);
671
672        gbc = GBC.std();
673        gbc.gridx = 0;
674        gbc.gridy = y;
675        panelTf.add(new JLabel(tr("Offset:")), gbc);
676
677        gbc = GBC.std().fill(GBC.HORIZONTAL);
678        gbc.gridx = 1;
679        gbc.gridy = y++;
680        gbc.weightx = 1.;
681        panelTf.add(tfOffset, gbc);
682
683        gbc = GBC.std().insets(5, 5, 5, 5);
684        gbc.gridx = 2;
685        gbc.gridy = y-2;
686        gbc.gridheight = 2;
687        gbc.gridwidth = 2;
688        gbc.fill = GridBagConstraints.BOTH;
689        gbc.weightx = 0.5;
690        panelTf.add(buttonViewGpsPhoto, gbc);
691
692        gbc = GBC.std().fill(GBC.BOTH).insets(5, 5, 5, 5);
693        gbc.gridx = 2;
694        gbc.gridy = y++;
695        gbc.weightx = 0.5;
696        panelTf.add(buttonAutoGuess, gbc);
697
698        gbc.gridx = 3;
699        panelTf.add(buttonAdjust, gbc);
700
701        gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 12, 0, 0);
702        gbc.gridx = 0;
703        gbc.gridy = y++;
704        panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc);
705
706        gbc = GBC.eol();
707        gbc.gridx = 0;
708        gbc.gridy = y++;
709        panelTf.add(labelPosition, gbc);
710
711        gbc = GBC.eol();
712        gbc.gridx = 1;
713        gbc.gridy = y++;
714        panelTf.add(cbExifImg, gbc);
715
716        gbc = GBC.eol();
717        gbc.gridx = 1;
718        gbc.gridy = y++;
719        panelTf.add(cbTaggedImg, gbc);
720
721        gbc = GBC.eol();
722        gbc.gridx = 0;
723        gbc.gridy = y;
724        panelTf.add(cbShowThumbs, gbc);
725
726        final JPanel statusBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
727        statusBar.setBorder(BorderFactory.createLoweredBevelBorder());
728        statusBarText = new JLabel(" ");
729        statusBarText.setFont(statusBarText.getFont().deriveFont(8));
730        statusBar.add(statusBarText);
731
732        tfTimezone.addFocusListener(repaintTheMap);
733        tfOffset.addFocusListener(repaintTheMap);
734
735        tfTimezone.getDocument().addDocumentListener(statusBarUpdater);
736        tfOffset.getDocument().addDocumentListener(statusBarUpdater);
737        cbExifImg.addItemListener(statusBarUpdaterWithRepaint);
738        cbTaggedImg.addItemListener(statusBarUpdaterWithRepaint);
739
740        statusBarUpdater.updateStatusBar();
741
742        outerPanel = new JPanel(new BorderLayout());
743        outerPanel.add(statusBar, BorderLayout.PAGE_END);
744
745        if (!GraphicsEnvironment.isHeadless()) {
746            syncDialog = new ExtendedDialog(
747                    Main.parent,
748                    tr("Correlate images with GPX track"),
749                    new String[] {tr("Correlate"), tr("Cancel")},
750                    false
751            );
752            syncDialog.setContent(panelTf, false);
753            syncDialog.setButtonIcons(new String[] {"ok", "cancel"});
754            syncDialog.setupDialog();
755            outerPanel.add(syncDialog.getContentPane(), BorderLayout.PAGE_START);
756            syncDialog.setContentPane(outerPanel);
757            syncDialog.pack();
758            syncDialog.addWindowListener(new SyncDialogWindowListener());
759            syncDialog.showDialog();
760        }
761    }
762
763    private final transient StatusBarUpdater statusBarUpdater = new StatusBarUpdater(false);
764    private final transient StatusBarUpdater statusBarUpdaterWithRepaint = new StatusBarUpdater(true);
765
766    private class StatusBarUpdater implements  DocumentListener, ItemListener, ActionListener {
767        private final boolean doRepaint;
768
769        StatusBarUpdater(boolean doRepaint) {
770            this.doRepaint = doRepaint;
771        }
772
773        @Override
774        public void insertUpdate(DocumentEvent ev) {
775            updateStatusBar();
776        }
777
778        @Override
779        public void removeUpdate(DocumentEvent ev) {
780            updateStatusBar();
781        }
782
783        @Override
784        public void changedUpdate(DocumentEvent ev) {
785            // Do nothing
786        }
787
788        @Override
789        public void itemStateChanged(ItemEvent e) {
790            updateStatusBar();
791        }
792
793        @Override
794        public void actionPerformed(ActionEvent e) {
795            updateStatusBar();
796        }
797
798        public void updateStatusBar() {
799            statusBarText.setText(statusText());
800            if (doRepaint) {
801                yLayer.updateBufferAndRepaint();
802            }
803        }
804
805        private String statusText() {
806            try {
807                timezone = Timezone.parseTimezone(tfTimezone.getText().trim());
808                delta = Offset.parseOffset(tfOffset.getText().trim());
809            } catch (ParseException e) {
810                return e.getMessage();
811            }
812
813            // The selection of images we are about to correlate may have changed.
814            // So reset all images.
815            if (yLayer.data != null) {
816                for (ImageEntry ie: yLayer.data) {
817                    ie.discardTmp();
818                }
819            }
820
821            // Construct a list of images that have a date, and sort them on the date.
822            List<ImageEntry> dateImgLst = getSortedImgList();
823            // Create a temporary copy for each image
824            for (ImageEntry ie : dateImgLst) {
825                ie.createTmp();
826                ie.tmp.setPos(null);
827            }
828
829            GpxDataWrapper selGpx = selectedGPX(false);
830            if (selGpx == null)
831                return tr("No gpx selected");
832
833            final long offsetMs = ((long) (timezone.getHours() * 3600 * 1000)) + delta.getMilliseconds(); // in milliseconds
834            lastNumMatched = matchGpxTrack(dateImgLst, selGpx.data, offsetMs);
835
836            return trn("<html>Matched <b>{0}</b> of <b>{1}</b> photo to GPX track.</html>",
837                    "<html>Matched <b>{0}</b> of <b>{1}</b> photos to GPX track.</html>",
838                    dateImgLst.size(), lastNumMatched, dateImgLst.size());
839        }
840    }
841
842    private final transient RepaintTheMapListener repaintTheMap = new RepaintTheMapListener();
843
844    private class RepaintTheMapListener implements FocusListener {
845        @Override
846        public void focusGained(FocusEvent e) { // do nothing
847        }
848
849        @Override
850        public void focusLost(FocusEvent e) {
851            yLayer.updateBufferAndRepaint();
852        }
853    }
854
855    /**
856     * Presents dialog with sliders for manual adjust.
857     */
858    private class AdjustActionListener implements ActionListener {
859
860        @Override
861        public void actionPerformed(ActionEvent arg0) {
862
863            final Offset offset = Offset.milliseconds(
864                    delta.getMilliseconds() + Math.round(timezone.getHours() * 60 * 60 * 1000));
865            final int dayOffset = offset.getDayOffset();
866            final Pair<Timezone, Offset> timezoneOffsetPair = offset.withoutDayOffset().splitOutTimezone();
867
868            // Info Labels
869            final JLabel lblMatches = new JLabel();
870
871            // Timezone Slider
872            // The slider allows to switch timezon from -12:00 to 12:00 in 30 minutes steps. Therefore the range is -24 to 24.
873            final JLabel lblTimezone = new JLabel();
874            final JSlider sldTimezone = new JSlider(-24, 24, 0);
875            sldTimezone.setPaintLabels(true);
876            Dictionary<Integer, JLabel> labelTable = new Hashtable<>();
877            // CHECKSTYLE.OFF: ParenPad
878            for (int i = -12; i <= 12; i += 6) {
879                labelTable.put(i * 2, new JLabel(new Timezone(i).formatTimezone()));
880            }
881            // CHECKSTYLE.ON: ParenPad
882            sldTimezone.setLabelTable(labelTable);
883
884            // Minutes Slider
885            final JLabel lblMinutes = new JLabel();
886            final JSlider sldMinutes = new JSlider(-15, 15, 0);
887            sldMinutes.setPaintLabels(true);
888            sldMinutes.setMajorTickSpacing(5);
889
890            // Seconds slider
891            final JLabel lblSeconds = new JLabel();
892            final JSlider sldSeconds = new JSlider(-600, 600, 0);
893            sldSeconds.setPaintLabels(true);
894            labelTable = new Hashtable<>();
895            // CHECKSTYLE.OFF: ParenPad
896            for (int i = -60; i <= 60; i += 30) {
897                labelTable.put(i * 10, new JLabel(Offset.seconds(i).formatOffset()));
898            }
899            // CHECKSTYLE.ON: ParenPad
900            sldSeconds.setLabelTable(labelTable);
901            sldSeconds.setMajorTickSpacing(300);
902
903            // This is called whenever one of the sliders is moved.
904            // It updates the labels and also calls the "match photos" code
905            class SliderListener implements ChangeListener {
906                @Override
907                public void stateChanged(ChangeEvent e) {
908                    timezone = new Timezone(sldTimezone.getValue() / 2.);
909
910                    lblTimezone.setText(tr("Timezone: {0}", timezone.formatTimezone()));
911                    lblMinutes.setText(tr("Minutes: {0}", sldMinutes.getValue()));
912                    lblSeconds.setText(tr("Seconds: {0}", Offset.milliseconds(100L * sldSeconds.getValue()).formatOffset()));
913
914                    delta = Offset.milliseconds(100L * sldSeconds.getValue()
915                            + 1000L * 60 * sldMinutes.getValue()
916                            + 1000L * 60 * 60 * 24 * dayOffset);
917
918                    tfTimezone.getDocument().removeDocumentListener(statusBarUpdater);
919                    tfOffset.getDocument().removeDocumentListener(statusBarUpdater);
920
921                    tfTimezone.setText(timezone.formatTimezone());
922                    tfOffset.setText(delta.formatOffset());
923
924                    tfTimezone.getDocument().addDocumentListener(statusBarUpdater);
925                    tfOffset.getDocument().addDocumentListener(statusBarUpdater);
926
927                    lblMatches.setText(statusBarText.getText() + "<br>" + trn("(Time difference of {0} day)",
928                            "Time difference of {0} days", Math.abs(dayOffset), Math.abs(dayOffset)));
929
930                    statusBarUpdater.updateStatusBar();
931                    yLayer.updateBufferAndRepaint();
932                }
933            }
934
935            // Put everything together
936            JPanel p = new JPanel(new GridBagLayout());
937            p.setPreferredSize(new Dimension(400, 230));
938            p.add(lblMatches, GBC.eol().fill());
939            p.add(lblTimezone, GBC.eol().fill());
940            p.add(sldTimezone, GBC.eol().fill().insets(0, 0, 0, 10));
941            p.add(lblMinutes, GBC.eol().fill());
942            p.add(sldMinutes, GBC.eol().fill().insets(0, 0, 0, 10));
943            p.add(lblSeconds, GBC.eol().fill());
944            p.add(sldSeconds, GBC.eol().fill());
945
946            // If there's an error in the calculation the found values
947            // will be off range for the sliders. Catch this error
948            // and inform the user about it.
949            try {
950                sldTimezone.setValue((int) (timezoneOffsetPair.a.getHours() * 2));
951                sldMinutes.setValue((int) (timezoneOffsetPair.b.getSeconds() / 60));
952                final long deciSeconds = timezoneOffsetPair.b.getMilliseconds() / 100;
953                sldSeconds.setValue((int) (deciSeconds % 60));
954            } catch (RuntimeException e) {
955                JOptionPane.showMessageDialog(Main.parent,
956                        tr("An error occurred while trying to match the photos to the GPX track."
957                                +" You can adjust the sliders to manually match the photos."),
958                                tr("Matching photos to track failed"),
959                                JOptionPane.WARNING_MESSAGE);
960            }
961
962            // Call the sliderListener once manually so labels get adjusted
963            new SliderListener().stateChanged(null);
964            // Listeners added here, otherwise it tries to match three times
965            // (when setting the default values)
966            sldTimezone.addChangeListener(new SliderListener());
967            sldMinutes.addChangeListener(new SliderListener());
968            sldSeconds.addChangeListener(new SliderListener());
969
970            // There is no way to cancel this dialog, all changes get applied
971            // immediately. Therefore "Close" is marked with an "OK" icon.
972            // Settings are only saved temporarily to the layer.
973            new ExtendedDialog(Main.parent,
974                    tr("Adjust timezone and offset"),
975                    new String[] {tr("Close")}).
976                    setContent(p).setButtonIcons(new String[] {"ok"}).showDialog();
977        }
978    }
979
980    static class NoGpxTimestamps extends Exception {
981    }
982
983    /**
984     * Tries to auto-guess the timezone and offset.
985     *
986     * @param imgs the images to correlate
987     * @param gpx the gpx track to correlate to
988     * @return a pair of timezone and offset
989     * @throws IndexOutOfBoundsException when there are no images
990     * @throws NoGpxTimestamps when the gpx track does not contain a timestamp
991     */
992    static Pair<Timezone, Offset> autoGuess(List<ImageEntry> imgs, GpxData gpx) throws IndexOutOfBoundsException, NoGpxTimestamps {
993
994        // Init variables
995        long firstExifDate = imgs.get(0).getExifTime().getTime();
996
997        long firstGPXDate = -1;
998        // Finds first GPX point
999        outer: for (GpxTrack trk : gpx.tracks) {
1000            for (GpxTrackSegment segment : trk.getSegments()) {
1001                for (WayPoint curWp : segment.getWayPoints()) {
1002                    final Date parsedTime = curWp.setTimeFromAttribute();
1003                    if (parsedTime != null) {
1004                        firstGPXDate = parsedTime.getTime();
1005                        break outer;
1006                    }
1007                }
1008            }
1009        }
1010
1011        if (firstGPXDate < 0) {
1012            throw new NoGpxTimestamps();
1013        }
1014
1015        return Offset.milliseconds(firstExifDate - firstGPXDate).splitOutTimezone();
1016    }
1017
1018    private class AutoGuessActionListener implements ActionListener {
1019
1020        @Override
1021        public void actionPerformed(ActionEvent arg0) {
1022            GpxDataWrapper gpxW = selectedGPX(true);
1023            if (gpxW == null)
1024                return;
1025            GpxData gpx = gpxW.data;
1026
1027            List<ImageEntry> imgs = getSortedImgList();
1028
1029            try {
1030                final Pair<Timezone, Offset> r = autoGuess(imgs, gpx);
1031                timezone = r.a;
1032                delta = r.b;
1033            } catch (IndexOutOfBoundsException ex) {
1034                JOptionPane.showMessageDialog(Main.parent,
1035                        tr("The selected photos do not contain time information."),
1036                        tr("Photos do not contain time information"), JOptionPane.WARNING_MESSAGE);
1037                return;
1038            } catch (NoGpxTimestamps ex) {
1039                JOptionPane.showMessageDialog(Main.parent,
1040                        tr("The selected GPX track does not contain timestamps. Please select another one."),
1041                        tr("GPX Track has no time information"), JOptionPane.WARNING_MESSAGE);
1042                return;
1043            }
1044
1045            tfTimezone.getDocument().removeDocumentListener(statusBarUpdater);
1046            tfOffset.getDocument().removeDocumentListener(statusBarUpdater);
1047
1048            tfTimezone.setText(timezone.formatTimezone());
1049            tfOffset.setText(delta.formatOffset());
1050            tfOffset.requestFocus();
1051
1052            tfTimezone.getDocument().addDocumentListener(statusBarUpdater);
1053            tfOffset.getDocument().addDocumentListener(statusBarUpdater);
1054
1055            statusBarUpdater.updateStatusBar();
1056            yLayer.updateBufferAndRepaint();
1057        }
1058    }
1059
1060    private List<ImageEntry> getSortedImgList() {
1061        return getSortedImgList(cbExifImg.isSelected(), cbTaggedImg.isSelected());
1062    }
1063
1064    /**
1065     * Returns a list of images that fulfill the given criteria.
1066     * Default setting is to return untagged images, but may be overwritten.
1067     * @param exif also returns images with exif-gps info
1068     * @param tagged also returns tagged images
1069     * @return matching images
1070     */
1071    private List<ImageEntry> getSortedImgList(boolean exif, boolean tagged) {
1072        if (yLayer.data == null) {
1073            return Collections.emptyList();
1074        }
1075        List<ImageEntry> dateImgLst = new ArrayList<>(yLayer.data.size());
1076        for (ImageEntry e : yLayer.data) {
1077            if (!e.hasExifTime()) {
1078                continue;
1079            }
1080
1081            if (e.getExifCoor() != null && !exif) {
1082                continue;
1083            }
1084
1085            if (e.isTagged() && e.getExifCoor() == null && !tagged) {
1086                continue;
1087            }
1088
1089            dateImgLst.add(e);
1090        }
1091
1092        Collections.sort(dateImgLst, new Comparator<ImageEntry>() {
1093            @Override
1094            public int compare(ImageEntry arg0, ImageEntry arg1) {
1095                return arg0.getExifTime().compareTo(arg1.getExifTime());
1096            }
1097        });
1098
1099        return dateImgLst;
1100    }
1101
1102    private GpxDataWrapper selectedGPX(boolean complain) {
1103        Object item = cbGpx.getSelectedItem();
1104
1105        if (item == null || ((GpxDataWrapper) item).file == null) {
1106            if (complain) {
1107                JOptionPane.showMessageDialog(Main.parent, tr("You should select a GPX track"),
1108                        tr("No selected GPX track"), JOptionPane.ERROR_MESSAGE);
1109            }
1110            return null;
1111        }
1112        return (GpxDataWrapper) item;
1113    }
1114
1115    /**
1116     * Match a list of photos to a gpx track with a given offset.
1117     * All images need a exifTime attribute and the List must be sorted according to these times.
1118     * @param images images to match
1119     * @param selectedGpx selected GPX data
1120     * @param offset offset
1121     * @return number of matched points
1122     */
1123    static int matchGpxTrack(List<ImageEntry> images, GpxData selectedGpx, long offset) {
1124        int ret = 0;
1125
1126        for (GpxTrack trk : selectedGpx.tracks) {
1127            for (GpxTrackSegment segment : trk.getSegments()) {
1128
1129                long prevWpTime = 0;
1130                WayPoint prevWp = null;
1131
1132                for (WayPoint curWp : segment.getWayPoints()) {
1133                    final Date parsedTime = curWp.setTimeFromAttribute();
1134                    if (parsedTime != null) {
1135                        final long curWpTime = parsedTime.getTime() + offset;
1136                        ret += matchPoints(images, prevWp, prevWpTime, curWp, curWpTime, offset);
1137
1138                        prevWp = curWp;
1139                        prevWpTime = curWpTime;
1140                        continue;
1141                    }
1142                    prevWp = null;
1143                    prevWpTime = 0;
1144                }
1145            }
1146        }
1147        return ret;
1148    }
1149
1150    private static Double getElevation(WayPoint wp) {
1151        String value = wp.getString(GpxConstants.PT_ELE);
1152        if (value != null && !value.isEmpty()) {
1153            try {
1154                return Double.valueOf(value);
1155            } catch (NumberFormatException e) {
1156                Main.warn(e);
1157            }
1158        }
1159        return null;
1160    }
1161
1162    static int matchPoints(List<ImageEntry> images, WayPoint prevWp, long prevWpTime,
1163            WayPoint curWp, long curWpTime, long offset) {
1164        // Time between the track point and the previous one, 5 sec if first point, i.e. photos take
1165        // 5 sec before the first track point can be assumed to be take at the starting position
1166        long interval = prevWpTime > 0 ? Math.abs(curWpTime - prevWpTime) : 5*1000;
1167        int ret = 0;
1168
1169        // i is the index of the timewise last photo that has the same or earlier EXIF time
1170        int i = getLastIndexOfListBefore(images, curWpTime);
1171
1172        // no photos match
1173        if (i < 0)
1174            return 0;
1175
1176        Double speed = null;
1177        Double prevElevation = null;
1178
1179        if (prevWp != null) {
1180            double distance = prevWp.getCoor().greatCircleDistance(curWp.getCoor());
1181            // This is in km/h, 3.6 * m/s
1182            if (curWpTime > prevWpTime) {
1183                speed = 3600 * distance / (curWpTime - prevWpTime);
1184            }
1185            prevElevation = getElevation(prevWp);
1186        }
1187
1188        Double curElevation = getElevation(curWp);
1189
1190        // First trackpoint, then interval is set to five seconds, i.e. photos up to five seconds
1191        // before the first point will be geotagged with the starting point
1192        if (prevWpTime == 0 || curWpTime <= prevWpTime) {
1193            while (i >= 0) {
1194                final ImageEntry curImg = images.get(i);
1195                long time = curImg.getExifTime().getTime();
1196                if (time > curWpTime || time < curWpTime - interval) {
1197                    break;
1198                }
1199                if (curImg.tmp.getPos() == null) {
1200                    curImg.tmp.setPos(curWp.getCoor());
1201                    curImg.tmp.setSpeed(speed);
1202                    curImg.tmp.setElevation(curElevation);
1203                    curImg.tmp.setGpsTime(new Date(curImg.getExifTime().getTime() - offset));
1204                    curImg.flagNewGpsData();
1205                    ret++;
1206                }
1207                i--;
1208            }
1209            return ret;
1210        }
1211
1212        // This code gives a simple linear interpolation of the coordinates between current and
1213        // previous track point assuming a constant speed in between
1214        while (i >= 0) {
1215            ImageEntry curImg = images.get(i);
1216            long imgTime = curImg.getExifTime().getTime();
1217            if (imgTime < prevWpTime) {
1218                break;
1219            }
1220
1221            if (curImg.tmp.getPos() == null && prevWp != null) {
1222                // The values of timeDiff are between 0 and 1, it is not seconds but a dimensionless variable
1223                double timeDiff = (double) (imgTime - prevWpTime) / interval;
1224                curImg.tmp.setPos(prevWp.getCoor().interpolate(curWp.getCoor(), timeDiff));
1225                curImg.tmp.setSpeed(speed);
1226                if (curElevation != null && prevElevation != null) {
1227                    curImg.tmp.setElevation(prevElevation + (curElevation - prevElevation) * timeDiff);
1228                }
1229                curImg.tmp.setGpsTime(new Date(curImg.getExifTime().getTime() - offset));
1230                curImg.flagNewGpsData();
1231
1232                ret++;
1233            }
1234            i--;
1235        }
1236        return ret;
1237    }
1238
1239    private static int getLastIndexOfListBefore(List<ImageEntry> images, long searchedTime) {
1240        int lstSize = images.size();
1241
1242        // No photos or the first photo taken is later than the search period
1243        if (lstSize == 0 || searchedTime < images.get(0).getExifTime().getTime())
1244            return -1;
1245
1246        // The search period is later than the last photo
1247        if (searchedTime > images.get(lstSize - 1).getExifTime().getTime())
1248            return lstSize-1;
1249
1250        // The searched index is somewhere in the middle, do a binary search from the beginning
1251        int curIndex;
1252        int startIndex = 0;
1253        int endIndex = lstSize-1;
1254        while (endIndex - startIndex > 1) {
1255            curIndex = (endIndex + startIndex) / 2;
1256            if (searchedTime > images.get(curIndex).getExifTime().getTime()) {
1257                startIndex = curIndex;
1258            } else {
1259                endIndex = curIndex;
1260            }
1261        }
1262        if (searchedTime < images.get(endIndex).getExifTime().getTime())
1263            return startIndex;
1264
1265        // This final loop is to check if photos with the exact same EXIF time follows
1266        while ((endIndex < (lstSize-1)) && (images.get(endIndex).getExifTime().getTime()
1267                == images.get(endIndex + 1).getExifTime().getTime())) {
1268            endIndex++;
1269        }
1270        return endIndex;
1271    }
1272
1273    static final class Timezone {
1274
1275        static final Timezone ZERO = new Timezone(0.0);
1276        private final double timezone;
1277
1278        Timezone(double hours) {
1279            this.timezone = hours;
1280        }
1281
1282        public double getHours() {
1283            return timezone;
1284        }
1285
1286        String formatTimezone() {
1287            StringBuilder ret = new StringBuilder();
1288
1289            double timezone = this.timezone;
1290            if (timezone < 0) {
1291                ret.append('-');
1292                timezone = -timezone;
1293            } else {
1294                ret.append('+');
1295            }
1296            ret.append((long) timezone).append(':');
1297            int minutes = (int) ((timezone % 1) * 60);
1298            if (minutes < 10) {
1299                ret.append('0');
1300            }
1301            ret.append(minutes);
1302
1303            return ret.toString();
1304        }
1305
1306        static Timezone parseTimezone(String timezone) throws ParseException {
1307
1308            if (timezone.isEmpty())
1309                return ZERO;
1310
1311            String error = tr("Error while parsing timezone.\nExpected format: {0}", "+H:MM");
1312
1313            char sgnTimezone = '+';
1314            StringBuilder hTimezone = new StringBuilder();
1315            StringBuilder mTimezone = new StringBuilder();
1316            int state = 1; // 1=start/sign, 2=hours, 3=minutes.
1317            for (int i = 0; i < timezone.length(); i++) {
1318                char c = timezone.charAt(i);
1319                switch (c) {
1320                    case ' ':
1321                        if (state != 2 || hTimezone.length() != 0)
1322                            throw new ParseException(error, i);
1323                        break;
1324                    case '+':
1325                    case '-':
1326                        if (state == 1) {
1327                            sgnTimezone = c;
1328                            state = 2;
1329                        } else
1330                            throw new ParseException(error, i);
1331                        break;
1332                    case ':':
1333                    case '.':
1334                        if (state == 2) {
1335                            state = 3;
1336                        } else
1337                            throw new ParseException(error, i);
1338                        break;
1339                    case '0':
1340                    case '1':
1341                    case '2':
1342                    case '3':
1343                    case '4':
1344                    case '5':
1345                    case '6':
1346                    case '7':
1347                    case '8':
1348                    case '9':
1349                        switch (state) {
1350                            case 1:
1351                            case 2:
1352                                state = 2;
1353                                hTimezone.append(c);
1354                                break;
1355                            case 3:
1356                                mTimezone.append(c);
1357                                break;
1358                            default:
1359                                throw new ParseException(error, i);
1360                        }
1361                        break;
1362                    default:
1363                        throw new ParseException(error, i);
1364                }
1365            }
1366
1367            int h = 0;
1368            int m = 0;
1369            try {
1370                h = Integer.parseInt(hTimezone.toString());
1371                if (mTimezone.length() > 0) {
1372                    m = Integer.parseInt(mTimezone.toString());
1373                }
1374            } catch (NumberFormatException nfe) {
1375                // Invalid timezone
1376                throw (ParseException) new ParseException(error, 0).initCause(nfe);
1377            }
1378
1379            if (h > 12 || m > 59)
1380                throw new ParseException(error, 0);
1381            else
1382                return new Timezone((h + m / 60.0) * (sgnTimezone == '-' ? -1 : 1));
1383        }
1384
1385        @Override
1386        public boolean equals(Object o) {
1387            if (this == o) return true;
1388            if (!(o instanceof Timezone)) return false;
1389            Timezone timezone1 = (Timezone) o;
1390            return Double.compare(timezone1.timezone, timezone) == 0;
1391        }
1392
1393        @Override
1394        public int hashCode() {
1395            return Objects.hash(timezone);
1396        }
1397    }
1398
1399    static final class Offset {
1400
1401        static final Offset ZERO = new Offset(0);
1402        private final long milliseconds;
1403
1404        private Offset(long milliseconds) {
1405            this.milliseconds = milliseconds;
1406        }
1407
1408        static Offset milliseconds(long milliseconds) {
1409            return new Offset(milliseconds);
1410        }
1411
1412        static Offset seconds(long seconds) {
1413            return new Offset(1000 * seconds);
1414        }
1415
1416        long getMilliseconds() {
1417            return milliseconds;
1418        }
1419
1420        long getSeconds() {
1421            return milliseconds / 1000;
1422        }
1423
1424        String formatOffset() {
1425            if (milliseconds % 1000 == 0) {
1426                return Long.toString(milliseconds / 1000);
1427            } else if (milliseconds % 100 == 0) {
1428                return String.format(Locale.ENGLISH, "%.1f", milliseconds / 1000.);
1429            } else {
1430                return String.format(Locale.ENGLISH, "%.3f", milliseconds / 1000.);
1431            }
1432        }
1433
1434        static Offset parseOffset(String offset) throws ParseException {
1435            String error = tr("Error while parsing offset.\nExpected format: {0}", "number");
1436
1437            if (!offset.isEmpty()) {
1438                try {
1439                    if (offset.startsWith("+")) {
1440                        offset = offset.substring(1);
1441                    }
1442                    return Offset.milliseconds(Math.round(Double.parseDouble(offset) * 1000));
1443                } catch (NumberFormatException nfe) {
1444                    throw (ParseException) new ParseException(error, 0).initCause(nfe);
1445                }
1446            } else {
1447                return Offset.ZERO;
1448            }
1449        }
1450
1451        int getDayOffset() {
1452            final double diffInH = getMilliseconds() / 1000. / 60 / 60; // hours
1453
1454            // Find day difference
1455            return (int) Math.round(diffInH / 24);
1456        }
1457
1458        Offset withoutDayOffset() {
1459            return milliseconds(getMilliseconds() - getDayOffset() * 24L * 60L * 60L * 1000L);
1460        }
1461
1462        Pair<Timezone, Offset> splitOutTimezone() {
1463            // In hours
1464            double tz = withoutDayOffset().getSeconds() / 3600.0;
1465
1466            // Due to imprecise clocks we might get a "+3:28" timezone, which should obviously be 3:30 with
1467            // -2 minutes offset. This determines the real timezone and finds offset.
1468            final double timezone = (double) Math.round(tz * 2) / 2; // hours, rounded to one decimal place
1469            final long delta = Math.round(getMilliseconds() - timezone * 60 * 60 * 1000); // milliseconds
1470            return Pair.create(new Timezone(timezone), Offset.milliseconds(delta));
1471        }
1472
1473        @Override
1474        public boolean equals(Object o) {
1475            if (this == o) return true;
1476            if (!(o instanceof Offset)) return false;
1477            Offset offset = (Offset) o;
1478            return milliseconds == offset.milliseconds;
1479        }
1480
1481        @Override
1482        public int hashCode() {
1483            return Objects.hash(milliseconds);
1484        }
1485    }
1486}