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