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