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