package org.commcare.utils;
import android.os.AsyncTask;
import android.os.Environment;
import android.util.Log;
import android.webkit.MimeTypeMap;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.mime.MultipartEntity;
import org.apache.http.entity.mime.content.ContentBody;
import org.apache.http.entity.mime.content.FileBody;
import org.commcare.logging.AndroidLogger;
import org.commcare.network.DataSubmissionEntity;
import org.commcare.network.EncryptedFileBody;
import org.commcare.network.HttpRequestGenerator;
import org.commcare.tasks.DataSubmissionListener;
import org.javarosa.core.io.StreamsUtil;
import org.javarosa.core.io.StreamsUtil.InputIOException;
import org.javarosa.core.model.User;
import org.javarosa.core.services.Logger;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.UnknownHostException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
public class FormUploadUtil {
private static final String TAG = FormUploadUtil.class.getSimpleName();
/**
* 15 MB size limit
*/
public static final long MAX_BYTES = (15 * 1048576) - 1024;
private static final String[] SUPPORTED_FILE_EXTS =
{".xml", ".jpg", "jpeg", ".3gpp", ".3gp", ".3ga", ".3g2", ".mp3",
".wav", ".amr", ".mp4", ".3gp2", ".mpg4", ".mpeg4",
".m4v", ".mpg", ".mpeg", ".qcp", ".ogg"};
public static Cipher getDecryptCipher(SecretKeySpec key) {
Cipher cipher;
try {
cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, key);
return cipher;
//TODO: Something smart here;
} catch (NoSuchAlgorithmException |
NoSuchPaddingException | InvalidKeyException e) {
e.printStackTrace();
}
return null;
}
/**
* Send unencrypted data to the server without user facing progress
* reporting.
*
* @param submissionNumber For progress reporting
* @param folder All supported files in this folder will be
* attached to the submission
* @param url Submission server url
* @param user Used to build the http post
* @return Submission status code
* @throws FileNotFoundException Is raised if xml file isn't found on the
* file-system
*/
public static FormUploadResult sendInstance(int submissionNumber, File folder,
String url, User user)
throws FileNotFoundException {
return sendInstance(submissionNumber, folder, null, url, null, user);
}
/**
* Send data to the server, encrypting xml files and reporting progress
* along the way.
*
* @param submissionNumber For progress reporting
* @param folder All supported files in this folder will be
* attached to the submission
* @param key For encrypting xml files
* @param url Submission server url
* @param listener Used to report progress to the calling task
* @param user Used to build the http post
* @return Submission status code
* @throws FileNotFoundException Is raised if xml file isn't found on the
* file-system
*/
public static FormUploadResult sendInstance(int submissionNumber, File folder,
SecretKeySpec key, String url,
AsyncTask listener, User user)
throws FileNotFoundException {
boolean hasListener = false;
DataSubmissionListener myListener = null;
if (listener instanceof DataSubmissionListener) {
hasListener = true;
myListener = (DataSubmissionListener)listener;
}
File[] files = folder.listFiles();
if (files == null) {
// make sure external storage is available to begin with.
String state = Environment.getExternalStorageState();
if (!Environment.MEDIA_MOUNTED.equals(state)) {
// If so, just bail as if the user had logged out.
throw new SessionUnavailableException("External Storage Removed");
} else {
throw new FileNotFoundException("No directory found at: " +
folder.getAbsoluteFile());
}
}
// If we're listening, figure out how much (roughly) we have to send
long bytes = estimateUploadBytes(files);
if (hasListener) {
myListener.startSubmission(submissionNumber, bytes);
}
if (files.length == 0) {
Log.e(TAG, "no files to upload");
listener.cancel(true);
throw new FileNotFoundException("Folder at path " + folder.getAbsolutePath() + " had no files.");
}
// mime post
MultipartEntity entity =
new DataSubmissionEntity(myListener, submissionNumber);
if (!buildMultipartEntity(entity, key, files)) {
return FormUploadResult.RECORD_FAILURE;
}
HttpRequestGenerator generator = new HttpRequestGenerator(user);
return submitEntity(entity, url, generator);
}
/**
* Submit multipart entity with plenty of logging
*
* @return submission status of multipart entity post
*/
private static FormUploadResult submitEntity(MultipartEntity entity, String url,
HttpRequestGenerator generator) {
HttpResponse response;
try {
response = generator.postData(url, entity);
} catch (InputIOException ioe) {
// This implies that there was a problem with the _source_ of the
// transmission, not the processing or receiving end.
Logger.log(AndroidLogger.TYPE_ERROR_STORAGE,
"Internal error reading form record during submission: " +
ioe.getWrapped().getMessage());
return FormUploadResult.RECORD_FAILURE;
} catch (ClientProtocolException | UnknownHostException e) {
e.printStackTrace();
Logger.log(AndroidLogger.TYPE_WARNING_NETWORK,
"Client network issues during submission: " + e.getMessage());
return FormUploadResult.TRANSPORT_FAILURE;
} catch (IOException | IllegalStateException e) {
e.printStackTrace();
Logger.log(AndroidLogger.TYPE_ERROR_STORAGE,
"Error reading form during submission: " + e.getMessage());
return FormUploadResult.TRANSPORT_FAILURE;
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try {
StreamsUtil.writeFromInputToOutputNew(response.getEntity().getContent(), bos);
} catch (IllegalStateException | IOException e) {
e.printStackTrace();
}
String responseString = new String(bos.toByteArray());
int responseCode = response.getStatusLine().getStatusCode();
logResponse(responseCode, responseString);
if (responseCode >= 200 && responseCode < 300) {
return FormUploadResult.FULL_SUCCESS;
} else if (responseCode == 401) {
return FormUploadResult.AUTH_FAILURE;
} else {
return FormUploadResult.FAILURE;
}
}
private static void logResponse(int responseCode, String responseString) {
String responseCodeMessage = "Response code to form submission attempt: " + responseCode;
Log.e(TAG, responseCodeMessage);
Log.d(TAG, responseString);
if (!(responseCode >= 200 && responseCode < 300)) {
Logger.log(AndroidLogger.TYPE_WARNING_NETWORK, responseCodeMessage);
Logger.log(AndroidLogger.TYPE_FORM_SUBMISSION, responseCodeMessage);
Logger.log(AndroidLogger.TYPE_FORM_SUBMISSION,
"Response string to failed form submission attempt: " + responseString);
}
}
/**
* Validate the content body of the XML submission file.
*
* TODO: this should really be the responsibility of the form record, not
* of the submission process, persay.
*
* NOTE: this is a shallow validation (everything should be more or else
* constant time). Throws an exception if the file is gone because that's
* a common issue that gets caught to check if storage got removed
*
* @param f xml file to check
* @return false if the file is empty; otherwise true
* @throws FileNotFoundException file in question isn't found on the
* file-system
*/
private static boolean validateSubmissionFile(File f)
throws FileNotFoundException {
if (!f.exists()) {
throw new FileNotFoundException("Submission file: " +
f.getAbsolutePath());
}
// Gotta check f exists here since f.length returns 0 if the file isn't
// there for some reason.
if (f.length() == 0 && f.exists()) {
Logger.log(AndroidLogger.TYPE_ERROR_STORAGE,
"Submission body has no content at: " + f.getAbsolutePath());
return false;
}
return true;
}
/**
* @return The aggregated size in bytes the files of supported extension
* type.
*/
private static long estimateUploadBytes(File[] files) {
long bytes = 0;
for (File file : files) {
// Make sure we'll be sending it
if (!isSupportedMultimediaFile(file.getName())) {
continue;
}
bytes += file.length();
Log.d(TAG, "Added file: " + file.getName() +
". Bytes to send: " + bytes);
}
return bytes;
}
/**
* Add files of supported type to the multipart entity, encrypting xml
* files.
*
* @param entity Add files to this
* @param key Used to encrypt xml files
* @param files The files to be added to the entity,
* @return false if invalid xml files are found; otherwise true.
* @throws FileNotFoundException Is raised when an xml doesn't exist on the
* file-system
*/
private static boolean buildMultipartEntity(MultipartEntity entity,
SecretKeySpec key,
File[] files)
throws FileNotFoundException {
for (File f : files) {
ContentBody fb;
if (f.getName().endsWith(".xml")) {
if (key != null) {
if (!validateSubmissionFile(f)) {
return false;
}
fb = new EncryptedFileBody(f, FormUploadUtil.getDecryptCipher(key),
ContentType.TEXT_XML);
} else {
fb = new FileBody(f, ContentType.TEXT_XML, f.getName());
}
entity.addPart("xml_submission_file", fb);
} else if (f.getName().endsWith(".jpg")) {
fb = new FileBody(f, ContentType.create("image/jpeg"), f.getName());
if (fb.getContentLength() <= MAX_BYTES) {
entity.addPart(f.getName(), fb);
Log.i(TAG, "added image file " + f.getName());
} else {
Log.i(TAG, "file " + f.getName() + " is too big");
}
} else if (f.getName().endsWith(".3gpp")) {
fb = new FileBody(f, ContentType.create("audio/3gpp"), f.getName());
if (fb.getContentLength() <= MAX_BYTES) {
entity.addPart(f.getName(), fb);
Log.i(TAG, "added audio file " + f.getName());
} else {
Log.i(TAG, "file " + f.getName() + " is too big");
}
} else if (f.getName().endsWith(".3gp")) {
fb = new FileBody(f, ContentType.create("video/3gpp"), f.getName());
if (fb.getContentLength() <= MAX_BYTES) {
entity.addPart(f.getName(), fb);
Log.i(TAG, "added video file " + f.getName());
} else {
Log.i(TAG, "file " + f.getName() + " is too big");
}
} else if (isSupportedMultimediaFile(f.getName())) {
fb = new FileBody(f, ContentType.APPLICATION_OCTET_STREAM, f.getName());
if (fb.getContentLength() <= MAX_BYTES) {
entity.addPart(f.getName(), fb);
Log.i(TAG, "added unknown file " + f.getName());
} else {
Log.i(TAG, "file " + f.getName() + " is too big");
}
} else {
Log.w(TAG, "unsupported file type, not adding file: " +
f.getName());
}
}
return true;
}
/**
* @return Is the filename's extension in the hard-coded list of supported
* files or have a media mimetype?
*/
public static boolean isSupportedMultimediaFile(String filename) {
for (String ext : SUPPORTED_FILE_EXTS) {
if (filename.endsWith(ext)) {
return true;
}
}
return isAudioVisualMimeType(filename);
}
/**
* Use the file's extension to determine if it has an audio,
* video, or image mimetype.
*
* @return true if the file has an audio, image, or video mimetype
*/
private static boolean isAudioVisualMimeType(String filename) {
MimeTypeMap mtm = MimeTypeMap.getSingleton();
String[] filenameSegments = filename.split("\\.");
if (filenameSegments.length > 1) {
// use the file extension to determine the mimetype
String ext = filenameSegments[filenameSegments.length - 1];
String mimeType = mtm.getMimeTypeFromExtension(ext);
return (mimeType != null) &&
(mimeType.startsWith("audio") ||
mimeType.startsWith("image") ||
mimeType.startsWith("video"));
}
return false;
}
}