001/*
002 * Syncany, www.syncany.org
003 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com> 
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation, either version 3 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018package org.syncany.operations.up;
019
020import java.io.File;
021import java.io.IOException;
022import java.security.SecureRandom;
023import java.util.Collection;
024import java.util.Date;
025import java.util.List;
026import java.util.Queue;
027import java.util.logging.Level;
028import java.util.logging.Logger;
029
030import org.syncany.chunk.Chunk;
031import org.syncany.chunk.Deduper;
032import org.syncany.chunk.DeduperListener;
033import org.syncany.chunk.MultiChunk;
034import org.syncany.config.Config;
035import org.syncany.config.LocalEventBus;
036import org.syncany.database.ChunkEntry;
037import org.syncany.database.ChunkEntry.ChunkChecksum;
038import org.syncany.database.DatabaseVersion;
039import org.syncany.database.FileContent;
040import org.syncany.database.FileContent.FileChecksum;
041import org.syncany.database.FileVersion;
042import org.syncany.database.FileVersion.FileStatus;
043import org.syncany.database.FileVersion.FileType;
044import org.syncany.database.FileVersionComparator;
045import org.syncany.database.FileVersionComparator.FileProperties;
046import org.syncany.database.FileVersionComparator.FileVersionComparison;
047import org.syncany.database.MemoryDatabase;
048import org.syncany.database.MultiChunkEntry;
049import org.syncany.database.MultiChunkEntry.MultiChunkId;
050import org.syncany.database.PartialFileHistory;
051import org.syncany.database.PartialFileHistory.FileHistoryId;
052import org.syncany.database.SqlDatabase;
053import org.syncany.operations.daemon.messages.UpIndexChangesDetectedSyncExternalEvent;
054import org.syncany.operations.daemon.messages.UpIndexEndSyncExternalEvent;
055import org.syncany.operations.daemon.messages.UpIndexMidSyncExternalEvent;
056import org.syncany.operations.daemon.messages.UpIndexStartSyncExternalEvent;
057import org.syncany.util.EnvironmentUtil;
058import org.syncany.util.FileUtil;
059import org.syncany.util.StringUtil;
060
061/**
062 * The indexer combines the chunking process with the corresponding database
063 * lookups for the resulting chunks. It implements the deduplication mechanism 
064 * of Syncany.
065 * 
066 * <p>The class takes a list of files as input and uses the {@link Deduper} to
067 * break these files into individual chunks. By implementing the {@link DeduperListener},
068 * it reacts on chunking events and creates a new database version (with the newly
069 * added/changed/removed files. This functionality is entirely implemented by the
070 * index() method.
071 * 
072 * <p>The class uses the currently loaded {@link MemoryDatabase} as well as a potential  
073 * dirty database into account. Lookups for chunks and file histories are performed 
074 * on both databases.
075 * 
076 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
077 */
078public class Indexer {
079        private static final Logger logger = Logger.getLogger(Indexer.class.getSimpleName());
080        
081        private static final String DEFAULT_POSIX_PERMISSIONS_FILE = "rw-r--r--";
082        private static final String DEFAULT_POSIX_PERMISSIONS_FOLDER = "rwxr-xr-x";
083        private static final String DEFAULT_DOS_ATTRIBUTES = "--a-";
084
085        private Config config;
086        private Deduper deduper;
087        private SqlDatabase localDatabase;
088
089        private LocalEventBus eventBus;
090
091
092        public Indexer(Config config, Deduper deduper) {
093                this.config = config;
094                this.deduper = deduper;
095                this.localDatabase = new SqlDatabase(config, true);
096
097                this.eventBus = LocalEventBus.getInstance();
098        }
099
100        /**
101         * This method implements the index/deduplication functionality of Syncany. It uses a {@link Deduper}
102         * to break files down, compares them to the local database and creates a new {@link DatabaseVersion}
103         * as a result. 
104         * 
105         * <p>Depending on what has changed, the new database version will contain new instances of 
106         * {@link PartialFileHistory}, {@link FileVersion}, {@link FileContent}, {@link ChunkEntry} and 
107         * {@link MultiChunkEntry}.
108         * 
109         * @param files List of files to be deduplicated
110         * @param deletedFiles List of files that have been deleted
111         * @param databaseVersionQueue Queue to which created databaseVersions are offered
112         * @throws IOException If the chunking/deduplication cannot read/process any of the files
113         */
114        public void index(List<File> files, List<File> deletedFiles, Queue<DatabaseVersion> databaseVersionQueue) throws IOException {
115                if (!files.isEmpty()) {
116                        indexWithNewFiles(files, deletedFiles, databaseVersionQueue);
117                }
118                else {
119                        indexWithoutNewFiles(files, deletedFiles, databaseVersionQueue);
120                }
121
122                localDatabase.finalize();
123        }
124
125        private void indexWithNewFiles(List<File> files, List<File> deletedFiles, Queue<DatabaseVersion> databaseVersionQueue) throws IOException {
126                boolean isFirstFile = true;
127                int filesCount = files.size();
128
129                while (!files.isEmpty()) {
130                        DatabaseVersion newDatabaseVersion = new DatabaseVersion();
131
132                        // Create the DeduperListener that will receive MultiChunks and store them in the DatabaseVersion object
133                        DeduperListener deduperListener = new IndexerDeduperListener(newDatabaseVersion);
134
135                        // Signal the start of indexing if we are about to deduplicate the first file
136                        if (isFirstFile) {
137                                deduperListener.onStart(files.size());                          
138                                removeDeletedFiles(newDatabaseVersion, deletedFiles); // Add deletions in first database version
139                                
140                                isFirstFile = false;
141                        }
142
143                        // Find and index new files
144                        deduper.deduplicate(files, deduperListener);
145
146                        if (!newDatabaseVersion.getFileHistories().isEmpty()) {
147                                logger.log(Level.FINE, "Processed new database version: " + newDatabaseVersion);
148                                databaseVersionQueue.offer(newDatabaseVersion);
149                                
150                                int remainingFilesCount = filesCount - files.size();
151                                eventBus.post(new UpIndexMidSyncExternalEvent(config.getLocalDir().toString(), filesCount, remainingFilesCount));
152                        }
153                        //else { (comment-only else case)
154                        // Just chunks and multichunks, no filehistory. Since this means the file was being
155                        // written/vanished during operations, it makes no sense to upload it. If the user
156                        // wants it indexed, Up can be run again.
157                        //}
158                }
159        }
160
161        private void indexWithoutNewFiles(List<File> files, List<File> deletedFiles, Queue<DatabaseVersion> databaseVersionQueue) {
162                DatabaseVersion newDatabaseVersion = new DatabaseVersion();
163                
164                removeDeletedFiles(newDatabaseVersion, deletedFiles);
165                logger.log(Level.FINE, "Added database version with only deletions: " + newDatabaseVersion);
166                
167                databaseVersionQueue.offer(newDatabaseVersion);
168        }
169
170        private void removeDeletedFiles(DatabaseVersion newDatabaseVersion, List<File> deletedFiles) {
171                logger.log(Level.FINER, "- Looking for deleted files ...");
172
173                for (File deletedFile : deletedFiles) {
174                        String path = FileUtil.getRelativeDatabasePath(config.getLocalDir(), deletedFile);
175                        PartialFileHistory fileHistory = localDatabase.getFileHistoriesWithLastVersionByPath(path);
176
177                        // Ignore this file history if it has been updated in this database version before (file probably renamed!)
178                        if (newDatabaseVersion.getFileHistory(fileHistory.getFileHistoryId()) != null) {
179                                continue;
180                        }
181
182                        // Check if file exists, remove if it doesn't
183                        FileVersion lastLocalVersion = fileHistory.getLastVersion();
184                        File lastLocalVersionOnDisk = new File(config.getLocalDir() + File.separator + lastLocalVersion.getPath());
185
186                        // Ignore this file history if the last version is marked "DELETED"
187                        if (lastLocalVersion.getStatus() == FileStatus.DELETED) {
188                                continue;
189                        }
190
191                        // Add this file history if a new file with this name has been added (file type change)
192                        PartialFileHistory newFileWithSameName = getFileHistoryByPathFromDatabaseVersion(newDatabaseVersion, fileHistory.getLastVersion()
193                                        .getPath());
194
195                        // If file has VANISHED, mark as DELETED
196                        if (!FileUtil.exists(lastLocalVersionOnDisk) || newFileWithSameName != null) {
197                                PartialFileHistory fileHistoryForDeletion = createFileHistoryForDeletion(fileHistory, lastLocalVersion);
198                                newDatabaseVersion.addFileHistory(fileHistoryForDeletion);
199
200                                logger.log(Level.FINER, "  + Deleted: Adding DELETED version: {0}", fileHistoryForDeletion.getLastVersion());
201                                logger.log(Level.FINER, "                           based on: {0}", lastLocalVersion);
202                        }
203                }
204        }
205
206        private PartialFileHistory createFileHistoryForDeletion(PartialFileHistory fileHistory, FileVersion lastLocalVersion) {
207                PartialFileHistory deletedFileHistory = new PartialFileHistory(fileHistory.getFileHistoryId());
208                FileVersion deletedVersion = lastLocalVersion.clone();
209
210                deletedVersion.setStatus(FileStatus.DELETED);
211                deletedVersion.setVersion(fileHistory.getLastVersion().getVersion() + 1);
212                deletedVersion.setUpdated(new Date());
213
214                deletedFileHistory.addFileVersion(deletedVersion);
215
216                return deletedFileHistory;
217        }
218
219        private PartialFileHistory getFileHistoryByPathFromDatabaseVersion(DatabaseVersion databaseVersion, String path) {
220                // TODO [medium] Extremely performance intensive, because this is called inside a loop above. Implement better caching for database version!!!
221
222                for (PartialFileHistory fileHistory : databaseVersion.getFileHistories()) {
223                        FileVersion lastVersion = fileHistory.getLastVersion();
224
225                        if (lastVersion.getStatus() != FileStatus.DELETED && lastVersion.getPath().equals(path)) {
226                                return fileHistory;
227                        }
228                }
229
230                return null;
231        }
232
233        private class IndexerDeduperListener implements DeduperListener {
234                private FileVersionComparator fileVersionComparator;
235                private SecureRandom secureRandom;
236                private DatabaseVersion newDatabaseVersion;
237
238                private ChunkEntry chunkEntry;
239                private MultiChunkEntry multiChunkEntry;
240                private FileContent fileContent;
241
242                private FileProperties startFileProperties;
243                private FileProperties endFileProperties;
244
245                public IndexerDeduperListener(DatabaseVersion newDatabaseVersion) {
246
247                        this.fileVersionComparator = new FileVersionComparator(config.getLocalDir(), config.getChunker().getChecksumAlgorithm());
248                        this.secureRandom = new SecureRandom();
249                        this.newDatabaseVersion = newDatabaseVersion;
250                }
251
252                @Override
253                public boolean onFileFilter(File file) {
254                        logger.log(Level.FINER, "- +File {0}", file);
255
256                        startFileProperties = fileVersionComparator.captureFileProperties(file, null, false);
257
258                        // Check if file has vanished
259                        if (!startFileProperties.exists() || startFileProperties.isLocked()) {
260                                logger.log(Level.FINER, "- /File: {0}", file);
261                                logger.log(Level.INFO, "   * NOT ADDING because file has VANISHED (exists = {0}) or is LOCKED (locked = {1}).", new Object[] {
262                                                startFileProperties.exists(), startFileProperties.isLocked() });
263
264                                resetFileEnd();
265                                return false;
266                        }
267
268                        // Content
269                        if (startFileProperties.getType() == FileType.FILE) {
270                                logger.log(Level.FINER, "- +FileContent: {0}", file);
271                                fileContent = new FileContent();
272                        }
273
274                        return true;
275                }
276
277                @Override
278                public boolean onFileStart(File file) {
279                        boolean processFile = startFileProperties.getType() == FileType.FILE; // Ignore directories and symlinks!
280
281                        // We could fire an event here, but firing for every file
282                        // is very exhausting for the event bus.
283
284                        return processFile;
285                }
286
287                @Override
288                public void onFileEnd(File file, byte[] rawFileChecksum) {
289                        // Get file attributes (get them while file exists)
290
291                        // Note: Do NOT move any File-methods (file.anything()) below the file.exists()-part,
292                        // because the file could vanish!
293
294                        FileChecksum fileChecksum = (rawFileChecksum != null) ? new FileChecksum(rawFileChecksum) : null;
295                        endFileProperties = fileVersionComparator.captureFileProperties(file, fileChecksum, false);
296
297                        // Check if file has vanished
298                        boolean fileIsLocked = endFileProperties.isLocked();
299                        boolean fileVanished = !endFileProperties.exists();
300                        boolean fileHasChanged = startFileProperties.getSize() != endFileProperties.getSize()
301                                        || startFileProperties.getLastModified() != endFileProperties.getLastModified();
302
303                        if (fileVanished || fileIsLocked || fileHasChanged) {
304                                logger.log(Level.FINER, "- /File: {0}", file);
305                                logger.log(Level.INFO, "   * NOT ADDING because file has VANISHED (" + !endFileProperties.exists() + "), is LOCKED ("
306                                                + endFileProperties.isLocked() + "), or has CHANGED (" + fileHasChanged + ")");
307
308                                resetFileEnd();
309                                return;
310                        }
311
312                        // If it's still there, add it to the database
313                        addFileVersion(endFileProperties);
314
315                        // Reset
316                        resetFileEnd();
317                }
318
319                private void addFileVersion(FileProperties fileProperties) {
320                        if (fileProperties.getChecksum() != null) {
321                                logger.log(Level.FINER, "- /File: {0} (checksum {1})",
322                                                new Object[] { fileProperties.getRelativePath(), fileProperties.getChecksum() });
323                        }
324                        else {
325                                logger.log(Level.FINER, "- /File: {0} (directory/symlink/0-byte-file)", fileProperties.getRelativePath());
326                        }
327
328                        // 1. Determine if file already exists in database
329                        PartialFileHistory lastFileHistory = guessLastFileHistory(fileProperties);
330                        FileVersion lastFileVersion = (lastFileHistory != null) ? lastFileHistory.getLastVersion() : null;
331
332                        // 2. If file type changed, "close" the old file history by adding a file version that deletes the old file/directory
333                        PartialFileHistory deletedFileHistory = null;
334                        
335                        if (lastFileVersion != null && lastFileVersion.getType() != fileProperties.getType()) {
336                                logger.log(Level.FINER, "   * Detected change in file type. Deleting old file and starting a new file history.");
337
338                                deletedFileHistory = createFileHistoryForDeletion(lastFileHistory, lastFileVersion);
339                                lastFileHistory = null;
340                                lastFileVersion = null;
341                        }
342
343                        // 3. Create new file history/version
344                        PartialFileHistory fileHistory = createNewFileHistory(lastFileHistory);
345                        FileVersion fileVersion = createNewFileVersion(lastFileVersion, fileProperties);
346
347                        // 4. Compare new and last version
348                        FileProperties lastFileVersionProperties = fileVersionComparator.captureFileProperties(lastFileVersion);
349                        FileVersionComparison lastToNewFileVersionComparison = fileVersionComparator.compare(fileProperties, lastFileVersionProperties, true);
350
351                        boolean newVersionDiffersFromToLastVersion = !lastToNewFileVersionComparison.areEqual();
352
353                        if (newVersionDiffersFromToLastVersion) {
354                                fileHistory.addFileVersion(fileVersion);
355                                newDatabaseVersion.addFileHistory(fileHistory);
356                                
357                                if (deletedFileHistory != null) {
358                                        newDatabaseVersion.addFileHistory(deletedFileHistory);
359                                }
360
361                                logger.log(Level.INFO, "   * Added file version:    " + fileVersion);
362                                logger.log(Level.INFO, "     based on file version: " + lastFileVersion);
363
364                                fireHasChangesEvent();
365                        }
366                        else {
367                                logger.log(Level.INFO, "   * NOT ADDING file version: " + fileVersion);
368                                logger.log(Level.INFO, "         b/c IDENTICAL prev.: " + lastFileVersion);
369                        }
370
371                        // 4. Add file content (if not a directory)
372                        if (fileProperties.getChecksum() != null && fileContent != null) {
373                                fileContent.setSize(fileProperties.getSize());
374                                fileContent.setChecksum(fileProperties.getChecksum());
375
376                                // Check if content already exists, throw gathered content away if it does!
377                                FileContent existingContent = localDatabase.getFileContent(fileProperties.getChecksum(), false);
378
379                                if (existingContent == null) {
380                                        newDatabaseVersion.addFileContent(fileContent);
381                                }
382                                else {
383                                        // Uses existing content (already in database); ref. by checksum
384                                }
385                        }
386                }
387
388                private void fireHasChangesEvent() {
389                        boolean firstNewFileDetected = newDatabaseVersion.getFileHistories().size() == 1;
390
391                        if (firstNewFileDetected) { // Only fires once!
392                                eventBus.post(new UpIndexChangesDetectedSyncExternalEvent(config.getLocalDir().getAbsolutePath()));
393                        }
394                }
395
396                private PartialFileHistory createNewFileHistory(PartialFileHistory lastFileHistory) {
397                        if (lastFileHistory == null) {
398                                FileHistoryId newFileHistoryId = FileHistoryId.secureRandomFileId();
399                                return new PartialFileHistory(newFileHistoryId);
400                        }
401                        else {
402                                return new PartialFileHistory(lastFileHistory.getFileHistoryId());
403                        }
404                }
405
406                private FileVersion createNewFileVersion(FileVersion lastFileVersion, FileProperties fileProperties) {
407                        FileVersion fileVersion = null;
408
409                        // Version
410                        if (lastFileVersion == null) {
411                                fileVersion = new FileVersion();
412                                fileVersion.setVersion(1L);
413                                fileVersion.setStatus(FileStatus.NEW);
414                        }
415                        else {
416                                fileVersion = lastFileVersion.clone();
417                                fileVersion.setVersion(lastFileVersion.getVersion() + 1);
418                        }
419
420                        // Simple attributes
421                        fileVersion.setPath(fileProperties.getRelativePath());
422                        fileVersion.setLinkTarget(fileProperties.getLinkTarget());
423                        fileVersion.setType(fileProperties.getType());
424                        fileVersion.setSize(fileProperties.getSize());
425                        fileVersion.setChecksum(fileProperties.getChecksum());
426                        fileVersion.setLastModified(new Date(fileProperties.getLastModified()));
427                        fileVersion.setUpdated(new Date());
428
429                        // Permissions
430                        if (EnvironmentUtil.isWindows()) {
431                                fileVersion.setDosAttributes(fileProperties.getDosAttributes());
432
433                                if (fileVersion.getType() == FileType.FOLDER) {
434                                        fileVersion.setPosixPermissions(DEFAULT_POSIX_PERMISSIONS_FOLDER);
435                                }
436                                else {
437                                        fileVersion.setPosixPermissions(DEFAULT_POSIX_PERMISSIONS_FILE);
438                                }
439                        }
440                        else if (EnvironmentUtil.isUnixLikeOperatingSystem()) {
441                                fileVersion.setPosixPermissions(fileProperties.getPosixPermissions());
442                                fileVersion.setDosAttributes(DEFAULT_DOS_ATTRIBUTES);
443                        }
444
445                        // Status
446                        if (lastFileVersion != null) {
447                                if (fileVersion.getType() == FileType.FILE
448                                                && FileChecksum.fileChecksumEquals(fileVersion.getChecksum(), lastFileVersion.getChecksum())) {
449
450                                        fileVersion.setStatus(FileStatus.CHANGED);
451                                }
452                                else if (!fileVersion.getPath().equals(lastFileVersion.getPath())) {
453                                        fileVersion.setStatus(FileStatus.RENAMED);
454                                }
455                                else {
456                                        fileVersion.setStatus(FileStatus.CHANGED);
457                                }
458                        }
459
460                        return fileVersion;
461                }
462
463                private void resetFileEnd() {
464                        fileContent = null;
465                        startFileProperties = null;
466                        endFileProperties = null;
467                }
468
469                private PartialFileHistory guessLastFileHistory(FileProperties fileProperties) {
470                        if (fileProperties.getType() == FileType.FILE) {
471                                return guessLastFileHistoryForFile(fileProperties);
472                        }
473                        else if (fileProperties.getType() == FileType.SYMLINK) {
474                                return guessLastFileHistoryForSymlink(fileProperties);
475                        }
476                        else if (fileProperties.getType() == FileType.FOLDER) {
477                                return guessLastFileHistoryForFolder(fileProperties);
478                        }
479                        else {
480                                throw new RuntimeException("This should not happen.");
481                        }
482                }
483
484                private PartialFileHistory guessLastFileHistoryForSymlink(FileProperties fileProperties) {
485                        return guessLastFileHistoryForFolderOrSymlink(fileProperties);
486                }
487
488                private PartialFileHistory guessLastFileHistoryForFolder(FileProperties fileProperties) {
489                        return guessLastFileHistoryForFolderOrSymlink(fileProperties);
490                }
491
492                private PartialFileHistory guessLastFileHistoryForFolderOrSymlink(FileProperties fileProperties) {
493                        PartialFileHistory lastFileHistory = localDatabase.getFileHistoriesWithLastVersionByPath(fileProperties.getRelativePath());
494
495                        if (lastFileHistory == null) {
496                                logger.log(Level.FINER, "   * No old file history found, starting new history (path: " + fileProperties.getRelativePath() + ", "
497                                                + fileProperties.getType() + ")");
498                                return null;
499                        }
500                        else {
501                                logger.log(Level.FINER,
502                                                "   * Found old file history " + lastFileHistory.getFileHistoryId() + " (by path: " + fileProperties.getRelativePath()
503                                                                + "), " + fileProperties.getType() + ", appending new version.");
504                                return lastFileHistory;
505                        }
506                }
507
508                /**
509                 * Tries to guess a matching file history, first by path and then by matching checksum.
510                 * 
511                 * <p>If the path matches the path of an existing file in the database, the file history 
512                 * from the database is used, and a new file version is appended. If there is no file 
513                 * in the database with that path, checksums are compared.
514                 * 
515                 * <p>If there are more than one file with the same checksum (potential matches), the file
516                 * with the closest path is chosen.
517                 */
518                private PartialFileHistory guessLastFileHistoryForFile(FileProperties fileProperties) {
519                        PartialFileHistory lastFileHistory = null;
520
521                        // a) Try finding a file history for which the last version has the same path
522                        lastFileHistory = localDatabase.getFileHistoriesWithLastVersionByPath(fileProperties.getRelativePath());
523
524                        // b) If that fails, try finding files with a matching checksum
525                        if (lastFileHistory == null) {
526                                if (fileProperties.getChecksum() != null) {
527                                        Collection<PartialFileHistory> fileHistoriesWithSameChecksum = localDatabase
528                                                        .getFileHistoriesWithLastVersionByChecksumSizeAndModifiedDate(fileProperties.getChecksum().toString(),
529                                                                        fileProperties.getSize(), new Date(fileProperties.getLastModified()));
530
531                                        if (fileHistoriesWithSameChecksum != null && fileHistoriesWithSameChecksum.size() > 0) {
532                                                fileHistoriesWithSameChecksum.removeAll(newDatabaseVersion.getFileHistories());
533                                                lastFileHistory = guessLastFileHistoryForFileWithMatchingChecksum(fileProperties, fileHistoriesWithSameChecksum);
534                                        }
535                                }
536
537                                if (lastFileHistory == null) {
538                                        logger.log(Level.FINER, "   * No old file history found, starting new history (path: " + fileProperties.getRelativePath()
539                                                        + ", checksum: " + fileProperties.getChecksum() + ")");
540                                        return null;
541                                }
542                                else {
543                                        logger.log(Level.FINER,
544                                                        "   * Found old file history " + lastFileHistory.getFileHistoryId() + " (by checksum: " + fileProperties.getChecksum()
545                                                                        + "), appending new version.");
546                                        return lastFileHistory;
547                                }
548                        }
549                        else {
550                                logger.log(Level.FINER,
551                                                "   * Found old file history " + lastFileHistory.getFileHistoryId() + " (by path: " + fileProperties.getRelativePath()
552                                                                + "), appending new version.");
553                                return lastFileHistory;
554                        }
555                }
556
557                private PartialFileHistory guessLastFileHistoryForFileWithMatchingChecksum(FileProperties fileProperties,
558                                Collection<PartialFileHistory> fileHistoriesWithSameChecksum) {
559                        PartialFileHistory lastFileHistory = null;
560
561                        // Check if they do not exist anymore --> assume it has moved!
562                        // We choose the best fileHistory to base on as follows:
563
564                        // 1. Ensure that it was modified at the same time and is the same size
565                        // 2. Check the fileHistory was deleted and the file does not actually exists
566                        // 3. Choose the one with the longest matching tail of the path to the new path
567
568                        for (PartialFileHistory fileHistoryWithSameChecksum : fileHistoriesWithSameChecksum) {
569                                FileVersion lastVersion = fileHistoryWithSameChecksum.getLastVersion();
570
571                                if (fileProperties.getLastModified() != lastVersion.getLastModified().getTime() || fileProperties.getSize() != lastVersion.getSize()) {
572                                        continue;
573                                }
574
575                                File lastVersionOnLocalDisk = new File(config.getLocalDir() + File.separator + lastVersion.getPath());
576
577                                if (lastVersion.getStatus() != FileStatus.DELETED && !FileUtil.exists(lastVersionOnLocalDisk)) {
578                                        if (lastFileHistory == null) {
579                                                lastFileHistory = fileHistoryWithSameChecksum;
580                                        }
581                                        else {
582                                                String filePath = fileProperties.getRelativePath();
583                                                String currentPreviousPath = lastFileHistory.getLastVersion().getPath();
584                                                String candidatePreviousPath = fileHistoryWithSameChecksum.getLastVersion().getPath();
585
586                                                for (int i = 0; i < filePath.length(); i++) {
587                                                        if (!filePath.regionMatches(filePath.length() - i, candidatePreviousPath, candidatePreviousPath.length() - i, i)) {
588                                                                // The candidate no longer matches, take the current path.
589                                                                break;
590                                                        }
591                                                        
592                                                        if (!filePath.regionMatches(filePath.length() - i, currentPreviousPath, currentPreviousPath.length() - i, i)) {
593                                                                // The current previous path no longer matches, take the new candidate
594                                                                lastFileHistory = fileHistoryWithSameChecksum;
595                                                                break;
596                                                        }
597                                                }
598                                        }
599                                }
600                        }
601
602                        return lastFileHistory;
603                }
604
605                @Override
606                public void onMultiChunkOpen(MultiChunk multiChunk) {
607                        logger.log(Level.FINER, "- +MultiChunk {0}", multiChunk.getId());
608                        multiChunkEntry = new MultiChunkEntry(multiChunk.getId(), 0); // size unknown so far
609                }
610
611                @Override
612                public void onMultiChunkWrite(MultiChunk multiChunk, Chunk chunk) {
613                        logger.log(Level.FINER, "- Chunk > MultiChunk: {0} > {1}", new Object[] { StringUtil.toHex(chunk.getChecksum()), multiChunk.getId() });
614                        multiChunkEntry.addChunk(chunkEntry.getChecksum());
615                }
616
617                @Override
618                public void onMultiChunkClose(MultiChunk multiChunk) {
619                        logger.log(Level.FINER, "- /MultiChunk {0}", multiChunk.getId());
620
621                        multiChunkEntry.setSize(multiChunk.getSize());
622
623                        newDatabaseVersion.addMultiChunk(multiChunkEntry);
624                        multiChunkEntry = null;
625                }
626
627                @Override
628                public File getMultiChunkFile(MultiChunkId multiChunkId) {
629                        return config.getCache().getEncryptedMultiChunkFile(multiChunkId);
630                }
631
632                @Override
633                public MultiChunkId createNewMultiChunkId(Chunk firstChunk) {
634                        byte[] newMultiChunkId = new byte[firstChunk.getChecksum().length];
635                        secureRandom.nextBytes(newMultiChunkId);
636
637                        return new MultiChunkId(newMultiChunkId);
638                }
639
640                @Override
641                public void onFileAddChunk(File file, Chunk chunk) {
642                        logger.log(Level.FINER, "- Chunk > FileContent: {0} > {1}", new Object[] { StringUtil.toHex(chunk.getChecksum()), file });
643                        fileContent.addChunk(new ChunkChecksum(chunk.getChecksum()));
644                }
645
646                @Override
647                public void onStart(int fileCount) {
648                        eventBus.post(new UpIndexStartSyncExternalEvent(config.getLocalDir().getAbsolutePath(), fileCount));
649                }
650
651                @Override
652                public void onFinish() {
653                        eventBus.post(new UpIndexEndSyncExternalEvent(config.getLocalDir().getAbsolutePath()));
654                }
655
656                /**
657                 * Checks if chunk already exists in all database versions
658                 * Afterwards checks if chunk exists in new introduced database version. 
659                 */
660                @Override
661                public boolean onChunk(Chunk chunk) {
662                        ChunkChecksum chunkChecksum = new ChunkChecksum(chunk.getChecksum());
663                        chunkEntry = localDatabase.getChunk(chunkChecksum);
664
665                        if (chunkEntry == null) {
666                                chunkEntry = newDatabaseVersion.getChunk(chunkChecksum);
667
668                                if (chunkEntry == null) {
669                                        logger.log(Level.FINER, "- Chunk new: {0}", chunkChecksum.toString());
670
671                                        chunkEntry = new ChunkEntry(chunkChecksum, chunk.getSize());
672                                        newDatabaseVersion.addChunk(chunkEntry);
673
674                                        return true;
675                                }
676                        }
677
678                        logger.log(Level.FINER, "- Chunk exists: {0}", StringUtil.toHex(chunk.getChecksum()));
679                        return false;
680                }
681        }
682}