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