/*
* Copyright (C) 2010-2017 Stichting Akvo (Akvo Foundation)
*
* This file is part of Akvo Flow.
*
* Akvo Flow is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Akvo Flow 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Akvo Flow. If not, see <http://www.gnu.org/licenses/>.
*/
package org.akvo.flow.util;
import android.content.Context;
import android.database.Cursor;
import android.media.ExifInterface;
import android.os.Environment;
import android.provider.MediaStore;
import android.text.TextUtils;
import org.akvo.flow.BuildConfig;
import org.akvo.flow.app.FlowApp;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import timber.log.Timber;
/**
* utility for manipulating files
*
* @author Christopher Fagiani
*/
public class FileUtil {
// Directories stored in the External Storage root (i.e. /sdcard/akvoflow/data)
private static final String DIR_DATA = "akvoflow/data/files"; // form responses zip files
private static final String DIR_MEDIA = "akvoflow/data/media"; // form responses media files
private static final String DIR_INBOX = "akvoflow/inbox"; // Bootstrap files
// Directories stored in the app specific External Storage (i.e. /sdcard/Android/data/org.akvo.flow/files/forms)
private static final String DIR_FORMS = "forms"; // Form definitions
private static final String DIR_STACKTRACE = "stacktrace"; // Crash reports
private static final String DIR_TMP = "tmp"; // Temporary files
private static final String DIR_APK = "apk"; // App upgrades
private static final String DIR_RES = "res"; // Survey resources (i.e. cascading DB)
private static final int BUFFER_SIZE = 2048;
public enum FileType {DATA, MEDIA, INBOX, FORMS, STACKTRACE, TMP, APK, RES}
/**
* Get the appropriate files directory for the given FileType. The directory may or may
* not be in the app-specific External Storage. The caller cannot assume anything about
* the location.
*
* @param type FileType to determine the type of resource attempting to use.
* @return File representing the root directory for the given FileType.
*/
public static File getFilesDir(FileType type) {
String path = null;
switch (type) {
case DATA:
path = getFilesStorageDir(false) + File.separator + DIR_DATA;
break;
case MEDIA:
path = getFilesStorageDir(false) + File.separator + DIR_MEDIA;
break;
case INBOX:
path = getFilesStorageDir(false) + File.separator + DIR_INBOX;
break;
case FORMS:
path = getFilesStorageDir(true) + File.separator + DIR_FORMS;
break;
case STACKTRACE:
path = getFilesStorageDir(true) + File.separator + DIR_STACKTRACE;
break;
case TMP:
path = getFilesStorageDir(true) + File.separator + DIR_TMP;
break;
case APK:
path = getFilesStorageDir(true) + File.separator + DIR_APK;
break;
case RES:
path = getFilesStorageDir(true) + File.separator + DIR_RES;
break;
}
File dir = new File(path);
if (!dir.exists()) {
dir.mkdirs();
}
return dir;
}
/**
* Get the root of the files storage directory, depending on the resource being app internal
* (not concerning the user) or not (users might need to pull the resource from the storage).
*
* @param internal true for app specific resources, false otherwise
* @return The root directory for this kind of resources
*/
private static String getFilesStorageDir(boolean internal) {
if (internal) {
return FlowApp.getApp().getExternalFilesDir(null).getAbsolutePath();
}
return Environment.getExternalStorageDirectory().getAbsolutePath();
}
/**
* reads data from an InputStream into a string.
*/
public static String readText(InputStream is) throws IOException {
ByteArrayOutputStream out = null;
try {
out = read(is);
return out.toString();
} finally {
close(out);
}
}
public static void copy(InputStream in, OutputStream out) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
int size;
while ((size = in.read(buffer, 0, buffer.length)) != -1) {
out.write(buffer, 0, size);
}
}
/**
* reads the contents of an InputStream into a ByteArrayOutputStream.
*/
private static ByteArrayOutputStream read(InputStream is) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
copy(is, out);
return out;
}
/**
* extract zip file contents into destination folder.
*/
public static void extract(ZipInputStream zis, File dst) throws IOException {
ZipEntry entry;
try {
while ((entry = zis.getNextEntry()) != null && !entry.isDirectory()) {
File f = new File(dst, entry.getName());
FileOutputStream fout = new FileOutputStream(f);
FileUtil.copy(zis, fout);
fout.close();
zis.closeEntry();
}
} finally {
close(zis);
}
}
/**
* deletes all files in the directory (recursively) AND then deletes the
* directory itself if the "deleteFlag" is true
*/
public static void deleteFilesInDirectory(File dir, boolean deleteDir) {
if (dir != null && dir.isDirectory()) {
File[] files = dir.listFiles();
if (files != null) {
for (int i = 0; i < files.length; i++) {
if (files[i].isFile()) {
files[i].delete();
} else {
// recursively delete
deleteFilesInDirectory(files[i], true);
}
}
}
// now delete the directory itself
if (deleteDir) {
dir.delete();
}
}
}
/**
* Compute MD5 checksum of the given path's file
*/
private static byte[] getMD5Checksum(String path) {
return getMD5Checksum(new File(path));
}
/**
* Compute MD5 checksum of the given file
*/
public static byte[] getMD5Checksum(File file) {
InputStream in = null;
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
in = new BufferedInputStream(new FileInputStream(file));
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = in.read(buffer)) != -1) {
md.update(buffer, 0, read);
}
return md.digest();
} catch (NoSuchAlgorithmException | IOException e) {
Timber.e(e.getMessage());
} finally {
close(in);
}
return null;
}
public static String hexMd5(byte[] rawHash) {
if (rawHash != null) {
StringBuilder builder = new StringBuilder();
for (byte b : rawHash) {
builder.append(String.format("%02x", b));
}
return builder.toString();
}
return null;
}
public static String hexMd5(File file) {
return hexMd5(getMD5Checksum(file));
}
/**
* Compare to images to determine if their content is the same. To state
* that the two of them are the same, the datetime contained in their exif
* metadata will be compared. If the exif does not contain a datetime, the
* MD5 checksum of the images will be compared.
*
* @param image1 Absolute path to the first image
* @param image2 Absolute path to the second image
* @return true if their datetime is the same, false otherwise
*/
private static boolean compareImages(String image1, String image2) {
boolean equals = false;
try {
ExifInterface exif1 = new ExifInterface(image1);
ExifInterface exif2 = new ExifInterface(image2);
final String datetime1 = exif1.getAttribute(ExifInterface.TAG_DATETIME);
final String datetime2 = exif2.getAttribute(ExifInterface.TAG_DATETIME);
if (!TextUtils.isEmpty(datetime1) && !TextUtils.isEmpty(datetime1)) {
equals = datetime1.equals(datetime2);
} else {
Timber.d("Datetime is null or empty. The MD5 checksum will be compared");
equals = compareFilesChecksum(image1, image2);
}
} catch (IOException e) {
Timber.e(e.getMessage());
}
return equals;
}
/**
* Compare to files to determine if their content is the same. To state that
* the two of them are the same, the MD5 checksum will be compared. Note
* that if any of the files does not exist, or if its checksum cannot be
* computed, false will be returned.
*
* @param path1 Absolute path to the first file
* @param path2 Absolute path to the second file
* @return true if their MD5 checksum is the same, false otherwise.
*/
private static boolean compareFilesChecksum(String path1, String path2) {
final byte[] checksum1 = getMD5Checksum(path1);
final byte[] checksum2 = getMD5Checksum(path2);
return Arrays.equals(checksum1, checksum2);
}
/**
* Some manufacturers will duplicate the image saving a copy in the DCIM
* folder. This method will try to spot those situations and remove the
* duplicated image.
*
* @param context Context
* @param filepath The absolute path to the original image
*/
public static void cleanDCIM(Context context, String filepath) {
Cursor cursor = context.getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
new String[] {
MediaStore.Images.ImageColumns.DATA,
MediaStore.Images.ImageColumns.DATE_TAKEN
},
null,
null,
MediaStore.Images.ImageColumns.DATE_TAKEN + " DESC"
);
if (cursor.moveToFirst()) {
final String lastImagePath = cursor.getString(cursor
.getColumnIndex(MediaStore.Images.ImageColumns.DATA));
if ((!filepath.equals(lastImagePath))
&& (FileUtil.compareImages(filepath, lastImagePath))) {
final int result = context.getContentResolver().delete(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
MediaStore.Images.ImageColumns.DATA + " = ?",
new String[] {
lastImagePath
});
if (result == 1) {
Timber.i("Duplicated file successfully removed: " + lastImagePath);
} else {
Timber.e("Error removing duplicated image:" + lastImagePath);
}
}
}
cursor.close();
}
/**
* Check for the latest downloaded version. If old versions are found, delete them.
* The APK corresponding to the installed version will also be deleted, if found,
* in order to perform a cleanup after an upgrade.
*
* @return the path and version of a newer APK, if found, null otherwise
*/
public static String checkDownloadedVersions() {
String maxVersion = BuildConfig.VERSION_NAME;// Keep track of newest version available
String apkPath = null;
File appsLocation = getFilesDir(FileType.APK);
File[] versions = appsLocation.listFiles();
if (versions != null) {
for (File version : versions) {
File[] apks = version.listFiles();
if (apks == null) {
continue;// Nothing to see here
}
String versionName = version.getName();
if (!PlatformUtil.isNewerVersion(maxVersion, versionName)) {
// Delete old versions
for (File apk : apks) {
apk.delete();
}
version.delete();
} else if (apks.length > 0) {
maxVersion = versionName;
apkPath = apks[0].getAbsolutePath();// There should only be 1
}
}
}
if (apkPath != null && maxVersion != null) {
return apkPath;
}
return null;
}
/**
* Helper function to close a Closeable instance
*/
public static void close(Closeable closeable) {
if (closeable == null) {
return;
}
try {
closeable.close();
} catch (IOException e) {
Timber.e(e.getMessage());
}
}
}