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