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