/*
* PhoneGap is available under *either* the terms of the modified BSD license *or* the
* MIT License (2008). See http://opensource.org/licenses/alphabetical for full text.
*
* Copyright (c) 2005-2011, Nitobi Software Inc.
* Copyright (c) 2010-2011, IBM Corporation
*/
package com.phonegap.camera;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;
import javax.microedition.io.Connector;
import javax.microedition.io.file.FileConnection;
import net.rim.blackberry.api.invoke.CameraArguments;
import net.rim.blackberry.api.invoke.Invoke;
import net.rim.device.api.io.Base64OutputStream;
import net.rim.device.api.io.IOUtilities;
import net.rim.device.api.system.ApplicationDescriptor;
import net.rim.device.api.system.Bitmap;
import net.rim.device.api.system.Characters;
import net.rim.device.api.system.ControlledAccessException;
import net.rim.device.api.system.EncodedImage;
import net.rim.device.api.system.EventInjector;
import net.rim.device.api.system.JPEGEncodedImage;
import net.rim.device.api.system.PNGEncodedImage;
import net.rim.device.api.ui.UiApplication;
import com.phonegap.api.Plugin;
import com.phonegap.api.PluginResult;
import com.phonegap.json4j.JSONArray;
import com.phonegap.json4j.JSONException;
import com.phonegap.util.Logger;
/**
* The Camera plugin interface.
*
* The Camera class can invoke the following actions:
*
* - takePicture: takes photo and returns base64 encoded image or image file URI
*
* future?
* - captureVideo...
*
*/
public class Camera extends Plugin
{
/**
* Possible actions.
*/
public static final String ACTION_TAKE_PICTURE = "takePicture";
/**
* Maximum image encoding size (in bytes) to allow. (Obtained unofficially
* through trial and error). Anything larger will cause stability issues
* when sending back to the browser.
*/
private static final long MAX_ENCODING_SIZE = 1500000L;
/**
* Executes the requested action and returns a PluginResult.
*
* @param action The action to execute.
* @param callbackId The callback ID to be invoked upon action completion
* @param args JSONArry of arguments for the action.
* @return A PluginResult object with a status and message.
*/
public PluginResult execute(String action, JSONArray args, String callbackId)
{
PluginResult result = null;
// take a picture
if (action != null && action.equals(ACTION_TAKE_PICTURE))
{
// Parse the options specified for the take picture action.
CameraOptions options;
try {
options = CameraOptions.fromJSONArray(args);
} catch (NumberFormatException e) {
return new PluginResult(PluginResult.Status.JSON_EXCEPTION, "One of the camera options is not a valid number.");
} catch (JSONException e) {
return new PluginResult(PluginResult.Status.JSON_EXCEPTION, "One of the camera options is not valid JSON.");
}
// launch native camera application
launchCamera(new PhotoListener(options, callbackId));
// The native camera application runs in a separate process, so we
// must now wait for the listener to retrieve the photo taken.
// Return NO_RESULT status so plugin manager does not invoke a callback,
// but keep the callback so the listener can invoke it later.
result = new PluginResult(PluginResult.Status.NO_RESULT);
result.setKeepCallback(true);
return result;
}
else
{
result = new PluginResult(PluginResult.Status.INVALID_ACTION, "Camera: Invalid action:" + action);
}
return result;
}
/**
* Launches the native camera application.
*/
private static void launchCamera(PhotoListener listener)
{
// MMAPI interface doesn't use the native Camera application or interface
// (we would have to replicate it). So, we invoke the native Camera application,
// which doesn't allow us to set any options.
synchronized(UiApplication.getEventLock()) {
UiApplication.getUiApplication().addFileSystemJournalListener(listener);
Invoke.invokeApplication(Invoke.APP_TYPE_CAMERA, new CameraArguments());
}
}
/**
* Closes the native camera application.
*/
public static void closeCamera()
{
// simulate two escape characters to exit native camera application
// no, there is no other way to do this
UiApplication.getUiApplication().invokeLater(new Runnable() {
public void run() {
try
{
EventInjector.KeyEvent inject = new EventInjector.KeyEvent(
EventInjector.KeyEvent.KEY_DOWN, Characters.ESCAPE, 0);
inject.post();
inject.post();
}
catch (ControlledAccessException e)
{
// the application doesn't have key injection permissions
Logger.log(Camera.class.getName() + ": Unable to close camera. " +
ApplicationDescriptor.currentApplicationDescriptor().getName() +
" does not have key injection permissions.");
}
}
});
}
/**
* Returns the image file URI or the Base64-encoded image.
* @param filePath The full path of the image file
* @param options Specifies the format of the image and the result
* @param callbackId The id of the callback to receive the result
*/
public static void processImage(String filePath, CameraOptions options,
String callbackId) {
PluginResult result = null;
try
{
// wait for the file to be fully written to the file system
// to avoid premature access to it (yes, this has happened)
waitForImageFile(filePath);
// Reformat the image if the specified options require it,
// otherwise, get encoded string if base 64 string is output format.
String imageURIorData = filePath;
if (options.reformat) {
imageURIorData = reformatImage(filePath, options);
} else if (options.destinationType == CameraOptions.DESTINATION_DATA_URL) {
imageURIorData = encodeImage(filePath);
}
// we have to check the size to avoid memory errors in the browser
if (imageURIorData.length() > MAX_ENCODING_SIZE)
{
// it's a big one. this is for your own good.
String msg = "Encoded image is too large. Try reducing camera image size.";
Logger.log(Camera.class.getName() + ": " + msg);
result = new PluginResult(PluginResult.Status.ERROR, msg);
}
else
{
result = new PluginResult(PluginResult.Status.OK, imageURIorData);
}
}
catch (Exception e)
{
result = new PluginResult(PluginResult.Status.IO_EXCEPTION, e.toString());
}
// send result back to JavaScript
sendResult(result, callbackId);
}
/**
* Waits for the image file to be fully written to the file system.
* @param filePath Full path of the image file
* @throws IOException
*/
private static void waitForImageFile(String filePath) throws IOException
{
long start = (new Date()).getTime();
FileConnection fconn = null;
try
{
fconn = (FileConnection)Connector.open(filePath, Connector.READ);
if (fconn.exists())
{
long fileSize = fconn.fileSize();
long size = 0;
while (true)
{
try { Thread.sleep(100); } catch (InterruptedException e) {}
size = fconn.fileSize();
if (size == fileSize) {
break;
}
fileSize = size;
}
Logger.log(Camera.class.getName() + ": " + filePath +
" size=" + Long.toString(fileSize) + " bytes");
}
}
finally
{
if (fconn != null) fconn.close();
}
long end = (new Date()).getTime();
Logger.log(Camera.class.getName() + ": wait time=" + Long.toString(end-start) + " ms");
}
/**
* Opens the specified image file and converts its contents to a Base64-encoded string.
* @param filePath Full path of the image file
* @return file contents as a Base64-encoded String
*/
private static String encodeImage(String filePath) throws IOException
{
String imageData = null;
// open the image file
FileConnection fconn = null;
InputStream in = null;
ByteArrayOutputStream byteArrayOS = null;
try
{
fconn = (FileConnection)Connector.open(filePath);
if (fconn.exists())
{
// encode file contents using BASE64 encoding
in = fconn.openInputStream();
byteArrayOS = new ByteArrayOutputStream();
Base64OutputStream base64OS = new Base64OutputStream(byteArrayOS);
base64OS.write(IOUtilities.streamToBytes(in, 96*1024));
base64OS.flush();
base64OS.close();
imageData = byteArrayOS.toString();
Logger.log(Camera.class.getName() + ": Base64 encoding size=" +
Integer.toString(imageData.length()));
}
}
finally
{
if (in != null) in.close();
if (fconn != null) fconn.close();
if (byteArrayOS != null) byteArrayOS.close();
}
return imageData;
}
/**
* Reformats the image taken with the camera based on the options specified.
*
* Unfortunately, reformatting the image will cause EXIF data in the photo
* to be lost. Most importantly the orientation data is lost so the
* picture is not auto rotated by software that recognizes EXIF data.
*
* @param filePath
* The full path of the image file
* @param options
* Specifies the format of the image and the result
* @return the reformatted image file URI or Base64-encoded image
* @throws IOException
*/
private static String reformatImage(String filePath, CameraOptions options)
throws IOException {
long start = (new Date()).getTime();
// Open the original image created by the camera application and read
// it into an EncodedImage object.
FileConnection fconn = null;
InputStream in = null;
Bitmap originalImage = null;
try {
fconn = (FileConnection) Connector.open(filePath);
in = fconn.openInputStream();
originalImage = Bitmap.createBitmapFromBytes(IOUtilities.streamToBytes(in, 96*1024), 0, -1, 1);
} finally {
if (in != null)
in.close();
if (fconn != null)
fconn.close();
}
int newWidth = options.targetWidth;
int newHeight = options.targetHeight;
int origWidth = originalImage.getWidth();
int origHeight = originalImage.getHeight();
// If only width or only height was specified, the missing dimension is
// set based on the current aspect ratio of the image.
if (newWidth > 0 && newHeight <= 0) {
newHeight = (newWidth * origHeight) / origWidth;
} else if (newWidth <= 0 && newHeight > 0) {
newWidth = (newHeight * origWidth) / origHeight;
} else if (newWidth <= 0 && newHeight <= 0) {
newWidth = origWidth;
newHeight = origHeight;
} else {
// If the user specified both a positive width and height
// (potentially different aspect ratio) then the width or height is
// scaled so that the image fits while maintaining aspect ratio.
// Alternatively, the specified width and height could have been
// kept and Bitmap.SCALE_TO_FIT specified when scaling, but this
// would result in whitespace in the new image.
double newRatio = newWidth / (double)newHeight;
double origRatio = origWidth / (double)origHeight;
if (origRatio > newRatio) {
newHeight = (newWidth * origHeight) / origWidth;
} else if (origRatio < newRatio) {
newWidth = (newHeight * origWidth) / origHeight;
}
}
Bitmap newImage = new Bitmap(newWidth, newHeight);
originalImage.scaleInto(newImage, options.imageFilter, Bitmap.SCALE_TO_FILL);
// Convert the image to the appropriate encoding. PNG does not allow
// quality to be specified so the only affect that the quality option
// has for a PNG is on the seelction of the image filter.
EncodedImage encodedImage;
if (options.encoding == CameraOptions.ENCODING_PNG) {
encodedImage = PNGEncodedImage.encode(newImage);
} else {
encodedImage = JPEGEncodedImage.encode(newImage, options.quality);
}
// Rewrite the modified image back out to the same file. This is done
// to ensure that for every picture taken, only one shows up in the
// gallery. If the encoding changed the file extension will differ
// from the original.
OutputStream out = null;
int dirIndex = filePath.lastIndexOf('/');
String filename = filePath.substring(dirIndex + 1, filePath.lastIndexOf('.'))
+ options.fileExtension;
try {
fconn = (FileConnection) Connector.open(filePath);
fconn.truncate(0);
out = fconn.openOutputStream();
out.write(encodedImage.getData());
fconn.rename(filename);
} finally {
if (out != null)
out.close();
if (fconn != null)
fconn.close();
}
// Return either the Base64-encoded string or the image URI for the
// new image.
String imageURIorData;
if (options.destinationType == CameraOptions.DESTINATION_DATA_URL) {
ByteArrayOutputStream byteArrayOS = null;
try {
byteArrayOS = new ByteArrayOutputStream();
Base64OutputStream base64OS = new Base64OutputStream(
byteArrayOS);
base64OS.write(encodedImage.getData());
base64OS.flush();
base64OS.close();
imageURIorData = byteArrayOS.toString();
Logger.log(Camera.class.getName() + ": Base64 encoding size="
+ Integer.toString(imageURIorData.length()));
} finally {
if (byteArrayOS != null) {
byteArrayOS.close();
}
}
} else {
imageURIorData = filePath.substring(0, dirIndex + 1) + filename;
}
long end = (new Date()).getTime();
Logger.log(Camera.class.getName() + ": reformat time=" + Long.toString(end-start) + " ms");
return imageURIorData;
}
/**
* Sends result back to JavaScript.
* @param result PluginResult
*/
private static void sendResult(PluginResult result, String callbackId)
{
// invoke the appropriate callback
if (result.getStatus() == PluginResult.Status.OK.ordinal())
{
success(result, callbackId);
}
else
{
error(result, callbackId);
}
}
}