package mil.nga.dice.report;
import android.Manifest;
import android.app.Activity;
import android.app.Dialog;
import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.provider.OpenableColumns;
import android.provider.Settings;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AlertDialog;
import android.util.Log;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import mil.nga.dice.DICEConstants;
import mil.nga.dice.R;
import mil.nga.dice.ReportCollectionActivity;
import mil.nga.geopackage.GeoPackageConstants;
import mil.nga.geopackage.GeoPackageManager;
import mil.nga.geopackage.factory.GeoPackageFactory;
/**
* TODO: modify to look for content roots in report dir instead of tying to source file
*/
public class ReportManager implements ReportImportCallbacks {
public static final String INTENT_END_REFRESH_REPORT_LIST = "mil.nga.giat.dice.ReportManager.END_REFRESH_REPORT_LIST";
public static final String INTENT_UPDATE_REPORT_LIST = "mil.nga.giat.dice.ReportManager.UPDATE_REPORT_LIST";
public static final String USER_GUIDE_REPORT_ID = "mil.nga.giat.dice.downloadUserGuide";
private static final String TAG = ReportManager.class.getSimpleName();
private static final long STABILITY_CHECK_INTERVAL = 250;
private static final int MIN_STABILITY_CHECKS = 2;
private Report userGuideReport = new Report();
public static synchronized ReportManager initialize(Context context) {
return instance = new ReportManager(context);
}
public static ReportManager getInstance() {
if (instance == null) {
throw new Error("ReportManager has not been properly initialized");
}
return instance;
}
private static ReportManager instance;
private static void ensureUiThread() {
if (Looper.getMainLooper() != Looper.myLooper()) {
throw new Error("not on ui thread!");
}
}
private final List<Report> reports = new ArrayList<>();
private final List<Report> reportsView = Collections.unmodifiableList(reports);
private final Context context;
private final File reportsDir;
private final File notesDir;
private final String externalContentThumbnail;
private final Drawable externalContentIcon;
private final Drawable thumbnailMissingIcon;
private ScheduledExecutorService scheduledExecutor;
private ExecutorService importExecutor;
private Handler handler;
private boolean directoriesCreated = false;
public ReportManager(Context context) {
super();
if (instance != null) {
throw new Error("too many ReportManager instances");
}
this.context = context.getApplicationContext();
externalContentThumbnail = ContentResolver.SCHEME_ANDROID_RESOURCE + "://" +
context.getApplicationInfo().packageName + "/" + String.valueOf(R.drawable.ic_launch);
externalContentIcon = loadExternalLaunchIcon();
thumbnailMissingIcon = loadThumbnailMissingIcon();
final ThreadFactory defaultThreadFactory = Executors.defaultThreadFactory();
ThreadFactory backgroundThreads = new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = defaultThreadFactory.newThread(r);
t.setPriority(Process.THREAD_PRIORITY_BACKGROUND);
return t;
}
};
scheduledExecutor = Executors.newSingleThreadScheduledExecutor(backgroundThreads);
int coreThreadCount = Math.max(Runtime.getRuntime().availableProcessors() - 1, 1);
Log.d(TAG, "initializing import thread pool with " + coreThreadCount + " core threads");
ThreadPoolExecutor executor = new ThreadPoolExecutor(coreThreadCount, coreThreadCount,
30, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(), backgroundThreads);
executor.allowCoreThreadTimeOut(true);
importExecutor = executor;
handler = new Handler(Looper.getMainLooper());
reportsDir = ReportUtils.getReportDirectory();
notesDir = new File(reportsDir, DICEConstants.DICE_REPORT_NOTES_DIRECTORY);
}
public void destroy() {
Log.i(TAG, "destruction");
scheduledExecutor.shutdown();
scheduledExecutor = null;
importExecutor.shutdown();
importExecutor = null;
handler = null;
instance = null;
}
/**
* Return a live, read-only list of the processed reports.
* @return
*/
public List<Report> getReports() {
return reportsView;
}
public void refreshReports(final Activity activity) {
removeDeletedAndErrorReports();
// If we have external storage permission, refresh the reports
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
refreshReportsWithPermissions(activity, true);
} else {
// Should we justify why we need permission?
if (ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
new AlertDialog.Builder(activity, R.style.AppCompatAlertDialogStyle)
.setTitle(R.string.storage_access_rational_title)
.setMessage(R.string.storage_access_rational_message)
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
// Request permission
ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ReportCollectionActivity.PERMISSIONS_REQUEST_REPORTS_ACCESS);
}
})
.create()
.show();
} else {
// Request permission
ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ReportCollectionActivity.PERMISSIONS_REQUEST_REPORTS_ACCESS);
}
}
}
/**
* Refresh the reports after attempting to obtain external storage permission
*
* @param granted true if granted, false if denied
*/
public void refreshReportsWithPermissions(final Activity activity, boolean granted) {
if(granted) {
createDirectories();
findExistingReports();
broadcastUpdateReportList();
}else{
// If the user has declared to no longer get asked about permissions
if (!ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.READ_EXTERNAL_STORAGE)) {
new AlertDialog.Builder(activity, R.style.AppCompatAlertDialogStyle)
.setTitle(context.getResources().getString(R.string.storage_access_denied_title))
.setMessage(context.getResources().getString(R.string.storage_access_denied_message))
.setPositiveButton(R.string.settings, new Dialog.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", activity.getPackageName(), null));
activity.startActivityForResult(intent, ReportCollectionActivity.ACTIVITY_APP_SETTINGS);
}
})
.setNegativeButton(android.R.string.cancel, null)
.show();
}
}
broadcastEndRefresh();
}
/**
* Create the report and note directories if needed
*/
private void createDirectories(){
if(!directoriesCreated) {
if (!reportsDir.exists()) {
reportsDir.mkdirs();
}
if (!reportsDir.isDirectory()) {
throw new RuntimeException("content directory is not a directory or could not be created: " + reportsDir);
}
File noMedia = new File(reportsDir, DICEConstants.DICE_NO_MEDIA_FILE);
if (!noMedia.exists()) {
try {
noMedia.createNewFile();
} catch (IOException e) {
Log.i(TAG, "Failed to create no media file: " + noMedia.getAbsolutePath(), e);
}
}
if (!notesDir.exists() && !notesDir.mkdirs()) {
throw new RuntimeException("notes directory does not exist and could not be created: " + notesDir);
}
directoriesCreated = true;
}
}
public Report getReportWithId(String id) {
for (Report r : reports) {
if (id.equals(r.getId())) {
return r;
}
}
return null;
}
public File getReportsDir() {
return reportsDir;
}
public File getNotesDir() {
return notesDir;
}
public File noteFileForReport(Report report) {
return new File(notesDir, report.getTitle() + ".txt");
}
public Drawable thumbnailForReport(Report report) {
if (report.getThumbnail() == null) {
return thumbnailMissingIcon;
}
if (externalContentThumbnail.equals(report.getThumbnail())) {
return externalContentIcon;
}
File imageFile = new File(report.getPath(), report.getThumbnail());
if (imageFile.exists()) {
return Drawable.createFromPath(imageFile.getAbsolutePath());
}
return thumbnailMissingIcon;
}
/**
* Add a report to DICE from the given {@link android.net.Uri}. If the Uri points to a zip file, extract it
* to the reports directory. If the Uri points to some other kind of regular file, copy it to the reports
* directory if necessary.
* {@link android.support.v4.content.LocalBroadcastManager#sendBroadcast(android.content.Intent) broadcast}
* a {@link #INTENT_UPDATE_REPORT_LIST} notification.
*
* @param reportUri
*/
public void importReportFromUri(Uri reportUri) {
if (!ReportUtils.uriCouldBeReport(context, reportUri)) {
return;
}
if (reports.contains(userGuideReport)) {
reports.remove(userGuideReport);
}
Report report = addNewReportForUri(reportUri);
broadcastUpdateReportList();
continueImport(report);
}
// TODO: do something with this
public void deleteReport(Report report) {
renameThenDeleteInBackground(report.getPath());
broadcastUpdateReportList();
}
@Override
public void importProgressPercentage(Report report, int percent) {
report.setDescription(context.getString(R.string.import_pending) + " " + percent + "%");
broadcastUpdateReportList();
}
@Override
public void importComplete(Report report) {
ReportDescriptorUtil.readDescriptorAndUpdateReport(report);
loadCacheFiles(report);
deleteSourceFileIfInDropbox(report);
removeDuplicatesOf(report);
report.setEnabled(true);
broadcastUpdateReportList();
}
@Override
public void importError(Report report) {
report.setEnabled(false);
report.setDescription(context.getString(R.string.import_error));
if (report.getError() == null) {
report.setError(report.getDescription());
}
broadcastUpdateReportList();
}
@Override
public void downloadProgressPercentage(Report report, int value) {
report.setDescription("Download " + value + " % complete");
broadcastUpdateReportList();
}
@Override
public void downloadComplete(Report report, Activity activity, Uri reportZipPath) {
reports.remove(report);
importReportFromUri(reportZipPath);
broadcastUpdateReportList();
}
@Override
public void downloadError(Report report, String errorMessage) {
// TODO: add code
}
private Drawable loadExternalLaunchIcon() {
int color = context.getResources().getColor(R.color.colorPrimaryDark);
int red = (color & 0x00ff0000) >> 16;
int green = (color & 0x0000ff00) >> 8;
int blue = color & 0x000000ff;
ColorMatrixColorFilter filter = new ColorMatrixColorFilter(new float[]{
0, 0, 0, 0, red,
0, 0, 0, 0, green,
0, 0, 0, 0, blue,
0, 0, 0, 1, 0,
});
Drawable drawable = context.getResources().getDrawable(R.drawable.ic_launch);
drawable.setColorFilter(filter);
return drawable;
}
public void downloadReport(URL reportURL, Activity activity) {
Report report = new Report();
report.setTitle(reportURL.toString());
report.setDescription("Downloading...");
report.setEnabled(false);
reports.add(report);
DownloadReportTask downloadReportTask = new DownloadReportTask(report, activity, this);
downloadReportTask.execute(reportURL.toString());
broadcastUpdateReportList();
}
private Drawable loadThumbnailMissingIcon() {
return context.getResources().getDrawable(R.drawable.thumbnail_missing);
}
/**
* Call on main thread only
*/
private void removeDeletedAndErrorReports() {
ensureUiThread();
Iterator<Report> reportIterator = reports.iterator();
while (reportIterator.hasNext()) {
Report report = reportIterator.next();
if ((report.isEnabled() && report.getPath() != null && !report.getPath().exists()) ||
report.getError() != null) {
reportIterator.remove();
}
}
}
/**
* Call on main thread only.
*/
private void findExistingReports() {
ensureUiThread();
Log.i(TAG, "finding existing reports in " + reportsDir);
File[] existingReports = ReportUtils.getReportDirectories(context, reportsDir);
for (File reportPath : existingReports) {
Log.d(TAG, "found existing potential report: " + reportPath);
Report report = getReportWithPath(reportPath);
if (report == null) {
report = addNewReportForUri(Uri.fromFile(reportPath));
if (reportPath.isFile()) {
new CheckReportSourceFileStability(report, reportPath).schedule();
}
else if (reportPath.isDirectory()) {
report.setPath(reportPath);
ReportDescriptorUtil.readDescriptorAndUpdateReport(report);
loadCacheFiles(report);
report.setEnabled(true);
}
else {
report.setError(context.getString(R.string.import_error_unsupported));
report.setDescription(report.getError());
}
}
}
// If there are no reports, point them towards to user guide
if (reports.isEmpty()) {
Log.d(TAG, "No reports, adding user guide placeholder");
userGuideReport.setTitle("Tap here to download the user guide");
userGuideReport.setDescription("After the download is complete, tap the notification and select \"Open in DICE\"");
userGuideReport.setEnabled(true);
userGuideReport.setId(USER_GUIDE_REPORT_ID);
reports.add(userGuideReport);
}
}
private void broadcastUpdateReportList() {
LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(INTENT_UPDATE_REPORT_LIST));
}
private void broadcastEndRefresh() {
LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(INTENT_END_REFRESH_REPORT_LIST));
}
/**
* Call on the main thread only. Create a new {@link Report} object for the given source file URI and add it to the
* {@link #reports list}. The new Report object will be updated during and/or after the import process.
*
* @param sourceFile the {@link android.net.Uri} that points to the source file of the report
* @return the new added {@link Report} object
*/
private Report addNewReportForUri(Uri sourceFile) {
ensureUiThread();
Report report = getReportWithSourceFile(sourceFile);
if (report != null) {
// already got it - move on
return report;
}
String fileName = sourceFile.toString();
long fileSize = -1;
if ("file".equals(sourceFile.getScheme())) {
File reportFile = new File(sourceFile.getPath());
report = getReportWithPath(reportFile);
if (report != null) {
// source file is probably a file in the reports dir
return report;
}
fileName = reportFile.getName();
fileSize = reportFile.length();
}
else if ("content".equals(sourceFile.getScheme())) {
// TODO: test what happens when ADD_CONTENT selects a file from the report dropbox
Cursor reportInfo = context.getContentResolver().query(sourceFile, null, null, null, null);
int nameCol = reportInfo.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int sizeCol = reportInfo.getColumnIndex(OpenableColumns.SIZE);
reportInfo.moveToFirst();
fileName = reportInfo.getString(nameCol);
fileSize = reportInfo.getLong(sizeCol);
reportInfo.close();
}
report = new Report();
report.setSourceFile(sourceFile);
report.setSourceFileName(fileName);
report.setSourceFileSize(fileSize);
report.setTitle(fileName);
report.setDescription(context.getString(R.string.import_pending));
report.setEnabled(false);
reports.add(report);
return report;
}
/**
* Continue the import process after the source file is stabilized by unzipping or
* copying it to the reports directory.
* @param report
*/
private void continueImport(Report report) {
String fileName = report.getSourceFileName();
String extension = ReportUtils.extensionOfFile(fileName);
String mimeType = context.getContentResolver().getType(report.getSourceFile());
if ("application/zip".equals(mimeType) || "zip".equalsIgnoreCase(extension)) {
// TODO: delete zip if in dropbox
String simpleName = fileName.substring(0, fileName.lastIndexOf("."));
File unzipDir = new File(reportsDir, simpleName);
report.setPath(unzipDir);
new UnzipReportSourceFile(report, reportsDir, context, this).executeOnExecutor(importExecutor);
}
else {
File destPath = new File(reportsDir, fileName);
// file stability check should be handling this
// TODO: unnecessary if dropbox dir and reports dir are separate
report.setPath(destPath);
report.setThumbnail(externalContentThumbnail);
new CopyReportSourceFileToReportPath(report).executeOnExecutor(importExecutor);
}
}
private void deleteSourceFileIfInDropbox(Report report) {
if (!"file".equals(report.getSourceFile().getScheme())) {
// only reports imported from the dropbox dir should have file:// uris
return;
}
File sourceFile = new File(report.getSourceFile().getPath());
if (reportsDir.equals(sourceFile.getParentFile()) && !sourceFile.equals(report.getPath()) /* when dropbox and reports dir are the same */ ) {
renameThenDeleteInBackground(sourceFile);
}
}
private void removeDuplicatesOf(Report report) {
Iterator<Report> reportIterator = reports.iterator();
while (reportIterator.hasNext()) {
Report dup = reportIterator.next();
if (dup != report && report.getPath().equals(dup.getPath())) {
reportIterator.remove();
}
}
}
private Report getReportWithPath(File path) {
for (Report r : reports) {
if (path.equals(r.getPath())) {
return r;
}
}
return null;
}
private Report getReportWithSourceFile(Uri sourceFile) {
for (Report r : reports) {
if (sourceFile.equals(r.getSourceFile())) {
return r;
}
}
return null;
}
private void fileArrivedInDropbox(final File file) {
// TODO: support copying directory?
if (!file.isFile()) {
return;
}
// TODO: unnecessary if the dropbox dir and reports dir are different
handler.post(new Runnable() {
@Override
public void run() {
Report report = getReportWithPath(file);
if (report != null) {
return;
}
Uri uri = Uri.fromFile(file);
if (ReportUtils.uriCouldBeReport(context, uri)) {
// not imported yet
report = addNewReportForUri(Uri.fromFile(file));
broadcastUpdateReportList();
new CheckReportSourceFileStability(report, file).schedule();
}
}
});
}
private void fileRemovedFromDropbox(final File file) {
handler.post(new Runnable() {
@Override
public void run() {
Report report = getReportWithPath(file);
if (report == null) {
return;
}
reports.remove(report);
broadcastUpdateReportList();
}
});
}
/**
* After a dropbox file has stabilized, finish importing the file.
* @param report
*/
private void sourceFileDidStabilize(Report report) {
continueImport(report);
}
private boolean renameThenDeleteInBackground(final File path) {
File deletePath = new File(path.getParent(), ReportUtils.DELETE_FILE_PREFIX + path.getName());
if (!path.renameTo(deletePath)) {
Log.e(TAG, "failed to rename path for deleting: " + path);
}
new DeleteRecursive(deletePath).executeOnExecutor(importExecutor);
return true;
}
/**
* This is a {@link java.lang.Runnable} that will continue to check whether a file
* has changed every {@link #STABILITY_CHECK_INTERVAL} and reports when no changes
* have occurred after {@link #MIN_STABILITY_CHECKS}. This is intended to avoid
* operating on files that are in the process of transferring from another device
* or file system.
*/
private class CheckReportSourceFileStability implements Runnable {
private final Report report;
private final File sourceFile;
private int stableCount = 0;
private long lastModified = 0;
private long lastLength = 0;
private CheckReportSourceFileStability(Report report, File sourceFile) {
this.report = report;
this.sourceFile = sourceFile;
lastLength = sourceFile.length();
lastModified = sourceFile.lastModified();
}
private boolean fileIsStable() {
return sourceFile.lastModified() == lastModified && sourceFile.length() == lastLength;
}
public void schedule() {
scheduledExecutor.schedule(this, STABILITY_CHECK_INTERVAL, TimeUnit.MILLISECONDS);
}
@Override
public void run() {
if (fileIsStable()) {
if (++stableCount >= MIN_STABILITY_CHECKS) {
report.setSourceFileSize(sourceFile.length());
sourceFileDidStabilize(report);
}
else {
schedule();
}
}
else {
stableCount = 0;
lastLength = sourceFile.length();
lastModified = sourceFile.lastModified();
schedule();
}
}
}
/**
* Copy a standalone report file (PDF, MS Word, etc.) to the reports directory.
*/
private class CopyReportSourceFileToReportPath extends AsyncTask<Void, Void, Boolean> {
private final Report report;
private CopyReportSourceFileToReportPath(Report report) {
this.report = report;
}
@Override
protected Boolean doInBackground(Void... params) {
if (report.getPath().exists()) {
return true;
}
FileDescriptor fd;
try {
fd = context.getContentResolver().openFileDescriptor(report.getSourceFile(), "r").getFileDescriptor();
}
catch (FileNotFoundException e) {
// TODO: user feedback
Log.e(TAG, "error opening file descriptor for report uri: " + report.getSourceFile(), e);
report.setError(e.getMessage());
return false;
}
FileChannel source = new FileInputStream(fd).getChannel();
// TODO: handle existing file - ask user to overwrite or rename?
File destFile = report.getPath();
FileChannel dest;
try {
dest = new FileOutputStream(destFile).getChannel();
}
catch (FileNotFoundException e) {
// TODO: user feedback
Log.e(TAG, "error creating new report file for import: " + destFile, e);
report.setError(e.getMessage());
return false;
}
try {
long sourceSize = source.size();
long transferred = source.transferTo(0, sourceSize, dest);
return transferred == sourceSize;
}
catch (IOException e) {
Log.e(TAG, "error copying report file from " + report.getSourceFile() + " to " + destFile, e);
report.setError(e.getMessage());
return false;
}
finally {
try {
source.close();
}
catch (IOException e) {
e.printStackTrace();
}
try {
dest.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
@Override
protected void onPostExecute(Boolean success) {
if (report.getError() == null && success) {
report.setDescription("");
importComplete(report);
}
else {
importError(report);
}
}
}
private class DeleteRecursive extends AsyncTask<Void, Void, Boolean> {
private final File path;
private DeleteRecursive(File path) {
this.path = path;
}
@Override
protected Boolean doInBackground(Void... params) {
return deleteRecursive(path);
}
private boolean deleteRecursive(File file) {
if (file.isDirectory()) {
for (File child : file.listFiles()) {
if (!deleteRecursive(child)) {
Log.e(TAG, "failed to delete directory recursively: " + file);
return false;
}
}
}
if (!file.delete()) {
Log.e(TAG, "failed to delete file: " + file);
return false;
}
return true;
}
}
private void loadCacheFiles(Report report){
List<File> files = new ArrayList<>();
File path = report.getPath();
getCacheFiles(path, files);
for(File file: files){
String fileString = file.getAbsolutePath();
String fileSubPath = fileString.replaceFirst(path.getAbsolutePath(), "");
if(fileSubPath.startsWith(File.separator)){
fileSubPath = fileSubPath.substring(1);
}
boolean shared = fileSubPath.startsWith(DICEConstants.DICE_REPORT_SHARED_DIRECTORY + File.separator);
String nameWithExtension = file.getName();
String name = removeExtension(nameWithExtension);
String reportName = removeExtension(report.getId());
name = GeoPackageWebViewClient.reportId(name, reportName, shared);
if(shared){
GeoPackageManager manager = GeoPackageFactory.getManager(context);
if(!manager.exists(name)) {
manager.importGeoPackageAsExternalLink(file, name);
}
}
ReportCache reportCache = new ReportCache(name, fileString, shared);
report.addReportCache(reportCache);
}
}
private String removeExtension(String name){
String nameWithoutExtension = name;
int i = name.lastIndexOf('.');
if (i > 0) {
nameWithoutExtension = name.substring(0, i);
}
return nameWithoutExtension;
}
private void getCacheFiles(File path, List<File> files){
if(path.isDirectory()) {
for (File file : path.listFiles()) {
getCacheFiles(file, files);
}
}else{
String stringPath = path.getAbsolutePath();
if(stringPath.endsWith("." + GeoPackageConstants.GEOPACKAGE_EXTENSION)
|| stringPath.endsWith("." + GeoPackageConstants.GEOPACKAGE_EXTENDED_EXTENSION)){
files.add(path);
}
}
}
}