Java tutorial
/** * MiBox Client - folder synchronization client * Copyright (C) 2011 Wladislaw Mitzel * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.wlami.mibox.client.metadata; import static com.wlami.mibox.client.application.DebugUtil.isDecryptedDebugEnabled; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.channels.FileChannel; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.util.Date; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentSkipListSet; import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang.StringUtils; import org.bouncycastle.crypto.CryptoException; import org.codehaus.jackson.map.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.wlami.mibox.client.application.AppSettings; import com.wlami.mibox.client.application.AppSettingsDao; import com.wlami.mibox.client.exception.CryptoRuntimeException; import com.wlami.mibox.client.metadata2.DecryptedMiTree; import com.wlami.mibox.client.metadata2.EncryptableDecryptedMiTree; import com.wlami.mibox.client.metadata2.EncryptedMiTree; import com.wlami.mibox.client.metadata2.EncryptedMiTreeInformation; import com.wlami.mibox.client.metadata2.EncryptedMiTreeRepository; import com.wlami.mibox.client.metadata2.MetaMetaDataHolder; import com.wlami.mibox.client.networking.encryption.AesChunkEncryption; import com.wlami.mibox.client.networking.encryption.ChunkEncryption; import com.wlami.mibox.client.networking.encryption.DataChunk; import com.wlami.mibox.client.networking.synchronization.ChunkUploadRequest; import com.wlami.mibox.client.networking.synchronization.DownloadRequest; import com.wlami.mibox.client.networking.synchronization.RequestContainer; import com.wlami.mibox.client.networking.synchronization.TransportCallback; import com.wlami.mibox.client.networking.synchronization.TransportProvider; import com.wlami.mibox.core.util.HashUtil; /** * This class represents the worker thread, which is controlled the * {@link MetadataRepositoryImpl}. * * @author Wladislaw Mitzel * */ public class MetadataWorker extends Thread { /** * */ public static final String CALLBACK_PARAM_CONTENT = "content"; /** * */ public static final String CALLBACK_PARAM_ENCRYPTED_CHUNK_HASH = "encryptedChunkHash"; /** Defines the period between writes of metadata in seconds. */ protected static final int WRITE_PERIOD_SECONDS = 60; /** internal logger. */ private static final Logger log = LoggerFactory.getLogger(MetadataWorker.class); /** * Defines the current state. Thread runs until active is set to false. Then * the thread dies and a new Worker has to be created. */ private boolean active = true; /** Time period between checking the incoming set. */ private static final long DEFAULT_SLEEP_TIME_MILLIS = 250L; /** reference to a loader */ private final EncryptedMiTreeRepository encryptedMiTreeRepo; /** * set of incoming {@link ObservedFilesystemEvent} instances which is * acquired from the {@link MetadataRepository}. */ private ConcurrentSkipListSet<ObservedFilesystemEvent> incomingEvents = new ConcurrentSkipListSet<ObservedFilesystemEvent>(); /** Reference to the {@link AppSettingsDao} bean. */ final AppSettingsDao appSettingsDao; /** Reference to the {@link TransportProvider} bean. */ TransportProvider<ChunkUploadRequest> transportProvider; /** Reference to a encryption implementation */ final ChunkEncryption chunkEncryption; /** * @param active * the active to set */ protected void setActive(boolean active) { this.active = active; } private final MetadataUtil metadataUtil; private MetaMetaDataHolder metaMetaDataHolder; private AppSettings appSettings; /** * Default constructor. Loads appSettings from {@link AppSettingsDao} and * the metadata from disk. */ public MetadataWorker(AppSettingsDao appSettingsDao, TransportProvider<ChunkUploadRequest> transportProvider, ConcurrentSkipListSet<ObservedFilesystemEvent> incomingEvents, EncryptedMiTreeRepository encryptedMiTreeRepo, MetadataUtil metadataUtil, MetaMetaDataHolder metaMetaDataHolder, ChunkEncryption chunkEncryption) { this.incomingEvents = incomingEvents; this.appSettingsDao = appSettingsDao; this.transportProvider = transportProvider; this.encryptedMiTreeRepo = encryptedMiTreeRepo; this.metadataUtil = metadataUtil; this.chunkEncryption = chunkEncryption; this.metaMetaDataHolder = metaMetaDataHolder; } /** * Synchronizes the metadata and the state of the file system. For this * purpose the following algorithm is used: * <ol> * <li>update the root node to match the {@link AppSettings} .watchDirectory * </li> * <li>traverse the file system recursively * <ol> * <li>add non existing {@link MFolder}s</li> * <li>add non existing {@link MFile}s or update them if they exist</li> * </ol> * </li> * </ol> * * @throws IOException * Thrown on io errors. */ private void synchronizeFilesystemWithLocalMetadata() throws IOException { log.debug("Synchronizing metadata with filesystem"); AppSettings appSettings = appSettingsDao.load(); String watchDir = appSettings.getWatchDirectory(); EncryptedMiTreeInformation miTreeInformation = metaMetaDataHolder.getDecryptedMetaMetaData().getRoot(); EncryptedMiTree encryptedRoot = encryptedMiTreeRepo.loadEncryptedMiTree(miTreeInformation.getFileName()); DecryptedMiTree root; if (encryptedRoot == null) { root = new DecryptedMiTree(); root.setFolderName("/"); } else { root = encryptedRoot.decrypt(miTreeInformation.getKey(), miTreeInformation.getIv()); } traverseFileSystem(new File(watchDir), root, miTreeInformation); } /** * * @param rootFolder * @param decryptedMiTree * MUST NOT BE NULL! */ public void traverseFileSystem(File rootFolder, DecryptedMiTree decryptedMiTree, EncryptedMiTreeInformation miTreeInformation) { if (!rootFolder.exists()) { log.error("folder does not exist! [{}]", rootFolder); return; } log.debug("Traversing file system. Processing folder [{}]", rootFolder.getName()); for (File file : rootFolder.listFiles()) { if (file.isFile()) { MFile mFile; log.debug("Processing file [{}]", file.getName()); if (decryptedMiTree.getFiles().containsKey(file.getName())) { // There is a matching MFile. mFile = decryptedMiTree.getFiles().get(file.getName()); log.debug("Found file in metadata. [{}]", file.getName()); } else { // There is no matching file, so we create it first mFile = new MFile(); mFile.setName(file.getName()); decryptedMiTree.getFiles().put(mFile.getName(), mFile); log.debug("Creating a new MFile in metadata for [{}]", file.getName()); } synchronizeFileMetadata(file, mFile); } else if (file.isDirectory()) { // find the right metadata log.debug("Search folder metadata for [{}]", file.getName()); EncryptedMiTreeInformation encryptedMiTreeInformation = decryptedMiTree.getSubfolder() .get(file.getName()); if (encryptedMiTreeInformation == null) { // Let's create a new subtree! DecryptedMiTree subTree = new DecryptedMiTree(); subTree.setFolderName(file.getName()); encryptedMiTreeInformation = EncryptedMiTreeInformation.createRandom(); decryptedMiTree.getSubfolder().put(file.getName(), encryptedMiTreeInformation); log.debug("No information available yet. Creating new data file [{}]", encryptedMiTreeInformation.getFileName()); traverseFileSystem(file, subTree, encryptedMiTreeInformation); } else { // load the metadata for the subtree log.debug("Found information for folder. Trying to load it"); EncryptedMiTree encryptedMiTree = encryptedMiTreeRepo .loadEncryptedMiTree(encryptedMiTreeInformation.getFileName()); log.debug("Trying to decrypt the metadata now."); DecryptedMiTree subTree = encryptedMiTree.decrypt(encryptedMiTreeInformation.getKey(), encryptedMiTreeInformation.getIv()); traverseFileSystem(file, subTree, encryptedMiTreeInformation); } } } // Save the tree EncryptedMiTree encryptedMiTree = decryptedMiTree.encrypt(miTreeInformation.getFileName(), miTreeInformation.getKey(), miTreeInformation.getIv()); encryptedMiTreeRepo.saveEncryptedMiTree(encryptedMiTree, miTreeInformation.getFileName()); } public void synchronizeLocalMetadataWithRemoteMetadata() { log.info("Starting complete synchronization of mibox with remote metadata"); EncryptedMiTreeInformation rootInfo = metaMetaDataHolder.getDecryptedMetaMetaData().getRoot(); EncryptedMiTree localRootEncrypted = encryptedMiTreeRepo.loadEncryptedMiTree(rootInfo.getFileName()); DecryptedMiTree localRoot = null; if (localRootEncrypted != null) { log.debug("decrypting local root"); localRoot = localRootEncrypted.decrypt(rootInfo.getKey(), rootInfo.getIv()); } EncryptedMiTree remoteRootEncrypted = encryptedMiTreeRepo.loadRemoteEncryptedMiTree(rootInfo.getFileName()); DecryptedMiTree remoteRoot = remoteRootEncrypted.decrypt(rootInfo.getKey(), rootInfo.getIv()); appSettings = appSettingsDao.load(); File file = new File(appSettings.getWatchDirectory()); synchronizeLocalMetadataWithRemoteMetadata(file, localRoot, remoteRoot); } public void synchronizeLocalMetadataWithRemoteMetadata(File f, DecryptedMiTree local, DecryptedMiTree remote) { log.info("starting incoming synchronization of folder [{}]", f.getAbsolutePath()); if (local == null) { log.debug("local metadata not available for folder [{}]", f.getAbsolutePath()); // In this case the incoming folder is new and we need to create a // local folder local = new DecryptedMiTree(); local.setFolderName(f.getName()); } // Remember which files got processed in the first loop Set<String> processedFiles = new HashSet<>(); // Get all files from the local metadata and compare them to the remote // metadata. for (String localMFileName : local.getFiles().keySet()) { log.debug("Comparing MFiles for [{}]", localMFileName); MFile localMFile = local.getFiles().get(localMFileName); MFile remoteMFile = remote.getFiles().get(localMFileName); // if the remote file is newer than the local file we want to update // it if (remoteMFile == null || remoteMFile.getLastModified().after(localMFile.getLastModified())) { log.info("remote file is newer than local file. will request download for [{}]", localMFileName); File file = new File(f, localMFileName); updateFileFromMetadata(file, localMFile, remoteMFile); } // we remember which files has been processed by us processedFiles.add(localMFileName); } // Now we want to iterate over all remote files which have not been // processed yet. This for we remove already processed files from the // remote files. Set<String> newRemoteFileNames = new HashSet<>(remote.getFiles().keySet()); newRemoteFileNames.removeAll(processedFiles); for (String remoteMFileName : newRemoteFileNames) { log.info("incoming new file [{}]", remoteMFileName); MFile localMFile = null; MFile remoteFile = remote.getFiles().get(remoteMFileName); File file = new File(f, remoteMFileName); updateFileFromMetadata(file, localMFile, remoteFile); } // TODO don't forget to update the local metadata if a file got // updated!!! // And now lets compare the subfolders Map<String, EncryptedMiTreeInformation> localSubFolders = local.getSubfolder(); Map<String, EncryptedMiTreeInformation> remoteSubFolders = remote.getSubfolder(); Set<String> processedFolders = new HashSet<>(); for (String localFolderName : localSubFolders.keySet()) { EncryptedMiTreeInformation localMiTreeInfo = localSubFolders.get(localFolderName); DecryptedMiTree localMiTree = encryptedMiTreeRepo.loadEncryptedMiTree(localMiTreeInfo.getFileName()) .decrypt(localMiTreeInfo.getKey(), localMiTreeInfo.getIv()); // TODO what happens if the folder has been deleted on another // client EncryptedMiTree remoteMiTreeEncrypted = encryptedMiTreeRepo .loadRemoteEncryptedMiTree(localMiTreeInfo.getFileName()); DecryptedMiTree remoteMiTree = remoteMiTreeEncrypted.decrypt(localMiTreeInfo.getKey(), localMiTreeInfo.getIv()); synchronizeLocalMetadataWithRemoteMetadata(new File(f, localFolderName), localMiTree, remoteMiTree); processedFolders.add(localFolderName); } Set<String> newRemoteFolders = new HashSet<>(remoteSubFolders.keySet()); newRemoteFolders.removeAll(processedFolders); for (String remoteFolderName : newRemoteFolders) { EncryptedMiTreeInformation localMiTreeInfo = remoteSubFolders.get(remoteFolderName); DecryptedMiTree localMiTree = null; EncryptedMiTree remoteMiTreeEncrypted = encryptedMiTreeRepo .loadRemoteEncryptedMiTree(localMiTreeInfo.getFileName()); DecryptedMiTree remoteMiTree = remoteMiTreeEncrypted.decrypt(localMiTreeInfo.getKey(), localMiTreeInfo.getIv()); synchronizeLocalMetadataWithRemoteMetadata(new File(f, remoteFolderName), localMiTree, remoteMiTree); } } /** * Synchronizes incoming MFile with the local metadata. * * If the file already exists on the filesystem it gets updated. Otherwise * the file is created. * * <b>Warning: both params must not be null at the same time!</b> * * @param file * File to update. May be non-existent. * @param localMFile * the local metadata. may be null if there is no local file yet. * @param incomingMFile * the incoming metadata. may be null if a local file shall be * deleted. * * */ public void updateFileFromMetadata(final File file, final MFile localMFile, final MFile incomingMFile) { if (localMFile == null && incomingMFile == null) { return; } if (localMFile != null) { // probably a change to local file } else { // probably a new file final AppSettings appSettings = appSettingsDao.load(); final RequestContainer<DownloadRequest> downloadRequestContainer = new RequestContainer<>(); for (MChunk currentMChunk : incomingMFile.getChunks()) { final MChunk mChunk = currentMChunk; final DownloadRequest request = new DownloadRequest(currentMChunk.getEncryptedChunkHash()); request.setTransportCallback(createChunkDownloadCompletedCallback(file, appSettings, downloadRequestContainer, mChunk, request)); downloadRequestContainer.add(request); } downloadRequestContainer.setAllChildrenCompletedCallback( createDownloadContainerFinishedCallback(file, incomingMFile, appSettings)); transportProvider.addDownloadContainer(downloadRequestContainer); } log.debug("start file update from incoming metadata! [{}]", incomingMFile != null ? incomingMFile.getName() : ""); } /** * Creates a callback which is executed when all chunk downloads have been * successfully processed. In this callback the downloaded and decrypted * chunks (which are stored in the temp directory) are written to the target * file in the right order. Afterwards the temporary chunks are delted. * * @param file * The target file which is created from all temporary chunks. * @param incomingMFile * Metadata containing information on the file. * @param appSettings * The settings are used for the retrieval of the temp-dir path. * @return A callback for finished download containers. */ public TransportCallback createDownloadContainerFinishedCallback(final File file, final MFile incomingMFile, final AppSettings appSettings) { return new TransportCallback() { @Override public void transportCallback(Map<String, Object> parameter) { File parent = new File(file.getParent()); if (!parent.exists()) { parent.mkdirs(); } try (FileOutputStream fos = new FileOutputStream(file); FileChannel channelDestination = fos.getChannel()) { long position = 0; for (MChunk mChunk : incomingMFile.getChunks()) { String pathHash = HashUtil.calculateSha256(file.getAbsolutePath().getBytes()); String tmpfilename = pathHash + "." + mChunk.getPosition(); File decryptedChunkFile = new File(appSettings.getTempDirectory(), tmpfilename); try (FileInputStream fis = new FileInputStream(decryptedChunkFile); FileChannel channelSource = fis.getChannel()) { channelDestination.transferFrom(channelSource, position, decryptedChunkFile.length()); position += decryptedChunkFile.length(); } } for (MChunk mChunk : incomingMFile.getChunks()) { String pathHash = HashUtil.calculateSha256(file.getAbsolutePath().getBytes()); String tmpfilename = pathHash + "." + mChunk.getPosition(); File decryptedChunkFile = new File(appSettings.getTempDirectory(), tmpfilename); decryptedChunkFile.delete(); } } catch (Exception ioe) { throw new RuntimeException(ioe); } } }; } /** * Creates a callback for {@link DownloadRequest}s. In this callback the * downloaded chunk content gets decrypted and written to the temporary * folder. * * @param file * this file will contain the decrypted chunk as a part of it. * @param appSettings * The settings are used for the retrieval of the temp directory * path. * @param downloadRequestContainer * A reference to the container the chunk belongs to. The * callback has to tell the container that the request has been * processed. * @param mChunk * The mChunk which has been processed for this callback. * @param request * The request which triggered this callback. * @return A callback for chunk {@link DownloadRequest}s. */ public TransportCallback createChunkDownloadCompletedCallback(final File file, final AppSettings appSettings, final RequestContainer<DownloadRequest> downloadRequestContainer, final MChunk mChunk, final DownloadRequest request) { return new TransportCallback() { @Override public void transportCallback(Map<String, Object> parameter) { byte[] content = (byte[]) parameter.get(CALLBACK_PARAM_CONTENT); DataChunk decrypted = chunkEncryption.decryptChunk(mChunk, content); String pathHash = HashUtil.calculateSha256(file.getAbsolutePath().getBytes()); String tmpfilename = pathHash + "." + mChunk.getPosition(); File decryptedChunkFile = new File(appSettings.getTempDirectory(), tmpfilename); try (FileOutputStream fos = new FileOutputStream(decryptedChunkFile)) { fos.write(decrypted.getContent()); } catch (IOException e) { throw new RuntimeException(e); } downloadRequestContainer.oneChildCompleted(request); } }; } /** * Synchronizes the file system state with the metadata.<br/> * <br/> * First checks whether file system lastModified date is later than the * metadata. In this case the hashes are updated. * * @param f * Referende to the filesystem file. * @param mFile * Reference to the metadata file. */ private void synchronizeFileMetadata(final File f, final MFile mFile) { // Check whether the file has been modified since the last meta sync log.debug("Start synchronization for file [{}]", f.getAbsolutePath()); final Date filesystemLastModified = new Date(f.lastModified()); if ((mFile.getLastModified() == null) || (filesystemLastModified.after(mFile.getLastModified()))) { // The file has been modified, so we have to update metadata log.debug("File newer than last modification date. Calculating file and chunk hashes for [{}]", f.getName()); try { RequestContainer<ChunkUploadRequest> uploadContainer = new RequestContainer<>(); // create two digests. One is for the whole file. The other // is for the chunks and gets reseted after each chunk. MessageDigest fileDigest = MessageDigest.getInstance(HashUtil.SHA_256_MESSAGE_DIGEST, "BC"); MessageDigest chunkDigest = MessageDigest.getInstance(HashUtil.SHA_256_MESSAGE_DIGEST, "BC"); FileInputStream fileInputStream = new FileInputStream(f); int readBytes = 0; int currentChunk = 0; int chunkSize = mFile.getChunkSize(); // Read the file until EOF == -1 byte[] currentBytes = new byte[chunkSize]; while ((readBytes = fileInputStream.read(currentBytes)) != -1) { fileDigest.update(currentBytes, 0, readBytes); chunkDigest.update(currentBytes, 0, readBytes); // If we have finished the chunk MChunk chunk; // Check whether we have the chunk data already if (mFile.getChunks().size() > currentChunk) { // We found the chunk chunk = mFile.getChunks().get(currentChunk); } else { // There is no chunk and we create a new one. chunk = new MChunk(currentChunk); mFile.getChunks().add(chunk); chunk.setMFile(mFile); } String newChunkHash = HashUtil.digestToString(chunkDigest.digest()); if (!newChunkHash.equals(chunk.getDecryptedChunkHash())) { chunk.setDecryptedChunkHash(newChunkHash); log.debug("New Chunk [{}] finished with hash [{}]", currentChunk, newChunkHash); // Create Upload request uploadContainer.add(createUploadRequest(chunk, f, uploadContainer)); } currentChunk++; } mFile.setFileHash(HashUtil.digestToString(fileDigest.digest())); mFile.setLastModified(filesystemLastModified); // Define a callback which is executed when all chunks have been // uploaded. uploadContainer.setAllChildrenCompletedCallback(createUploadContainerFinishedCallback(f, mFile)); transportProvider.addUploadContainer(uploadContainer); } catch (NoSuchAlgorithmException e) { log.error("No SHA availabe", e); } catch (IOException | NoSuchProviderException e) { log.error("", e); } } else { log.debug("The file has not been modified [{}]", f.getName()); } } /** * Creates a Callback which is executed when all uploads for a file have * been successfully executed. The callback persists the metadata ( * {@link MFile} ) which got updated during the upload callback of each * chunk. At this point the {@link MChunk} inside the mFile contain the new * encrypted chunk hash. * * @param f * The current file which has been uploaded. * @param mFile * The metadata of the file. This metadata gets persisted in the * callback. * @return A callback for finished upload containers. */ public TransportCallback createUploadContainerFinishedCallback(final File f, final MFile mFile) { return new TransportCallback() { @Override public void transportCallback(Map<String, Object> parameter) { AppSettings appSettings = appSettingsDao.load(); try { EncryptableDecryptedMiTree decryptedMiTree = metadataUtil .locateDecryptedMiTree(getRelativePath(appSettings, f.getAbsolutePath())); decryptedMiTree.getDecryptedMiTree().getFiles().put(mFile.getName(), mFile); EncryptedMiTree encryptedMiTree = decryptedMiTree.encrypt(); encryptedMiTreeRepo.saveEncryptedMiTree(encryptedMiTree, decryptedMiTree.getEncryptedMiTreeInformation().getFileName()); } catch (CryptoException | IOException e) { throw new CryptoRuntimeException(e); } } }; } /** * Creates a {@link ChunkUploadRequest} object and turns it over to the * {@link TransportProvider}. Persists the metadata to disk. * * @param chunk * the chunk which shall be uploaded. */ protected ChunkUploadRequest createUploadRequest(final MChunk chunk, File file, final RequestContainer<ChunkUploadRequest> requestContainer) { log.debug("Creating upload request for chunk [{}]", chunk.getDecryptedChunkHash()); // TODO inject encryption provider final ChunkUploadRequest mChunkUpload = new ChunkUploadRequest(chunk, file, null, new AesChunkEncryption()); mChunkUpload.setUploadCallback(new TransportCallback() { @Override public void transportCallback(Map<String, Object> parameter) { String encryptedHash = (String) parameter.get(CALLBACK_PARAM_ENCRYPTED_CHUNK_HASH); chunk.setEncryptedChunkHash(encryptedHash); chunk.setLastSync(new Date()); requestContainer.oneChildCompleted(mChunkUpload); if (isDecryptedDebugEnabled()) { try { ObjectMapper objectMapper = new ObjectMapper(); System.out.println(objectMapper.writeValueAsString(chunk)); } catch (Exception e) { } } } }); log.debug("returning upload request [{}]", mChunkUpload); return mChunkUpload; } /* * (non-Javadoc) * * @see java.lang.Thread#run() */ @Override public void run() { log.debug("Starting"); try { synchronizeFilesystemWithLocalMetadata(); AppSettings appSettings = appSettingsDao.load(); while (active) { ObservedFilesystemEvent ofe; while ((ofe = incomingEvents.pollFirst()) != null) { log.debug("Processing event " + ofe); File f = new File(ofe.getFilename()); if (f.isFile()) { String filename = ofe.getFilename(); String relativePath = getRelativePath(appSettings, filename); System.out.println(relativePath); EncryptedMiTreeInformation miTreeInformation = metaMetaDataHolder.getDecryptedMetaMetaData() .getRoot(); EncryptedMiTree encryptedRoot = encryptedMiTreeRepo .loadEncryptedMiTree(miTreeInformation.getFileName()); MFile mFile = metadataUtil.locateMFile(relativePath); synchronizeFileMetadata(f, mFile); } incomingEvents.remove(ofe); } try { Thread.sleep(DEFAULT_SLEEP_TIME_MILLIS); } catch (InterruptedException e) { } } } catch (IOException e) { log.error("Cannot load Appsettings - MetadataRepository cannot be started!", e); } catch (CryptoException e) { log.error("Cannot decrypt metadata!", e); e.printStackTrace(); } } /** * @param appSettings * @param filename * @return */ public String getRelativePath(AppSettings appSettings, String filename) { return StringUtils.substringAfter(FilenameUtils.separatorsToUnix(filename), appSettings.getWatchDirectory()); } }