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