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