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