diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4bd6da9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +### Platforms affected + + +### What does this PR do? + + +### What testing has been done on this change? + + +### Checklist +- [ ] [ICLA](http://www.apache.org/licenses/icla.txt) has been signed and submitted to secretary@apache.org. +- [ ] [Reported an issue](http://cordova.apache.org/contribute/issues.html) in the JIRA database +- [ ] Commit message follows the format: "CB-3232: (android) Fix bug with resolving file paths", where CB-xxxx is the JIRA ID & "android" is the platform affected. +- [ ] Added automated test coverage as appropriate for this change. diff --git a/README.md b/README.md index 8102670..a15680c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ description: Take pictures with the device camera. # under the License. --> -[![Build Status](https://travis-ci.org/apache/cordova-plugin-camera.svg?branch=master)](https://travis-ci.org/apache/cordova-plugin-camera) +|Android|iOS| Windows 8.1 Store | Windows 8.1 Phone | Windows 10 Store | Travis CI | +|:-:|:-:|:-:|:-:|:-:|:-:| +|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=android,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=android,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=ios,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=ios,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=windows-8.1-store,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=windows-8.1-store,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=windows-8.1-phone,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=windows-8.1-phone,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=windows-10-store,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=windows-10-store,PLUGIN=cordova-plugin-camera/)|[![Build Status](https://travis-ci.org/apache/cordova-plugin-camera.svg?branch=master)](https://travis-ci.org/apache/cordova-plugin-camera) # cordova-plugin-camera @@ -73,7 +75,7 @@ Documentation consists of template and API docs produced from the plugin JS code --- -# API Reference +# API Reference * [camera](#module_camera) @@ -114,26 +116,20 @@ Once the user snaps the photo, the camera application closes and the application If `Camera.sourceType` is `Camera.PictureSourceType.PHOTOLIBRARY` or `Camera.PictureSourceType.SAVEDPHOTOALBUM`, then a dialog displays -that allows users to select an existing image. The -`camera.getPicture` function returns a [`CameraPopoverHandle`](#module_CameraPopoverHandle) object, -which can be used to reposition the image selection dialog, for -example, when the device orientation changes. +that allows users to select an existing image. The return value is sent to the [`cameraSuccess`](#module_camera.onSuccess) callback function, in one of the following formats, depending on the specified `cameraOptions`: - A `String` containing the Base64-encoded photo image. - - A `String` representing the image file location on local storage (default). You can do whatever you want with the encoded image or URI, for example: - Render the image in an `` tag, as in the example below - - Save the data locally (`LocalStorage`, [Lawnchair](http://brianleroux.github.com/lawnchair/), etc.) - - Post the data to a remote server __NOTE__: Photo resolution on newer devices is quite good. Photos @@ -256,6 +252,12 @@ Optional parameters to customize the camera settings. ### Camera.DestinationType : enum +Defines the output format of `Camera.getPicture` call. +_Note:_ On iOS passing `DestinationType.NATIVE_URI` along with +`PictureSourceType.PHOTOLIBRARY` or `PictureSourceType.SAVEDPHOTOALBUM` will +disable any image modifications (resize, quality change, cropping, etc.) due +to implementation specific. + **Kind**: static enum property of [Camera](#module_Camera) **Properties** @@ -291,14 +293,19 @@ Optional parameters to customize the camera settings. ### Camera.PictureSourceType : enum +Defines the output format of `Camera.getPicture` call. +_Note:_ On iOS passing `PictureSourceType.PHOTOLIBRARY` or `PictureSourceType.SAVEDPHOTOALBUM` +along with `DestinationType.NATIVE_URI` will disable any image modifications (resize, quality +change, cropping, etc.) due to implementation specific. + **Kind**: static enum property of [Camera](#module_Camera) **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | -| PHOTOLIBRARY | number | 0 | Choose image from picture library (same as SAVEDPHOTOALBUM for Android) | +| PHOTOLIBRARY | number | 0 | Choose image from the device's photo library (same as SAVEDPHOTOALBUM for Android) | | CAMERA | number | 1 | Take picture from camera | -| SAVEDPHOTOALBUM | number | 2 | Choose image from picture library (same as PHOTOLIBRARY for Android) | +| SAVEDPHOTOALBUM | number | 2 | Choose image only from the device's Camera Roll album (same as PHOTOLIBRARY for Android) | @@ -362,7 +369,7 @@ __Supported Platforms__ **Example** ```js -var cameraPopoverHandle = navigator.camera.getPicture(onSuccess, onFail, +navigator.camera.getPicture(onSuccess, onFail, { destinationType: Camera.DestinationType.FILE_URI, sourceType: Camera.PictureSourceType.PHOTOLIBRARY, @@ -371,6 +378,7 @@ var cameraPopoverHandle = navigator.camera.getPicture(onSuccess, onFail, // Reposition the popover if the orientation changes. window.onorientationchange = function() { + var cameraPopoverHandle = new CameraPopoverHandle(); var cameraPopoverOptions = new CameraPopoverOptions(0, 0, 100, 100, Camera.PopoverArrowDirection.ARROW_ANY); cameraPopoverHandle.setPosition(cameraPopoverOptions); } @@ -464,6 +472,16 @@ displays: Invoking the native camera application while the device is connected via Zune does not work, and triggers an error callback. +#### Windows quirks + +On Windows Phone 8.1 using `SAVEDPHOTOALBUM` or `PHOTOLIBRARY` as a source type causes application to suspend until file picker returns the selected image and +then restore with start page as defined in app's `config.xml`. In case when `camera.getPicture` was called from different page, this will lead to reloading +start page from scratch and success and error callbacks will never be called. + +To avoid this we suggest using SPA pattern or call `camera.getPicture` only from your app's start page. + +More information about Windows Phone 8.1 picker APIs is here: [How to continue your Windows Phone app after calling a file picker](https://msdn.microsoft.com/en-us/library/windows/apps/dn720490.aspx) + #### Tizen Quirks Tizen only supports a `destinationType` of @@ -529,6 +547,8 @@ Tizen only supports a `destinationType` of - When using `destinationType.NATIVE_URI` and `sourceType.CAMERA`, photos are saved in the saved photo album regardless on the value of `saveToPhotoAlbum` parameter. +- When using `destinationType.NATIVE_URI` and `sourceType.PHOTOLIBRARY` or `sourceType.SAVEDPHOTOALBUM`, all editing options are ignored and link is returned to original picture. + #### Tizen Quirks - options not supported @@ -618,7 +638,7 @@ function displayImage(imgUri) { } ``` -To display the image on some platforms, you might need to include the main part of the URI in the Content-Security-Policy element in index.html. For example, on Windows 10, you can include `ms-appdata:` in your element. Here is an example. +To display the image on some platforms, you might need to include the main part of the URI in the Content-Security-Policy `` element in index.html. For example, on Windows 10, you can include `ms-appdata:` in your `` element. Here is an example. ```html diff --git a/appium-tests/android/android.spec.js b/appium-tests/android/android.spec.js index 7a9a71b..0e00bab 100644 --- a/appium-tests/android/android.spec.js +++ b/appium-tests/android/android.spec.js @@ -52,6 +52,12 @@ describe('Camera tests Android.', function () { var screenHeight = DEFAULT_SCREEN_HEIGHT; // promise count to use in promise ID var promiseCount = 0; + // determine if Appium session is created successfully + var appiumSessionStarted = false; + // determine if camera is present on the device/emulator + var cameraAvailable = false; + // a path to the image we add to the gallery before test run + var fillerImagePath; function getNextPromiseId() { promiseCount += 1; @@ -72,9 +78,9 @@ describe('Camera tests Android.', function () { }); } - // generates test specs by combining all the specified options + // combinines specified options in all possible variations // you can add more options to test more scenarios - function generateSpecs() { + function generateOptions() { var sourceTypes = [ cameraConstants.PictureSourceType.CAMERA, cameraConstants.PictureSourceType.PHOTOLIBRARY @@ -112,12 +118,13 @@ describe('Camera tests Android.', function () { tapTile .tap({ x: Math.round(screenWidth / 4), - y: Math.round(screenHeight / 5) + y: Math.round(screenHeight / 4) }); swipeRight - .press({x: 10, y: 100}) + .press({x: 10, y: 150}) .wait(300) - .moveTo({x: Math.round(screenWidth / 2), y: 0}) + .moveTo({x: Math.round(screenWidth - (screenWidth / 8)), y: 0}) + .wait(1500) .release() .wait(1000); if (options.allowEdit) { @@ -127,10 +134,18 @@ describe('Camera tests Android.', function () { .performTouchAction(tapTile); } return driver + .waitForElementByXPath('//android.widget.TextView[@text="Gallery"]', 20000) + .elementByXPath('//android.widget.TextView[@text="Gallery"]') + .elementByXPath('//android.widget.TextView[@text="Gallery"]') + .elementByXPath('//android.widget.TextView[@text="Gallery"]') .elementByXPath('//android.widget.TextView[@text="Gallery"]') .fail(function () { return driver .performTouchAction(swipeRight) + .waitForElementByXPath('//android.widget.TextView[@text="Gallery"]', 20000) + .elementByXPath('//android.widget.TextView[@text="Gallery"]') + .elementByXPath('//android.widget.TextView[@text="Gallery"]') + .elementByXPath('//android.widget.TextView[@text="Gallery"]') .elementByXPath('//android.widget.TextView[@text="Gallery"]'); }) .click() @@ -140,9 +155,13 @@ describe('Camera tests Android.', function () { } // taking a picture from camera return driver - .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]', MINUTE) + .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]', MINUTE / 2) + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]') + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]') .click() - .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]', MINUTE) + .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]', MINUTE / 2) + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]') + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]') .click(); }) .then(function () { @@ -156,44 +175,31 @@ describe('Camera tests Android.', function () { } }) .fail(function (failure) { - console.log(failure); - fail(failure); + throw failure; }); } // checks if the picture was successfully taken // if shouldLoad is falsy, ensures that the error callback was called - function checkPicture(shouldLoad) { + function checkPicture(shouldLoad, options) { + if (!options) { + options = {}; + } return driver .context(webviewContext) - .setAsyncScriptTimeout(MINUTE) - .executeAsync(cameraHelper.checkPicture, [getCurrentPromiseId()]) + .setAsyncScriptTimeout(MINUTE / 2) + .executeAsync(cameraHelper.checkPicture, [getCurrentPromiseId(), options]) .then(function (result) { if (shouldLoad) { - expect(result.length).toBeGreaterThan(0); - if (result.indexOf('ERROR') >= 0) { - return fail(result); + if (result !== 'OK') { + fail(result); } - } else { - if (result.indexOf('ERROR') === -1) { - return fail('Unexpected success callback with result: ' + result); - } - expect(result.indexOf('ERROR')).toBe(0); + } else if (result.indexOf('ERROR') === -1) { + throw 'Unexpected success callback with result: ' + result; } }); } - function runCombinedSpec(spec) { - return driver - .then(function () { - return getPicture(spec.options); - }) - .then(function () { - return checkPicture(true); - }) - .fail(saveScreenshotAndFail); - } - // deletes the latest image from the gallery function deleteImage() { var holdTile = new wd.TouchAction(); @@ -216,32 +222,93 @@ describe('Camera tests Android.', function () { function getDriver() { driver = wdHelper.getDriver('Android'); - return wdHelper.getWebviewContext(driver) + return driver.getWebviewContext() .then(function(context) { webviewContext = context; return driver.context(webviewContext); }) + .waitForDeviceReady() + .injectLibraries() + .deleteFillerImage(fillerImagePath) .then(function () { - return wdHelper.waitForDeviceReady(driver); + fillerImagePath = null; }) - .then(function () { - return wdHelper.injectLibraries(driver); + .addFillerImage() + .then(function (result) { + if (result && result.indexOf('ERROR:') === 0) { + throw new Error(result); + } else { + fillerImagePath = result; + } }); } + function recreateSession() { + return driver + .quit() + .finally(function () { + return getDriver(); + }); + } + + function tryRunSpec(spec) { + return driver + .then(spec) + .fail(function () { + return recreateSession() + .then(spec) + .fail(function() { + return recreateSession() + .then(spec); + }); + }) + .fail(saveScreenshotAndFail); + } + + // produces a generic spec function which + // takes a picture with specified options + // and then verifies it + function generateSpec(options) { + return function () { + return driver + .then(function () { + return getPicture(options); + }) + .then(function () { + return checkPicture(true, options); + }); + }; + } + + function checkSession(done) { + if (!appiumSessionStarted) { + fail('Failed to start a session'); + done(); + } + } + + function checkCamera(pending) { + if (!cameraAvailable) { + pending('This test requires camera'); + } + } + it('camera.ui.util configuring driver and starting a session', function (done) { getDriver() - .fail(fail) + .then(function () { + appiumSessionStarted = true; + }, fail) .done(done); - }, 5 * MINUTE); + }, 10 * MINUTE); it('camera.ui.util determine screen dimensions', function (done) { - return driver + checkSession(done); + driver .context(webviewContext) .execute(function () { return { - 'width': window.innerWidth, - 'height': window.innerHeight + 'width': screen.availWidth, + 'height': screen.availHeight }; }, []) .then(function (size) { @@ -251,131 +318,287 @@ describe('Camera tests Android.', function () { .done(done); }, MINUTE); + it('camera.ui.util determine camera availability', function (done) { + checkSession(done); + var opts = { + sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false + }; + + return driver + .then(function () { + return getPicture(opts); + }) + .then(function () { + cameraAvailable = true; + }, function () { + return recreateSession(); + }) + .done(done); + }, 5 * MINUTE); + describe('Specs.', function () { // getPicture() with saveToPhotoLibrary = true - it('camera.ui.spec.1 Saving the picture to photo library', function (done) { - var options = { + it('camera.ui.spec.1 Saving a picture to the photo library', function (done) { + checkSession(done); + checkCamera(pending); + var spec = generateSpec({ quality: 50, allowEdit: false, sourceType: cameraConstants.PictureSourceType.CAMERA, saveToPhotoAlbum: true - }; - driver - .then(function () { - return getPicture(options); - }) + }); + + tryRunSpec(spec) .then(function () { isTestPictureSaved = true; - return checkPicture(true); }) - .fail(saveScreenshotAndFail) .done(done); - }, 3 * MINUTE); + }, 10 * MINUTE); // getPicture() with mediaType: VIDEO, sourceType: PHOTOLIBRARY it('camera.ui.spec.2 Selecting only videos', function (done) { - var options = { sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, - mediaType: cameraConstants.MediaType.VIDEO }; - driver - .then(function () { - return getPicture(options, true); - }) - .context('NATIVE_APP') - .then(function () { - // try to find "Gallery" menu item - // if there's none, the gallery should be already opened - return driver - .elementByXPath('//android.widget.TextView[@text="Gallery"]') - .then(function (element) { - return element.click(); - }, function () { - return driver; - }); - }) - .then(function () { - // if the gallery is opened on the videos page, - // there should be a "Choose video" caption - return driver - .elementByXPath('//*[@text="Choose video"]') - .fail(function () { - throw 'Couldn\'t find "Choose video" element.'; - }); - }) - .deviceKeyEvent(BACK_BUTTON) - .elementByXPath('//android.widget.TextView[@text="Gallery"]') - .deviceKeyEvent(BACK_BUTTON) - .finally(function () { - return driver - .elementById('action_bar_title') - .then(function () { - // success means we're still in native app - return driver - .deviceKeyEvent(BACK_BUTTON); - }, function () { - // error means we're already in webview - return driver; - }); - }) - .fail(saveScreenshotAndFail) - .done(done); - }, 3 * MINUTE); + checkSession(done); + var spec = function () { + var options = { sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + mediaType: cameraConstants.MediaType.VIDEO }; + return driver + .then(function () { + return getPicture(options, true); + }) + .context('NATIVE_APP') + .then(function () { + // try to find "Gallery" menu item + // if there's none, the gallery should be already opened + return driver + .waitForElementByXPath('//android.widget.TextView[@text="Gallery"]', 20000) + .elementByXPath('//android.widget.TextView[@text="Gallery"]') + .elementByXPath('//android.widget.TextView[@text="Gallery"]') + .then(function (element) { + return element.click(); + }, function () { + return driver; + }); + }) + .then(function () { + // if the gallery is opened on the videos page, + // there should be a "Choose video" caption + return driver + .elementByXPath('//*[@text="Choose video"]') + .fail(function () { + throw 'Couldn\'t find "Choose video" element.'; + }); + }) + .deviceKeyEvent(BACK_BUTTON) + .elementByXPath('//android.widget.TextView[@text="Gallery"]') + .deviceKeyEvent(BACK_BUTTON) + .finally(function () { + return driver + .elementById('action_bar_title') + .then(function () { + // success means we're still in native app + return driver + .deviceKeyEvent(BACK_BUTTON) + // give native app some time to close + .sleep(2000) + // try again! because every ~30th build + // on Sauce Labs this backbutton doesn't work + .elementById('action_bar_title') + .then(function () { + // success means we're still in native app + return driver + .deviceKeyEvent(BACK_BUTTON); + }, function () { + // error means we're already in webview + return driver; + }); + }, function () { + // error means we're already in webview + return driver; + }); + }); + }; + tryRunSpec(spec).done(done); + }, 10 * MINUTE); // getPicture(), then dismiss // wait for the error callback to be called it('camera.ui.spec.3 Dismissing the camera', function (done) { - var options = { quality: 50, - allowEdit: true, - sourceType: cameraConstants.PictureSourceType.CAMERA, - destinationType: cameraConstants.DestinationType.FILE_URI }; - driver - .then(function () { - return getPicture(options, true); - }) - .context("NATIVE_APP") - .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'cancel\')]', MINUTE / 2) - .click() - .then(function () { - return checkPicture(false); - }) - .fail(saveScreenshotAndFail) - .done(done); - }, 3 * MINUTE); + checkSession(done); + checkCamera(pending); + var spec = function () { + var options = { + quality: 50, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URI + }; + return driver + .then(function () { + return getPicture(options, true); + }) + .context("NATIVE_APP") + .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'cancel\')]', MINUTE / 2) + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'cancel\')]') + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'cancel\')]') + .click() + .then(function () { + return checkPicture(false); + }); + }; + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); // getPicture(), then take picture but dismiss the edit // wait for the error callback to be called it('camera.ui.spec.4 Dismissing the edit', function (done) { - var options = { quality: 50, - allowEdit: true, - sourceType: cameraConstants.PictureSourceType.CAMERA, - destinationType: cameraConstants.DestinationType.FILE_URI }; - driver - .then(function () { - return getPicture(options, true); - }) - .context('NATIVE_APP') - .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]', MINUTE / 2) - .click() - .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]', MINUTE / 2) - .click() - .waitForElementByXPath('//*[contains(@resource-id,\'discard\')]', MINUTE / 2) - .click() - .then(function () { - return checkPicture(false); - }) - .fail(saveScreenshotAndFail) - .done(done); - }, 3 * MINUTE); + checkSession(done); + checkCamera(pending); + var spec = function () { + var options = { + quality: 50, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URI + }; + return driver + .then(function () { + return getPicture(options, true); + }) + .context('NATIVE_APP') + .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]', MINUTE / 2) + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]') + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'shutter\')]') + .click() + .waitForElementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]', MINUTE / 2) + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]') + .elementByXPath('//android.widget.ImageView[contains(@resource-id,\'done\')]') + .click() + .waitForElementByXPath('//*[contains(@resource-id,\'discard\')]', MINUTE / 2) + .elementByXPath('//*[contains(@resource-id,\'discard\')]') + .elementByXPath('//*[contains(@resource-id,\'discard\')]') + .click() + .then(function () { + return checkPicture(false); + }); + }; + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.5 Verifying target image size, sourceType=CAMERA', function (done) { + checkSession(done); + checkCamera(pending); + var spec = generateSpec({ + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.6 Verifying target image size, sourceType=PHOTOLIBRARY', function (done) { + checkSession(done); + var spec = generateSpec({ + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.7 Verifying target image size, sourceType=CAMERA, DestinationType=NATIVE_URI', function (done) { + checkSession(done); + checkCamera(pending); + var spec = generateSpec({ + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.8 Verifying target image size, sourceType=PHOTOLIBRARY, DestinationType=NATIVE_URI', function (done) { + checkSession(done); + var spec = generateSpec({ + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.9 Verifying target image size, sourceType=CAMERA, DestinationType=NATIVE_URI, quality=100', function (done) { + checkSession(done); + checkCamera(pending); + var spec = generateSpec({ + quality: 100, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.10 Verifying target image size, sourceType=PHOTOLIBRARY, DestinationType=NATIVE_URI, quality=100', function (done) { + checkSession(done); + var spec = generateSpec({ + quality: 100, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); // combine various options for getPicture() - generateSpecs().forEach(function (spec) { - it('camera.ui.spec.5.' + spec.id + ' Combining options. ' + spec.description, function (done) { - runCombinedSpec(spec) - .done(done); - }, 3 * MINUTE); + generateOptions().forEach(function (spec) { + it('camera.ui.spec.11.' + spec.id + ' Combining options. ' + spec.description, function (done) { + checkSession(done); + if (spec.options.sourceType == cameraConstants.PictureSourceType.CAMERA) { + checkCamera(pending); + } + var s = generateSpec(spec.options); + tryRunSpec(s).done(done); + }, 10 * MINUTE); }); + it('camera.ui.util Delete filler picture from device library', function (done) { + driver + .context(webviewContext) + .deleteFillerImage(fillerImagePath) + .done(done); + }, MINUTE); - it('camera.ui.util Delete test image from device library', function (done) { + it('camera.ui.util Delete taken picture from device library', function (done) { + checkSession(done); if (!isTestPictureSaved) { // couldn't save test picture earlier, so nothing to delete here done(); @@ -383,7 +606,7 @@ describe('Camera tests Android.', function () { } // delete exactly one latest picture // this should be the picture we've taken in the first spec - return driver + driver .context('NATIVE_APP') .deviceKeyEvent(BACK_BUTTON) .sleep(1000) @@ -408,6 +631,7 @@ describe('Camera tests Android.', function () { }); it('camera.ui.util Destroy the session', function (done) { + checkSession(done); driver .quit() .done(done); diff --git a/appium-tests/helpers/cameraHelper.js b/appium-tests/helpers/cameraHelper.js index 4f33163..a16916f 100644 --- a/appium-tests/helpers/cameraHelper.js +++ b/appium-tests/helpers/cameraHelper.js @@ -1,5 +1,5 @@ /*jshint node: true */ -/* global Q */ +/* global Q, resolveLocalFileSystemURL, Camera, cordova */ /* * * Licensed to the Apache Software Foundation (ASF) under one @@ -94,7 +94,13 @@ module.exports.generateSpecs = function (sourceTypes, destinationTypes, encoding return specs; }; +// calls getPicture() and saves the result in promise +// note that this function is executed in the context of tested app +// and not in the context of tests module.exports.getPicture = function (opts, pid) { + if (navigator._appiumPromises[pid - 1]) { + navigator._appiumPromises[pid - 1] = null; + } navigator._appiumPromises[pid] = Q.defer(); navigator.camera.getPicture(function (result) { navigator._appiumPromises[pid].resolve(result); @@ -103,11 +109,197 @@ module.exports.getPicture = function (opts, pid) { }, opts); }; -module.exports.checkPicture = function (pid, cb) { +// verifies taken picture when the promise is resolved, +// calls a callback with 'OK' if everything is good, +// calls a callback with 'ERROR: ' if something is wrong +// note that this function is executed in the context of tested app +// and not in the context of tests +module.exports.checkPicture = function (pid, options, cb) { + var isIos = cordova.platformId === "ios"; + var isAndroid = cordova.platformId === "android"; + // skip image type check if it's unmodified on Android: + // https://github.com/apache/cordova-plugin-camera/#android-quirks-1 + var skipFileTypeCheckAndroid = isAndroid && options.quality === 100 && + !options.targetWidth && !options.targetHeight && + !options.correctOrientation; + + // Skip image type check if destination is NATIVE_URI and source - device's photoalbum + // https://github.com/apache/cordova-plugin-camera/#ios-quirks-1 + var skipFileTypeCheckiOS = isIos && options.destinationType === Camera.DestinationType.NATIVE_URI && + (options.sourceType === Camera.PictureSourceType.PHOTOLIBRARY || + options.sourceType === Camera.PictureSourceType.SAVEDPHOTOALBUM); + + var skipFileTypeCheck = skipFileTypeCheckAndroid || skipFileTypeCheckiOS; + + var desiredType = 'JPEG'; + var mimeType = 'image/jpeg'; + if (options.encodingType === Camera.EncodingType.PNG) { + desiredType = 'PNG'; + mimeType = 'image/png'; + } + + function errorCallback(msg) { + if (msg.hasOwnProperty('message')) { + msg = msg.message; + } + cb('ERROR: ' + msg); + } + + // verifies the image we get from plugin + function verifyResult(result) { + if (result.length === 0) { + errorCallback('The result is empty.'); + return; + } else if (isIos && options.destinationType === Camera.DestinationType.NATIVE_URI && result.indexOf('assets-library:') !== 0) { + errorCallback('Expected "' + result.substring(0, 150) + '"to start with "assets-library:"'); + return; + } else if (isIos && options.destinationType === Camera.DestinationType.FILE_URI && result.indexOf('file:') !== 0) { + errorCallback('Expected "' + result.substring(0, 150) + '"to start with "file:"'); + return; + } + + try { + window.atob(result); + // if we got here it is a base64 string (DATA_URL) + result = "data:" + mimeType + ";base64," + result; + } catch (e) { + // not DATA_URL + if (options.destinationType === Camera.DestinationType.DATA_URL) { + errorCallback('Expected ' + result.substring(0, 150) + 'not to be DATA_URL'); + return; + } + } + try { + if (result.indexOf('file:') === 0 || + result.indexOf('content:') === 0 || + result.indexOf('assets-library:') === 0) { + + if (!window.resolveLocalFileSystemURL) { + errorCallback('Cannot read file. Please install cordova-plugin-file to fix this.'); + return; + } + resolveLocalFileSystemURL(result, function (entry) { + if (skipFileTypeCheck) { + displayFile(entry); + } else { + verifyFile(entry); + } + }); + } else { + displayImage(result); + } + } catch (e) { + errorCallback(e); + } + } + + // verifies that the file type matches the requested type + function verifyFile(entry) { + try { + var reader = new FileReader(); + reader.onloadend = function(e) { + var arr = (new Uint8Array(e.target.result)).subarray(0, 4); + var header = ''; + for(var i = 0; i < arr.length; i++) { + header += arr[i].toString(16); + } + var actualType = 'unknown'; + + switch (header) { + case "89504e47": + actualType = 'PNG'; + break; + case 'ffd8ffe0': + case 'ffd8ffe1': + case 'ffd8ffe2': + actualType = 'JPEG'; + break; + } + + if (actualType === desiredType) { + displayFile(entry); + } else { + errorCallback('File type mismatch. Expected ' + desiredType + ', got ' + actualType); + } + }; + reader.onerror = function (e) { + errorCallback(e); + }; + entry.file(function (file) { + reader.readAsArrayBuffer(file); + }, function (e) { + errorCallback(e); + }); + } catch (e) { + errorCallback(e); + } + } + + // reads the file, then displays the image + function displayFile(entry) { + function onFileReceived(file) { + var reader = new FileReader(); + reader.onerror = function (e) { + errorCallback(e); + }; + reader.onloadend = function (evt) { + displayImage(evt.target.result); + }; + reader.readAsDataURL(file); + } + + entry.file(onFileReceived, function (e) { + errorCallback(e); + }); + } + + function displayImage(image) { + try { + var imgEl = document.getElementById('camera_test_image'); + if (!imgEl) { + imgEl = document.createElement('img'); + imgEl.id = 'camera_test_image'; + document.body.appendChild(imgEl); + } + var timedOut = false; + var loadTimeout = setTimeout(function () { + timedOut = true; + imgEl.src = ''; + errorCallback('The image did not load: ' + image.substring(0, 150)); + }, 10000); + var done = function (status) { + if (!timedOut) { + clearTimeout(loadTimeout); + imgEl.src = ''; + cb(status); + } + }; + imgEl.onload = function () { + try { + // aspect ratio is preserved so only one dimension should match + if ((typeof options.targetWidth === 'number' && imgEl.naturalWidth !== options.targetWidth) && + (typeof options.targetHeight === 'number' && imgEl.naturalHeight !== options.targetHeight)) + { + done('ERROR: Wrong image size: ' + imgEl.naturalWidth + 'x' + imgEl.naturalHeight + + '. Requested size: ' + options.targetWidth + 'x' + options.targetHeight); + } else { + done('OK'); + } + } catch (e) { + errorCallback(e); + } + }; + imgEl.src = image; + } catch (e) { + errorCallback(e); + } + } + navigator._appiumPromises[pid].promise .then(function (result) { - cb(result); - }, function (err) { - cb('ERROR: ' + err); + verifyResult(result); + }) + .fail(function (e) { + errorCallback(e); }); }; diff --git a/appium-tests/ios/ios.spec.js b/appium-tests/ios/ios.spec.js index ea856a7..cab45f6 100644 --- a/appium-tests/ios/ios.spec.js +++ b/appium-tests/ios/ios.spec.js @@ -30,7 +30,6 @@ var wdHelper = global.WD_HELPER; var screenshotHelper = global.SCREENSHOT_HELPER; -var wd = wdHelper.getWD(); var isDevice = global.DEVICE; var cameraConstants = require('../../www/CameraConstants'); var cameraHelper = require('../helpers/cameraHelper'); @@ -44,6 +43,8 @@ describe('Camera tests iOS.', function () { var webviewContext = DEFAULT_WEBVIEW_CONTEXT; // promise count to use in promise ID var promiseCount = 0; + // going to set this to false if session is created successfully + var failedToStart = true; function getNextPromiseId() { promiseCount += 1; @@ -66,7 +67,7 @@ describe('Camera tests iOS.', function () { // generates test specs by combining all the specified options // you can add more options to test more scenarios - function generateSpecs() { + function generateOptions() { var sourceTypes = cameraConstants.PictureSourceType; var destinationTypes = cameraConstants.DestinationType; var encodingTypes = cameraConstants.EncodingType; @@ -81,17 +82,8 @@ describe('Camera tests iOS.', function () { .elementByXPath('//*[@label="Use"]') .click() .fail(function () { - return driver - // For some reason "Choose" element is not clickable by standard Appium methods - // So getting its position and tapping there using TouchAction - .elementByXPath('//UIAButton[@label="Choose"]') - .getLocation() - .then(function (loc) { - var tapChoose = new wd.TouchAction(); - tapChoose.tap(loc); - return driver - .performTouchAction(tapChoose); - }); + // For some reason "Choose" element is not clickable by standard Appium methods + return wdHelper.tapElementByXPath('//UIAButton[@label="Choose"]', driver); }); } @@ -136,12 +128,14 @@ describe('Camera tests iOS.', function () { if (cancelCamera) { return driver .waitForElementByXPath('//*[@label="Cancel"]', MINUTE / 2) + .elementByXPath('//*[@label="Cancel"]') + .elementByXPath('//*[@label="Cancel"]') .click(); } return driver .waitForElementByXPath('//*[@label="Take Picture"]', MINUTE / 2) .click() - .elementByXPath('//*[@label="Use Photo"]') + .waitForElementByXPath('//*[@label="Use Photo"]', MINUTE / 2) .click(); }) .fail(fail); @@ -149,33 +143,34 @@ describe('Camera tests iOS.', function () { // checks if the picture was successfully taken // if shouldLoad is falsy, ensures that the error callback was called - function checkPicture(shouldLoad) { + function checkPicture(shouldLoad, options) { + if (!options) { + options = {}; + } return driver .context(webviewContext) - .setAsyncScriptTimeout(MINUTE) - .executeAsync(cameraHelper.checkPicture, [getCurrentPromiseId()]) + .setAsyncScriptTimeout(MINUTE / 2) + .executeAsync(cameraHelper.checkPicture, [getCurrentPromiseId(), options]) .then(function (result) { if (shouldLoad) { - expect(result.length).toBeGreaterThan(0); - if (result.indexOf('ERROR') >= 0) { - return fail(result); + if (result !== 'OK') { + fail(result); } - } else { - if (result.indexOf('ERROR') === -1) { - return fail('Unexpected success callback with result: ' + result); - } - expect(result.indexOf('ERROR')).toBe(0); + } else if (result.indexOf('ERROR') === -1) { + throw 'Unexpected success callback with result: ' + result; } }); } - function runCombinedSpec(spec) { + // takes a picture with the specified options + // and then verifies it + function runSpec(options) { return driver .then(function () { - return getPicture(spec.options); + return getPicture(options); }) .then(function () { - return checkPicture(true); + return checkPicture(true, options); }) .fail(saveScreenshotAndFail); } @@ -195,15 +190,25 @@ describe('Camera tests iOS.', function () { }); } + function checkSession(done) { + if (failedToStart) { + fail('Failed to start a session'); + done(); + } + } + it('camera.ui.util configure driver and start a session', function (done) { getDriver() - .fail(fail) - .finally(done); - }, 5 * MINUTE); + .then(function () { + failedToStart = false; + }, fail) + .done(done); + }, 10 * MINUTE); describe('Specs.', function () { // getPicture() with mediaType: VIDEO, sourceType: PHOTOLIBRARY it('camera.ui.spec.1 Selecting only videos', function (done) { + checkSession(done); var options = { sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, mediaType: cameraConstants.MediaType.VIDEO }; driver @@ -219,11 +224,12 @@ describe('Camera tests iOS.', function () { // getPicture(), then dismiss // wait for the error callback to be called it('camera.ui.spec.2 Dismissing the camera', function (done) { - // camera is not available on the iOS simulator + checkSession(done); if (!isDevice) { - pending(); + pending('Camera is not available on iOS simulator'); } - var options = { sourceType: cameraConstants.PictureSourceType.CAMERA }; + var options = { sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false }; driver .then(function () { return getPicture(options, true); @@ -235,20 +241,175 @@ describe('Camera tests iOS.', function () { .done(done); }, 3 * MINUTE); + it('camera.ui.spec.3 Verifying target image size, sourceType=CAMERA', function (done) { + checkSession(done); + if (!isDevice) { + pending('Camera is not available on iOS simulator'); + } + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.4 Verifying target image size, sourceType=SAVEDPHOTOALBUM', function (done) { + checkSession(done); + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.5 Verifying target image size, sourceType=PHOTOLIBRARY', function (done) { + checkSession(done); + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.6 Verifying target image size, sourceType=CAMERA, destinationType=FILE_URL', function (done) { + // remove this line if you don't mind the tests leaving a photo saved on device + pending('Cannot prevent iOS from saving the picture to photo library'); + + checkSession(done); + if (!isDevice) { + pending('Camera is not available on iOS simulator'); + } + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.7 Verifying target image size, sourceType=SAVEDPHOTOALBUM, destinationType=FILE_URL', function (done) { + checkSession(done); + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.8 Verifying target image size, sourceType=PHOTOLIBRARY, destinationType=FILE_URL', function (done) { + checkSession(done); + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.9 Verifying target image size, sourceType=CAMERA, destinationType=FILE_URL, quality=100', function (done) { + // remove this line if you don't mind the tests leaving a photo saved on device + pending('Cannot prevent iOS from saving the picture to photo library'); + + checkSession(done); + if (!isDevice) { + pending('Camera is not available on iOS simulator'); + } + var options = { + quality: 100, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.10 Verifying target image size, sourceType=SAVEDPHOTOALBUM, destinationType=FILE_URL, quality=100', function (done) { + checkSession(done); + var options = { + quality: 100, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + + it('camera.ui.spec.11 Verifying target image size, sourceType=PHOTOLIBRARY, destinationType=FILE_URL, quality=100', function (done) { + checkSession(done); + var options = { + quality: 100, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + + runSpec(options).done(done); + }, 3 * MINUTE); + // combine various options for getPicture() - generateSpecs().forEach(function (spec) { - it('camera.ui.spec.3.' + spec.id + ' Combining options. ' + spec.description, function (done) { - // camera is not available on iOS simulator + generateOptions().forEach(function (spec) { + it('camera.ui.spec.12.' + spec.id + ' Combining options. ' + spec.description, function (done) { + checkSession(done); if (!isDevice && spec.options.sourceType === cameraConstants.PictureSourceType.CAMERA) { - pending(); + pending('Camera is not available on iOS simulator'); } - runCombinedSpec(spec).done(done); + + // remove this check if you don't mind the tests leaving a photo saved on device + if (spec.options.sourceType === cameraConstants.PictureSourceType.CAMERA && + spec.options.destinationType === cameraConstants.DestinationType.NATIVE_URI) { + pending('Skipping: cannot prevent iOS from saving the picture to photo library and cannot delete it. ' + + 'For more info, see iOS quirks here: https://github.com/apache/cordova-plugin-camera#ios-quirks-1'); + } + + runSpec(spec.options).done(done); }, 3 * MINUTE); }); }); - it('camera.ui.util.4 Destroy the session', function (done) { + it('camera.ui.util Destroy the session', function (done) { + checkSession(done); driver .quit() .done(done); diff --git a/jsdoc2md/TEMPLATE.md b/jsdoc2md/TEMPLATE.md index d554107..65cdbbc 100644 --- a/jsdoc2md/TEMPLATE.md +++ b/jsdoc2md/TEMPLATE.md @@ -4,7 +4,9 @@ description: Take pictures with the device camera. --- {{>cdv-license~}} -[![Build Status](https://travis-ci.org/apache/cordova-plugin-camera.svg?branch=master)](https://travis-ci.org/apache/cordova-plugin-camera) +|Android|iOS| Windows 8.1 Store | Windows 8.1 Phone | Windows 10 Store | Travis CI | +|:-:|:-:|:-:|:-:|:-:|:-:| +|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=android,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=android,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=ios,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=ios,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=windows-8.1-store,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=windows-8.1-store,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=windows-8.1-phone,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=windows-8.1-phone,PLUGIN=cordova-plugin-camera/)|[![Build Status](http://cordova-ci.cloudapp.net:8080/buildStatus/icon?job=cordova-periodic-build/PLATFORM=windows-10-store,PLUGIN=cordova-plugin-camera)](http://cordova-ci.cloudapp.net:8080/job/cordova-periodic-build/PLATFORM=windows-10-store,PLUGIN=cordova-plugin-camera/)|[![Build Status](https://travis-ci.org/apache/cordova-plugin-camera.svg?branch=master)](https://travis-ci.org/apache/cordova-plugin-camera) # cordova-plugin-camera @@ -15,7 +17,7 @@ the system's image library. --- -# API Reference +# API Reference {{#orphans~}} {{>member-index}} @@ -120,6 +122,16 @@ displays: Invoking the native camera application while the device is connected via Zune does not work, and triggers an error callback. +#### Windows quirks + +On Windows Phone 8.1 using `SAVEDPHOTOALBUM` or `PHOTOLIBRARY` as a source type causes application to suspend until file picker returns the selected image and +then restore with start page as defined in app's `config.xml`. In case when `camera.getPicture` was called from different page, this will lead to reloading +start page from scratch and success and error callbacks will never be called. + +To avoid this we suggest using SPA pattern or call `camera.getPicture` only from your app's start page. + +More information about Windows Phone 8.1 picker APIs is here: [How to continue your Windows Phone app after calling a file picker](https://msdn.microsoft.com/en-us/library/windows/apps/dn720490.aspx) + #### Tizen Quirks Tizen only supports a `destinationType` of @@ -185,6 +197,8 @@ Tizen only supports a `destinationType` of - When using `destinationType.NATIVE_URI` and `sourceType.CAMERA`, photos are saved in the saved photo album regardless on the value of `saveToPhotoAlbum` parameter. +- When using `destinationType.NATIVE_URI` and `sourceType.PHOTOLIBRARY` or `sourceType.SAVEDPHOTOALBUM`, all editing options are ignored and link is returned to original picture. + #### Tizen Quirks - options not supported @@ -274,7 +288,7 @@ function displayImage(imgUri) { } ``` -To display the image on some platforms, you might need to include the main part of the URI in the Content-Security-Policy element in index.html. For example, on Windows 10, you can include `ms-appdata:` in your element. Here is an example. +To display the image on some platforms, you might need to include the main part of the URI in the Content-Security-Policy `` element in index.html. For example, on Windows 10, you can include `ms-appdata:` in your `` element. Here is an example. ```html diff --git a/plugin.xml b/plugin.xml index 99769ab..0911fb8 100644 --- a/plugin.xml +++ b/plugin.xml @@ -150,6 +150,11 @@ + + + $CAMERA_USAGE_DESCRIPTION + + diff --git a/src/android/CameraLauncher.java b/src/android/CameraLauncher.java index 60b07fe..69cb5db 100644 --- a/src/android/CameraLauncher.java +++ b/src/android/CameraLauncher.java @@ -59,7 +59,6 @@ import android.os.Environment; import android.provider.DocumentsContract; import android.provider.MediaStore; import android.util.Base64; -import android.util.Log; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; @@ -140,7 +139,7 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect this.targetWidth = 0; this.encodingType = JPEG; this.mediaType = PICTURE; - this.mQuality = 80; + this.mQuality = 50; //Take the values from the arguments if they're not already defined (this is tricky) this.destType = args.getInt(1); @@ -366,7 +365,7 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect intent.putExtra("aspectX", 1); intent.putExtra("aspectY", 1); } - File photo = createCaptureFile(encodingType); + File photo = createCaptureFile(JPEG); croppedUri = Uri.fromFile(photo); intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, croppedUri); } else { @@ -428,14 +427,14 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect cropIntent, CROP_CAMERA + destType); } } catch (ActivityNotFoundException anfe) { - Log.e(LOG_TAG, "Crop operation not supported on this device"); + LOG.e(LOG_TAG, "Crop operation not supported on this device"); try { processResultFromCamera(destType, cameraIntent); } catch (IOException e) { e.printStackTrace(); - Log.e(LOG_TAG, "Unable to write to file"); + LOG.e(LOG_TAG, "Unable to write to file"); } } } @@ -496,7 +495,7 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect // Double-check the bitmap. if (bitmap == null) { - Log.d(LOG_TAG, "I either have a null image path or bitmap"); + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); this.failPicture("Unable to create bitmap!"); return; } @@ -536,7 +535,7 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect // Double-check the bitmap. if (bitmap == null) { - Log.d(LOG_TAG, "I either have a null image path or bitmap"); + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); this.failPicture("Unable to create bitmap!"); return; } @@ -588,6 +587,18 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect this.cordova.getActivity().sendBroadcast(mediaScanIntent); } + /** + * Converts output image format int value to string value of mime type. + * @param outputFormat int Output format of camera API. + * Must be value of either JPEG or PNG constant + * @return String String value of mime type or empty string if mime type is not supported + */ + private String getMimetypeForFormat(int outputFormat) { + if (outputFormat == PNG) return "image/png"; + if (outputFormat == JPEG) return "image/jpeg"; + return ""; + } + private String outputModifiedBitmap(Bitmap bitmap, Uri uri) throws IOException { @@ -639,7 +650,7 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect int rotate = 0; String fileLocation = FileHelper.getRealPath(uri, this.cordova); - Log.d(LOG_TAG, "File locaton is: " + fileLocation); + LOG.d(LOG_TAG, "File locaton is: " + fileLocation); // If you ask for video or all media type you will automatically get back a file URI // and there will be no attempt to resize any returned data @@ -647,18 +658,21 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect this.callbackContext.success(fileLocation); } else { + String uriString = uri.toString(); + // Get the path to the image. Makes loading so much easier. + String mimeType = FileHelper.getMimeType(uriString, this.cordova); + // This is a special case to just return the path as no scaling, // rotating, nor compressing needs to be done if (this.targetHeight == -1 && this.targetWidth == -1 && - (destType == FILE_URI || destType == NATIVE_URI) && !this.correctOrientation) { - this.callbackContext.success(uri.toString()); + (destType == FILE_URI || destType == NATIVE_URI) && !this.correctOrientation && + mimeType.equalsIgnoreCase(getMimetypeForFormat(encodingType))) + { + this.callbackContext.success(uriString); } else { - String uriString = uri.toString(); - // Get the path to the image. Makes loading so much easier. - String mimeType = FileHelper.getMimeType(uriString, this.cordova); // If we don't have a valid image so quit. if (!("image/jpeg".equalsIgnoreCase(mimeType) || "image/png".equalsIgnoreCase(mimeType))) { - Log.d(LOG_TAG, "I either have a null image path or bitmap"); + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); this.failPicture("Unable to retrieve path to picture!"); return; } @@ -669,7 +683,7 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect e.printStackTrace(); } if (bitmap == null) { - Log.d(LOG_TAG, "I either have a null image path or bitmap"); + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); this.failPicture("Unable to create bitmap!"); return; } @@ -683,7 +697,9 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect else if (destType == FILE_URI || destType == NATIVE_URI) { // Did we modify the image? if ( (this.targetHeight > 0 && this.targetWidth > 0) || - (this.correctOrientation && this.orientationCorrected) ) { + (this.correctOrientation && this.orientationCorrected) || + !mimeType.equalsIgnoreCase(getMimetypeForFormat(encodingType))) + { try { String modifiedPath = this.outputModifiedBitmap(bitmap, uri); // The modified image is cached by the app in order to get around this and not have to delete you @@ -733,7 +749,7 @@ public class CameraLauncher extends CordovaPlugin implements MediaScannerConnect processResultFromCamera(destType, intent); } catch (IOException e) { e.printStackTrace(); - Log.e(LOG_TAG, "Unable to write to file"); + LOG.e(LOG_TAG, "Unable to write to file"); } }// If cancelled diff --git a/src/android/CameraLauncherMaster.java b/src/android/CameraLauncherMaster.java new file mode 100644 index 0000000..506d6d4 --- /dev/null +++ b/src/android/CameraLauncherMaster.java @@ -0,0 +1,1307 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ +package org.apache.cordova.camera; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.text.SimpleDateFormat; +import java.util.Date; + +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.CordovaResourceApi; +import org.apache.cordova.LOG; +import org.apache.cordova.PermissionHelper; +import org.apache.cordova.PluginResult; +import org.json.JSONArray; +import org.json.JSONException; + +import android.Manifest; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.media.ExifInterface; +import android.media.MediaScannerConnection; +import android.media.MediaScannerConnection.MediaScannerConnectionClient; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.util.Base64; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.PermissionInfo; + +/** + * This class launches the camera view, allows the user to take a picture, closes the camera view, + * and returns the captured image. When the camera view is closed, the screen displayed before + * the camera view was shown is redisplayed. + */ +public class CameraLauncher extends CordovaPlugin implements MediaScannerConnectionClient { + + private static final int DATA_URL = 0; // Return base64 encoded string + private static final int FILE_URI = 1; // Return file uri (content://media/external/images/media/2 for Android) + private static final int NATIVE_URI = 2; // On Android, this is the same as FILE_URI + + private static final int PHOTOLIBRARY = 0; // Choose image from picture library (same as SAVEDPHOTOALBUM for Android) + private static final int CAMERA = 1; // Take picture from camera + private static final int SAVEDPHOTOALBUM = 2; // Choose image from picture library (same as PHOTOLIBRARY for Android) + + private static final int PICTURE = 0; // allow selection of still pictures only. DEFAULT. Will return format specified via DestinationType + private static final int VIDEO = 1; // allow selection of video only, ONLY RETURNS URL + private static final int ALLMEDIA = 2; // allow selection from all media types + + private static final int JPEG = 0; // Take a picture of type JPEG + private static final int PNG = 1; // Take a picture of type PNG + private static final String GET_PICTURE = "Get Picture"; + private static final String GET_VIDEO = "Get Video"; + private static final String GET_All = "Get All"; + + public static final int PERMISSION_DENIED_ERROR = 20; + public static final int TAKE_PIC_SEC = 0; + public static final int SAVE_TO_ALBUM_SEC = 1; + + private static final String LOG_TAG = "CameraLauncher"; + + //Where did this come from? + private static final int CROP_CAMERA = 100; + + private int mQuality; // Compression quality hint (0-100: 0=low quality & high compression, 100=compress of max quality) + private int targetWidth; // desired width of the image + private int targetHeight; // desired height of the image + private Uri imageUri; // Uri of captured image + private int encodingType; // Type of encoding to use + private int mediaType; // What type of media to retrieve + private int destType; // Source type (needs to be saved for the permission handling) + private int srcType; // Destination type (needs to be saved for permission handling) + private boolean saveToPhotoAlbum; // Should the picture be saved to the device's photo album + private boolean correctOrientation; // Should the pictures orientation be corrected + private boolean orientationCorrected; // Has the picture's orientation been corrected + private boolean allowEdit; // Should we allow the user to crop the image. + + protected final static String[] permissions = { Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE }; + + public CallbackContext callbackContext; + private int numPics; + + private MediaScannerConnection conn; // Used to update gallery app with newly-written files + private Uri scanMe; // Uri of image to be added to content store + private Uri croppedUri; + private ExifHelper exifData; // Exif data from source + + + /** + * Executes the request and returns PluginResult. + * + * @param action The action to execute. + * @param args JSONArry of arguments for the plugin. + * @param callbackContext The callback id used when calling back into JavaScript. + * @return A PluginResult object with a status and message. + */ + public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { + this.callbackContext = callbackContext; + + if (action.equals("takePicture")) { + this.srcType = CAMERA; + this.destType = FILE_URI; + this.saveToPhotoAlbum = false; + this.targetHeight = 0; + this.targetWidth = 0; + this.encodingType = JPEG; + this.mediaType = PICTURE; + this.mQuality = 50; + + //Take the values from the arguments if they're not already defined (this is tricky) + this.destType = args.getInt(1); + this.srcType = args.getInt(2); + this.mQuality = args.getInt(0); + this.targetWidth = args.getInt(3); + this.targetHeight = args.getInt(4); + this.encodingType = args.getInt(5); + this.mediaType = args.getInt(6); + this.allowEdit = args.getBoolean(7); + this.correctOrientation = args.getBoolean(8); + this.saveToPhotoAlbum = args.getBoolean(9); + + // If the user specifies a 0 or smaller width/height + // make it -1 so later comparisons succeed + if (this.targetWidth < 1) { + this.targetWidth = -1; + } + if (this.targetHeight < 1) { + this.targetHeight = -1; + } + + // We don't return full-quality PNG files. The camera outputs a JPEG + // so requesting it as a PNG provides no actual benefit + if (this.targetHeight == -1 && this.targetWidth == -1 && this.mQuality == 100 && + !this.correctOrientation && this.encodingType == PNG && this.srcType == CAMERA) { + this.encodingType = JPEG; + } + + try { + if (this.srcType == CAMERA) { + this.callTakePicture(destType, encodingType); + } + else if ((this.srcType == PHOTOLIBRARY) || (this.srcType == SAVEDPHOTOALBUM)) { + // FIXME: Stop always requesting the permission + if(!PermissionHelper.hasPermission(this, permissions[0])) { + PermissionHelper.requestPermission(this, SAVE_TO_ALBUM_SEC, Manifest.permission.READ_EXTERNAL_STORAGE); + } else { + this.getImage(this.srcType, destType, encodingType); + } + } + } + catch (IllegalArgumentException e) + { + callbackContext.error("Illegal Argument Exception"); + PluginResult r = new PluginResult(PluginResult.Status.ERROR); + callbackContext.sendPluginResult(r); + return true; + } + + PluginResult r = new PluginResult(PluginResult.Status.NO_RESULT); + r.setKeepCallback(true); + callbackContext.sendPluginResult(r); + + return true; + } + return false; + } + + //-------------------------------------------------------------------------- + // LOCAL METHODS + //-------------------------------------------------------------------------- + + private String getTempDirectoryPath() { + File cache = null; + + // SD Card Mounted + if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + cache = cordova.getActivity().getExternalCacheDir(); + } + // Use internal storage + else { + cache = cordova.getActivity().getCacheDir(); + } + + // Create the cache directory if it doesn't exist + cache.mkdirs(); + return cache.getAbsolutePath(); + } + + /** + * Take a picture with the camera. + * When an image is captured or the camera view is cancelled, the result is returned + * in CordovaActivity.onActivityResult, which forwards the result to this.onActivityResult. + * + * The image can either be returned as a base64 string or a URI that points to the file. + * To display base64 string in an img tag, set the source to: + * img.src="data:image/jpeg;base64,"+result; + * or to display URI in an img tag + * img.src=result; + * + * @param returnType Set the type of image to return. + * @param encodingType JPEG or PNG + */ + public void callTakePicture(int returnType, int encodingType) { + boolean saveAlbumPermission = PermissionHelper.hasPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); + boolean takePicturePermission = PermissionHelper.hasPermission(this, Manifest.permission.CAMERA); + + // CB-10120: The CAMERA permission does not need to be requested unless it is declared + // in AndroidManifest.xml. This plugin does not declare it, but others may and so we must + // check the package info to determine if the permission is present. + + if (!takePicturePermission) { + takePicturePermission = true; + try { + PackageManager packageManager = this.cordova.getActivity().getPackageManager(); + String[] permissionsInPackage = packageManager.getPackageInfo(this.cordova.getActivity().getPackageName(), PackageManager.GET_PERMISSIONS).requestedPermissions; + if (permissionsInPackage != null) { + for (String permission : permissionsInPackage) { + if (permission.equals(Manifest.permission.CAMERA)) { + takePicturePermission = false; + break; + } + } + } + } catch (NameNotFoundException e) { + // We are requesting the info for our package, so this should + // never be caught + } + } + + if (takePicturePermission && saveAlbumPermission) { + takePicture(returnType, encodingType); + } else if (saveAlbumPermission && !takePicturePermission) { + PermissionHelper.requestPermission(this, TAKE_PIC_SEC, Manifest.permission.CAMERA); + } else if (!saveAlbumPermission && takePicturePermission) { + PermissionHelper.requestPermission(this, TAKE_PIC_SEC, Manifest.permission.READ_EXTERNAL_STORAGE); + } else { + PermissionHelper.requestPermissions(this, TAKE_PIC_SEC, permissions); + } + } + + public void takePicture(int returnType, int encodingType) + { + // Save the number of images currently on disk for later + this.numPics = queryImgDB(whichContentStore()).getCount(); + + // Let's use the intent and see what happens + Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + + // Specify file so that large image is captured and returned + File photo = createCaptureFile(encodingType); + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, Uri.fromFile(photo)); + this.imageUri = Uri.fromFile(photo); + + if (this.cordova != null) { + // Let's check to make sure the camera is actually installed. (Legacy Nexus 7 code) + PackageManager mPm = this.cordova.getActivity().getPackageManager(); + if(intent.resolveActivity(mPm) != null) + { + + this.cordova.startActivityForResult((CordovaPlugin) this, intent, (CAMERA + 1) * 16 + returnType + 1); + } + else + { + LOG.d(LOG_TAG, "Error: You don't have a default camera. Your device may not be CTS complaint."); + } + } +// else +// LOG.d(LOG_TAG, "ERROR: You must use the CordovaInterface for this to work correctly. Please implement it in your activity"); + } + + /** + * Create a file in the applications temporary directory based upon the supplied encoding. + * + * @param encodingType of the image to be taken + * @return a File object pointing to the temporary picture + */ + private File createCaptureFile(int encodingType) { + return createCaptureFile(encodingType, ""); + } + + /** + * Create a file in the applications temporary directory based upon the supplied encoding. + * + * @param encodingType of the image to be taken + * @param fileName or resultant File object. + * @return a File object pointing to the temporary picture + */ + private File createCaptureFile(int encodingType, String fileName) { + if (fileName.isEmpty()) { + fileName = ".Pic"; + } + + if (encodingType == JPEG) { + fileName = fileName + ".jpg"; + } else if (encodingType == PNG) { + fileName = fileName + ".png"; + } else { + throw new IllegalArgumentException("Invalid Encoding Type: " + encodingType); + } + + return new File(getTempDirectoryPath(), fileName); + } + + + + /** + * Get image from photo library. + * + * @param srcType The album to get image from. + * @param returnType Set the type of image to return. + * @param encodingType + */ + // TODO: Images selected from SDCARD don't display correctly, but from CAMERA ALBUM do! + // TODO: Images from kitkat filechooser not going into crop function + public void getImage(int srcType, int returnType, int encodingType) { + Intent intent = new Intent(); + String title = GET_PICTURE; + croppedUri = null; + if (this.mediaType == PICTURE) { + intent.setType("image/*"); + if (this.allowEdit) { + intent.setAction(Intent.ACTION_PICK); + intent.putExtra("crop", "true"); + if (targetWidth > 0) { + intent.putExtra("outputX", targetWidth); + } + if (targetHeight > 0) { + intent.putExtra("outputY", targetHeight); + } + if (targetHeight > 0 && targetWidth > 0 && targetWidth == targetHeight) { + intent.putExtra("aspectX", 1); + intent.putExtra("aspectY", 1); + } + File photo = createCaptureFile(JPEG); + croppedUri = Uri.fromFile(photo); + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, croppedUri); + } else { + intent.setAction(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + } + } else if (this.mediaType == VIDEO) { + intent.setType("video/*"); + title = GET_VIDEO; + intent.setAction(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + } else if (this.mediaType == ALLMEDIA) { + // I wanted to make the type 'image/*, video/*' but this does not work on all versions + // of android so I had to go with the wildcard search. + intent.setType("*/*"); + title = GET_All; + intent.setAction(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + } + if (this.cordova != null) { + this.cordova.startActivityForResult((CordovaPlugin) this, Intent.createChooser(intent, + new String(title)), (srcType + 1) * 16 + returnType + 1); + } + } + + /** + * Brings up the UI to perform crop on passed image URI + * + * @param picUri + */ + private void performCrop(Uri picUri, int destType, Intent cameraIntent) { + try { + Intent cropIntent = new Intent("com.android.camera.action.CROP"); + // indicate image type and Uri + cropIntent.setDataAndType(picUri, "image/*"); + // set crop properties + cropIntent.putExtra("crop", "true"); + + // indicate output X and Y + if (targetWidth > 0) { + cropIntent.putExtra("outputX", targetWidth); + } + if (targetHeight > 0) { + cropIntent.putExtra("outputY", targetHeight); + } + if (targetHeight > 0 && targetWidth > 0 && targetWidth == targetHeight) { + cropIntent.putExtra("aspectX", 1); + cropIntent.putExtra("aspectY", 1); + } + // create new file handle to get full resolution crop + croppedUri = Uri.fromFile(createCaptureFile(this.encodingType, System.currentTimeMillis() + "")); + cropIntent.putExtra("output", croppedUri); + + // start the activity - we handle returning in onActivityResult + + if (this.cordova != null) { + this.cordova.startActivityForResult((CordovaPlugin) this, + cropIntent, CROP_CAMERA + destType); + } + } catch (ActivityNotFoundException anfe) { + LOG.e(LOG_TAG, "Crop operation not supported on this device"); + try { + processResultFromCamera(destType, cameraIntent); + } + catch (IOException e) + { + e.printStackTrace(); + LOG.e(LOG_TAG, "Unable to write to file"); + } + } + } + + /** + * Applies all needed transformation to the image received from the camera. + * + * @param destType In which form should we return the image + * @param intent An Intent, which can return result data to the caller (various data can be attached to Intent "extras"). + */ + private void processResultFromCamera(int destType, Intent intent) throws IOException { + int rotate = 0; + + // Create an ExifHelper to save the exif data that is lost during compression + ExifHelper exif = new ExifHelper(); + String sourcePath = (this.allowEdit && this.croppedUri != null) ? + FileHelper.stripFileProtocol(this.croppedUri.toString()) : + FileHelper.stripFileProtocol(this.imageUri.toString()); + + if (this.encodingType == JPEG) { + try { + //We don't support PNG, so let's not pretend we do + exif.createInFile(sourcePath); + exif.readExifData(); + rotate = exif.getOrientation(); + + } catch (IOException e) { + e.printStackTrace(); + } + } + + Bitmap bitmap = null; + Uri galleryUri = null; + + // CB-5479 When this option is given the unchanged image should be saved + // in the gallery and the modified image is saved in the temporary + // directory + if (this.saveToPhotoAlbum) { + galleryUri = Uri.fromFile(new File(getPicturesPath())); + + if(this.allowEdit && this.croppedUri != null) { + writeUncompressedImage(this.croppedUri, galleryUri); + } else { + writeUncompressedImage(this.imageUri, galleryUri); + } + + refreshGallery(galleryUri); + } + + // If sending base64 image back + if (destType == DATA_URL) { + bitmap = getScaledAndRotatedBitmap(sourcePath); + + if (bitmap == null) { + // Try to get the bitmap from intent. + bitmap = (Bitmap)intent.getExtras().get("data"); + } + + // Double-check the bitmap. + if (bitmap == null) { + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); + this.failPicture("Unable to create bitmap!"); + return; + } + + + this.processPicture(bitmap, this.encodingType); + + if (!this.saveToPhotoAlbum) { + checkForDuplicateImage(DATA_URL); + } + } + + // If sending filename back + else if (destType == FILE_URI || destType == NATIVE_URI) { + // If all this is true we shouldn't compress the image. + if (this.targetHeight == -1 && this.targetWidth == -1 && this.mQuality == 100 && + !this.correctOrientation) { + + // If we saved the uncompressed photo to the album, we can just + // return the URI we already created + if (this.saveToPhotoAlbum) { + this.callbackContext.success(galleryUri.toString()); + } else { + Uri uri = Uri.fromFile(createCaptureFile(this.encodingType, System.currentTimeMillis() + "")); + + if(this.allowEdit && this.croppedUri != null) { + writeUncompressedImage(this.croppedUri, uri); + } else { + writeUncompressedImage(this.imageUri, uri); + } + + this.callbackContext.success(uri.toString()); + } + } else { + Uri uri = Uri.fromFile(createCaptureFile(this.encodingType, System.currentTimeMillis() + "")); + bitmap = getScaledAndRotatedBitmap(sourcePath); + + // Double-check the bitmap. + if (bitmap == null) { + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); + this.failPicture("Unable to create bitmap!"); + return; + } + + + // Add compressed version of captured image to returned media store Uri + OutputStream os = this.cordova.getActivity().getContentResolver().openOutputStream(uri); + CompressFormat compressFormat = encodingType == JPEG ? + CompressFormat.JPEG : + CompressFormat.PNG; + + bitmap.compress(compressFormat, this.mQuality, os); + os.close(); + + // Restore exif data to file + if (this.encodingType == JPEG) { + String exifPath; + exifPath = uri.getPath(); + exif.createOutFile(exifPath); + exif.writeExifData(); + } + + // Send Uri back to JavaScript for viewing image + this.callbackContext.success(uri.toString()); + + } + } else { + throw new IllegalStateException(); + } + + this.cleanup(FILE_URI, this.imageUri, galleryUri, bitmap); + bitmap = null; + } + +private String getPicutresPath() +{ + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + String imageFileName = "IMG_" + timeStamp + (this.encodingType == JPEG ? ".jpg" : ".png"); + File storageDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES); + String galleryPath = storageDir.getAbsolutePath() + "/" + imageFileName; + return galleryPath; +} + +private void refreshGallery(Uri contentUri) +{ + Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + mediaScanIntent.setData(contentUri); + this.cordova.getActivity().sendBroadcast(mediaScanIntent); +} + + /** + * Converts output image format int value to string value of mime type. + * @param outputFormat int Output format of camera API. + * Must be value of either JPEG or PNG constant + * @return String String value of mime type or empty string if mime type is not supported + */ + private String getMimetypeForFormat(int outputFormat) { + if (outputFormat == PNG) return "image/png"; + if (outputFormat == JPEG) return "image/jpeg"; + return ""; + } + + private String ouputModifiedBitmap(Bitmap bitmap, Uri uri) throws IOException { + // Some content: URIs do not map to file paths (e.g. picasa). + String realPath = FileHelper.getRealPath(uri, this.cordova); + + // Get filename from uri + String fileName = realPath != null ? + realPath.substring(realPath.lastIndexOf('/') + 1, realPath.lastIndexOf(".") + 1) : + "modified."; + + // Append filename extension based on output encoding type + fileName += (this.encodingType == JPEG ? "jpg" : "png"); + + String modifiedPath = getTempDirectoryPath() + "/" + fileName; + + OutputStream os = new FileOutputStream(modifiedPath); + CompressFormat compressFormat = this.encodingType == JPEG ? + CompressFormat.JPEG : + CompressFormat.PNG; + + bitmap.compress(compressFormat, this.mQuality, os); + os.close(); + + if (realPath != null && this.encodingType == JPEG) { + // Create an ExifHelper to save the exif data that is lost during compression + ExifHelper exif = new ExifHelper(); + + try { + exif.createInFile(realPath); + exif.readExifData(); + if (this.correctOrientation && this.orientationCorrected) { + exif.resetOrientation(); + } + exif.createOutFile(modifiedPath); + exif.writeExifData(); + } catch (IOException e) { + e.printStackTrace(); + } + } + return modifiedPath; + } + + + +/** + * Applies all needed transformation to the image received from the gallery. + * + * @param destType In which form should we return the image + * @param intent An Intent, which can return result data to the caller (various data can be attached to Intent "extras"). + */ + private void processResultFromGallery(int destType, Intent intent) { + Uri uri = intent.getData(); + if (uri == null) { + if (croppedUri != null) { + uri = croppedUri; + } else { + this.failPicture("null data from photo library"); + return; + } + } + int rotate = 0; + + String fileLocation = FileHelper.getRealPath(uri, this.cordova); + LOG.d(LOG_TAG, "File locaton is: " + fileLocation); + + // If you ask for video or all media type you will automatically get back a file URI + // and there will be no attempt to resize any returned data + if (this.mediaType != PICTURE) { + this.callbackContext.success(fileLocation); + } + else { + String uriString = uri.toString(); + // Get the path to the image. Makes loading so much easier. + String mimeType = FileHelper.getMimeType(uriString, this.cordova); + + // This is a special case to just return the path as no scaling, + // rotating, nor compressing needs to be done + if (this.targetHeight == -1 && this.targetWidth == -1 && + (destType == FILE_URI || destType == NATIVE_URI) && !this.correctOrientation && + mimeType.equalsIgnoreCase(getMimetypeForFormat(encodingType))) + { + this.callbackContext.success(uriString); + } else { + // If we don't have a valid image so quit. + if (!("image/jpeg".equalsIgnoreCase(mimeType) || "image/png".equalsIgnoreCase(mimeType))) { + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); + this.failPicture("Unable to retrieve path to picture!"); + return; + } + Bitmap bitmap = null; + try { + bitmap = getScaledBitmap(uriString); + } catch (IOException e) { + e.printStackTrace(); + } + if (bitmap == null) { + LOG.d(LOG_TAG, "I either have a null image path or bitmap"); + this.failPicture("Unable to create bitmap!"); + return; + } + + if (this.correctOrientation) { + rotate = getImageOrientation(uri); + if (rotate != 0) { + Matrix matrix = new Matrix(); + matrix.setRotate(rotate); + try { + bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); + this.orientationCorrected = true; + } catch (OutOfMemoryError oom) { + this.orientationCorrected = false; + } + } + } + + // If sending base64 image back + if (destType == DATA_URL) { + this.processPicture(bitmap, this.encodingType); + } + + // If sending filename back + else if (destType == FILE_URI || destType == NATIVE_URI) { + // Did we modify the image? + if ( (this.targetHeight > 0 && this.targetWidth > 0) || + (this.correctOrientation && this.orientationCorrected) || + !mimeType.equalsIgnoreCase(getMimetypeForFormat(encodingType))) + { + try { + String modifiedPath = this.ouputModifiedBitmap(bitmap, uri); + // The modified image is cached by the app in order to get around this and not have to delete you + // application cache I'm adding the current system time to the end of the file url. + this.callbackContext.success("file://" + modifiedPath + "?" + System.currentTimeMillis()); + + } catch (Exception e) { + e.printStackTrace(); + this.failPicture("Error retrieving image."); + } + } + else { + this.callbackContext.success(fileLocation); + } + } + if (bitmap != null) { + bitmap.recycle(); + bitmap = null; + } + System.gc(); + } + } + } + + /** + * Called when the camera view exits. + * + * @param requestCode The request code originally supplied to startActivityForResult(), + * allowing you to identify who this result came from. + * @param resultCode The integer result code returned by the child activity through its setResult(). + * @param intent An Intent, which can return result data to the caller (various data can be attached to Intent "extras"). + */ + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + + // Get src and dest types from request code for a Camera Activity + int srcType = (requestCode / 16) - 1; + int destType = (requestCode % 16) - 1; + + // If Camera Crop + if (requestCode >= CROP_CAMERA) { + if (resultCode == Activity.RESULT_OK) { + + // Because of the inability to pass through multiple intents, this hack will allow us + // to pass arcane codes back. + destType = requestCode - CROP_CAMERA; + try { + processResultFromCamera(destType, intent); + } catch (IOException e) { + e.printStackTrace(); + LOG.e(LOG_TAG, "Unable to write to file"); + } + + }// If cancelled + else if (resultCode == Activity.RESULT_CANCELED) { + this.failPicture("Camera cancelled."); + } + + // If something else + else { + this.failPicture("Did not complete!"); + } + } + // If CAMERA + else if (srcType == CAMERA) { + // If image available + if (resultCode == Activity.RESULT_OK) { + try { + if(this.allowEdit) + { + Uri tmpFile = Uri.fromFile(createCaptureFile(this.encodingType)); + performCrop(tmpFile, destType, intent); + } + else { + this.processResultFromCamera(destType, intent); + } + } catch (IOException e) { + e.printStackTrace(); + this.failPicture("Error capturing image."); + } + } + + // If cancelled + else if (resultCode == Activity.RESULT_CANCELED) { + this.failPicture("Camera cancelled."); + } + + // If something else + else { + this.failPicture("Did not complete!"); + } + } + // If retrieving photo from library + else if ((srcType == PHOTOLIBRARY) || (srcType == SAVEDPHOTOALBUM)) { + if (resultCode == Activity.RESULT_OK && intent != null) { + final Intent i = intent; + final int finalDestType = destType; + cordova.getThreadPool().execute(new Runnable() { + public void run() { + processResultFromGallery(finalDestType, i); + } + }); + } + else if (resultCode == Activity.RESULT_CANCELED) { + this.failPicture("Selection cancelled."); + } + else { + this.failPicture("Selection did not complete!"); + } + } + } + + private int getImageOrientation(Uri uri) { + int rotate = 0; + String[] cols = { MediaStore.Images.Media.ORIENTATION }; + try { + Cursor cursor = cordova.getActivity().getContentResolver().query(uri, + cols, null, null, null); + if (cursor != null) { + cursor.moveToPosition(0); + rotate = cursor.getInt(0); + cursor.close(); + } + } catch (Exception e) { + // You can get an IllegalArgumentException if ContentProvider doesn't support querying for orientation. + } + return rotate; + } + + /** + * Figure out if the bitmap should be rotated. For instance if the picture was taken in + * portrait mode + * + * @param rotate + * @param bitmap + * @return rotated bitmap + */ + private Bitmap getRotatedBitmap(int rotate, Bitmap bitmap, ExifHelper exif) { + Matrix matrix = new Matrix(); + if (rotate == 180) { + matrix.setRotate(rotate); + } else { + matrix.setRotate(rotate, (float) bitmap.getWidth() / 2, (float) bitmap.getHeight() / 2); + } + + try + { + bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); + exif.resetOrientation(); + } + catch (OutOfMemoryError oom) + { + // You can run out of memory if the image is very large: + // http://simonmacdonald.blogspot.ca/2012/07/change-to-camera-code-in-phonegap-190.html + // If this happens, simply do not rotate the image and return it unmodified. + // If you do not catch the OutOfMemoryError, the Android app crashes. + } + + return bitmap; + } + + /** + * In the special case where the default width, height and quality are unchanged + * we just write the file out to disk saving the expensive Bitmap.compress function. + * + * @param uri + * @throws FileNotFoundException + * @throws IOException + */ + private void writeUncompressedImage(Uri src, Uri dest) throws FileNotFoundException, + IOException { + FileInputStream fis = null; + OutputStream os = null; + try { + fis = new FileInputStream(FileHelper.stripFileProtocol(src.toString())); + os = this.cordova.getActivity().getContentResolver().openOutputStream(dest); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } finally { + if (os != null) { + try { + os.close(); + } catch (IOException e) { + LOG.d(LOG_TAG,"Exception while closing output stream."); + } + } + if (fis != null) { + try { + fis.close(); + } catch (IOException e) { + LOG.d(LOG_TAG,"Exception while closing file input stream."); + } + } + } + } + + /** + * Create entry in media store for image + * + * @return uri + */ + private Uri getUriFromMediaStore() { + ContentValues values = new ContentValues(); + values.put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/jpeg"); + Uri uri; + try { + uri = this.cordova.getActivity().getContentResolver().insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + } catch (RuntimeException e) { + LOG.d(LOG_TAG, "Can't write to external media storage."); + try { + uri = this.cordova.getActivity().getContentResolver().insert(android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI, values); + } catch (RuntimeException ex) { + LOG.d(LOG_TAG, "Can't write to internal media storage."); + return null; + } + } + return uri; + } + + /** + * Return a scaled bitmap based on the target width and height + * + * @param imagePath + * @return + * @throws IOException + */ + private Bitmap getScaledBitmap(String imageUrl) throws IOException { + // If no new width or height were specified return the original bitmap + if (this.targetWidth <= 0 && this.targetHeight <= 0) { + InputStream fileStream = null; + Bitmap image = null; + try { + fileStream = FileHelper.getInputStreamFromUriString(imageUrl, cordova); + image = BitmapFactory.decodeStream(fileStream); + } finally { + if (fileStream != null) { + try { + fileStream.close(); + } catch (IOException e) { + LOG.d(LOG_TAG,"Exception while closing file input stream."); + } + } + } + return image; + } + + // figure out the original width and height of the image + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + InputStream fileStream = null; + try { + fileStream = FileHelper.getInputStreamFromUriString(imageUrl, cordova); + BitmapFactory.decodeStream(fileStream, null, options); + } finally { + if (fileStream != null) { + try { + fileStream.close(); + } catch (IOException e) { + LOG.d(LOG_TAG,"Exception while closing file input stream."); + } + } + } + + //CB-2292: WTF? Why is the width null? + if(options.outWidth == 0 || options.outHeight == 0) + { + return null; + } + + // determine the correct aspect ratio + int[] widthHeight = calculateAspectRatio(options.outWidth, options.outHeight); + + // Load in the smallest bitmap possible that is closest to the size we want + options.inJustDecodeBounds = false; + options.inSampleSize = calculateSampleSize(options.outWidth, options.outHeight, this.targetWidth, this.targetHeight); + Bitmap unscaledBitmap = null; + try { + fileStream = FileHelper.getInputStreamFromUriString(imageUrl, cordova); + unscaledBitmap = BitmapFactory.decodeStream(fileStream, null, options); + } finally { + if (fileStream != null) { + try { + fileStream.close(); + } catch (IOException e) { + LOG.d(LOG_TAG,"Exception while closing file input stream."); + } + } + } + if (unscaledBitmap == null) { + return null; + } + + return Bitmap.createScaledBitmap(unscaledBitmap, widthHeight[0], widthHeight[1], true); + } + + /** + * Maintain the aspect ratio so the resulting image does not look smooshed + * + * @param origWidth + * @param origHeight + * @return + */ + public int[] calculateAspectRatio(int origWidth, int origHeight) { + int newWidth = this.targetWidth; + int newHeight = this.targetHeight; + + // If no new width or height were specified return the original bitmap + if (newWidth <= 0 && newHeight <= 0) { + newWidth = origWidth; + newHeight = origHeight; + } + // Only the width was specified + else if (newWidth > 0 && newHeight <= 0) { + newHeight = (newWidth * origHeight) / origWidth; + } + // only the height was specified + else if (newWidth <= 0 && newHeight > 0) { + newWidth = (newHeight * origWidth) / origHeight; + } + // 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. + else { + 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; + } + } + + int[] retval = new int[2]; + retval[0] = newWidth; + retval[1] = newHeight; + return retval; + } + + /** + * Figure out what ratio we can load our image into memory at while still being bigger than + * our desired width and height + * + * @param srcWidth + * @param srcHeight + * @param dstWidth + * @param dstHeight + * @return + */ + public static int calculateSampleSize(int srcWidth, int srcHeight, int dstWidth, int dstHeight) { + final float srcAspect = (float)srcWidth / (float)srcHeight; + final float dstAspect = (float)dstWidth / (float)dstHeight; + + if (srcAspect > dstAspect) { + return srcWidth / dstWidth; + } else { + return srcHeight / dstHeight; + } + } + + /** + * Creates a cursor that can be used to determine how many images we have. + * + * @return a cursor + */ + private Cursor queryImgDB(Uri contentStore) { + return this.cordova.getActivity().getContentResolver().query( + contentStore, + new String[] { MediaStore.Images.Media._ID }, + null, + null, + null); + } + + /** + * Cleans up after picture taking. Checking for duplicates and that kind of stuff. + * @param newImage + */ + private void cleanup(int imageType, Uri oldImage, Uri newImage, Bitmap bitmap) { + if (bitmap != null) { + bitmap.recycle(); + } + + // Clean up initial camera-written image file. + (new File(FileHelper.stripFileProtocol(oldImage.toString()))).delete(); + + checkForDuplicateImage(imageType); + // Scan for the gallery to update pic refs in gallery + if (this.saveToPhotoAlbum && newImage != null) { + this.scanForGallery(newImage); + } + + System.gc(); + } + + /** + * Used to find out if we are in a situation where the Camera Intent adds to images + * to the content store. If we are using a FILE_URI and the number of images in the DB + * increases by 2 we have a duplicate, when using a DATA_URL the number is 1. + * + * @param type FILE_URI or DATA_URL + */ + private void checkForDuplicateImage(int type) { + int diff = 1; + Uri contentStore = whichContentStore(); + Cursor cursor = queryImgDB(contentStore); + int currentNumOfImages = cursor.getCount(); + + if (type == FILE_URI && this.saveToPhotoAlbum) { + diff = 2; + } + + // delete the duplicate file if the difference is 2 for file URI or 1 for Data URL + if ((currentNumOfImages - numPics) == diff) { + cursor.moveToLast(); + int id = Integer.valueOf(cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media._ID))); + if (diff == 2) { + id--; + } + Uri uri = Uri.parse(contentStore + "/" + id); + this.cordova.getActivity().getContentResolver().delete(uri, null, null); + cursor.close(); + } + } + + /** + * Determine if we are storing the images in internal or external storage + * @return Uri + */ + private Uri whichContentStore() { + if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + return android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else { + return android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI; + } + } + + /** + * Compress bitmap using jpeg, convert to Base64 encoded string, and return to JavaScript. + * + * @param bitmap + */ + public void processPicture(Bitmap bitmap, int encodingType) { + ByteArrayOutputStream jpeg_data = new ByteArrayOutputStream(); + CompressFormat compressFormat = encodingType == JPEG ? + CompressFormat.JPEG : + CompressFormat.PNG; + + try { + if (bitmap.compress(compressFormat, mQuality, jpeg_data)) { + byte[] code = jpeg_data.toByteArray(); + byte[] output = Base64.encode(code, Base64.NO_WRAP); + String js_out = new String(output); + this.callbackContext.success(js_out); + js_out = null; + output = null; + code = null; + } + } catch (Exception e) { + this.failPicture("Error compressing image."); + } + jpeg_data = null; + } + + /** + * Send error message to JavaScript. + * + * @param err + */ + public void failPicture(String err) { + this.callbackContext.error(err); + } + + private void scanForGallery(Uri newImage) { + this.scanMe = newImage; + if(this.conn != null) { + this.conn.disconnect(); + } + this.conn = new MediaScannerConnection(this.cordova.getActivity().getApplicationContext(), this); + conn.connect(); + } + + public void onMediaScannerConnected() { + try{ + this.conn.scanFile(this.scanMe.toString(), "image/*"); + } catch (java.lang.IllegalStateException e){ + LOG.e(LOG_TAG, "Can't scan file in MediaScanner after taking picture"); + } + + } + + public void onScanCompleted(String path, Uri uri) { + this.conn.disconnect(); + } + + + public void onRequestPermissionResult(int requestCode, String[] permissions, + int[] grantResults) throws JSONException + { + for(int r:grantResults) + { + if(r == PackageManager.PERMISSION_DENIED) + { + this.callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.ERROR, PERMISSION_DENIED_ERROR)); + return; + } + } + switch(requestCode) + { + case TAKE_PIC_SEC: + takePicture(this.destType, this.encodingType); + break; + case SAVE_TO_ALBUM_SEC: + this.getImage(this.srcType, this.destType, this.encodingType); + break; + } + } + + /** + * Taking or choosing a picture launches another Activity, so we need to implement the + * save/restore APIs to handle the case where the CordovaActivity is killed by the OS + * before we get the launched Activity's result. + */ + public Bundle onSaveInstanceState() { + Bundle state = new Bundle(); + state.putInt("destType", this.destType); + state.putInt("srcType", this.srcType); + state.putInt("mQuality", this.mQuality); + state.putInt("targetWidth", this.targetWidth); + state.putInt("targetHeight", this.targetHeight); + state.putInt("encodingType", this.encodingType); + state.putInt("mediaType", this.mediaType); + state.putInt("numPics", this.numPics); + state.putBoolean("allowEdit", this.allowEdit); + state.putBoolean("correctOrientation", this.correctOrientation); + state.putBoolean("saveToPhotoAlbum", this.saveToPhotoAlbum); + + if(this.croppedUri != null) { + state.putString("croppedUri", this.croppedUri.toString()); + } + + if(this.imageUri != null) { + state.putString("imageUri", this.imageUri.toString()); + } + + return state; + } + + public void onRestoreStateForActivityResult(Bundle state, CallbackContext callbackContext) { + this.destType = state.getInt("destType"); + this.srcType = state.getInt("srcType"); + this.mQuality = state.getInt("mQuality"); + this.targetWidth = state.getInt("targetWidth"); + this.targetHeight = state.getInt("targetHeight"); + this.encodingType = state.getInt("encodingType"); + this.mediaType = state.getInt("mediaType"); + this.numPics = state.getInt("numPics"); + this.allowEdit = state.getBoolean("allowEdit"); + this.correctOrientation = state.getBoolean("correctOrientation"); + this.saveToPhotoAlbum = state.getBoolean("saveToPhotoAlbum"); + + if(state.containsKey("croppedUri")) { + this.croppedUri = Uri.parse(state.getString("croppedUri")); + } + + if(state.containsKey("imageUri")) { + this.imageUri = Uri.parse(state.getString("imageUri")); + } + + this.callbackContext = callbackContext; + } +} \ No newline at end of file diff --git a/src/browser/CameraProxy.js b/src/browser/CameraProxy.js index 8f95c6f..635a9fb 100644 --- a/src/browser/CameraProxy.js +++ b/src/browser/CameraProxy.js @@ -23,11 +23,12 @@ var HIGHEST_POSSIBLE_Z_INDEX = 2147483647; function takePicture(success, error, opts) { if (opts && opts[2] === 1) { - capture(success, error); + capture(success, error, opts); } else { var input = document.createElement('input'); input.style.position = 'relative'; input.style.zIndex = HIGHEST_POSSIBLE_Z_INDEX; + input.className = 'cordova-camera-select'; input.type = 'file'; input.name = 'files[]'; @@ -48,28 +49,36 @@ function takePicture(success, error, opts) { } } -function capture(success, errorCallback) { +function capture(success, errorCallback, opts) { var localMediaStream; + var targetWidth = opts[3]; + var targetHeight = opts[4]; + + targetWidth = targetWidth == -1?320:targetWidth; + targetHeight = targetHeight == -1?240:targetHeight; var video = document.createElement('video'); var button = document.createElement('button'); var parent = document.createElement('div'); parent.style.position = 'relative'; parent.style.zIndex = HIGHEST_POSSIBLE_Z_INDEX; + parent.className = 'cordova-camera-capture'; parent.appendChild(video); parent.appendChild(button); - video.width = 320; - video.height = 240; + video.width = targetWidth; + video.height = targetHeight; button.innerHTML = 'Capture!'; button.onclick = function() { // create a canvas and capture a frame from video stream var canvas = document.createElement('canvas'); - canvas.getContext('2d').drawImage(video, 0, 0, 320, 240); + canvas.width = targetWidth; + canvas.height = targetHeight; + canvas.getContext('2d').drawImage(video, 0, 0, targetWidth, targetHeight); // convert image stored in canvas to base64 encoded image - var imageData = canvas.toDataURL('img/png'); + var imageData = canvas.toDataURL('image/png'); imageData = imageData.replace('data:image/png;base64,', ''); // stop video stream, remove video and button. diff --git a/src/ios/CDVCamera.m b/src/ios/CDVCamera.m index 42dd469..019141b 100644 --- a/src/ios/CDVCamera.m +++ b/src/ios/CDVCamera.m @@ -240,9 +240,13 @@ static NSString* toBase64(NSData* data) { - (void)repositionPopover:(CDVInvokedUrlCommand*)command { - NSDictionary* options = [command argumentAtIndex:0 withDefault:nil]; + if (([[self pickerController] pickerPopoverController] != nil) && [[[self pickerController] pickerPopoverController] isPopoverVisible]) { - [self displayPopover:options]; + [[[self pickerController] pickerPopoverController] dismissPopoverAnimated:NO]; + + NSDictionary* options = [command argumentAtIndex:0 withDefault:nil]; + [self displayPopover:options]; + } } - (NSInteger)integerValueForKey:(NSDictionary*)dict key:(NSString*)key defaultValue:(NSInteger)defaultValue @@ -520,9 +524,11 @@ static NSString* toBase64(NSData* data) { NSString* mediaType = [info objectForKey:UIImagePickerControllerMediaType]; if ([mediaType isEqualToString:(NSString*)kUTTypeImage]) { [weakSelf resultForImage:cameraPicker.pictureOptions info:info completion:^(CDVPluginResult* res) { - [weakSelf.commandDelegate sendPluginResult:res callbackId:cameraPicker.callbackId]; - weakSelf.hasPendingOperation = NO; - weakSelf.pickerController = nil; + if (![self usesGeolocation] || picker.sourceType != UIImagePickerControllerSourceTypeCamera) { + [weakSelf.commandDelegate sendPluginResult:res callbackId:cameraPicker.callbackId]; + weakSelf.hasPendingOperation = NO; + weakSelf.pickerController = nil; + } }]; } else { diff --git a/src/windows/CameraProxy.js b/src/windows/CameraProxy.js index 0862f35..eb10cd2 100644 --- a/src/windows/CameraProxy.js +++ b/src/windows/CameraProxy.js @@ -79,10 +79,14 @@ var HIGHEST_POSSIBLE_Z_INDEX = 2147483647; // Resize method function resizeImage(successCallback, errorCallback, file, targetWidth, targetHeight, encodingType) { var tempPhotoFileName = ""; + var targetContentType = ""; + if (encodingType == Camera.EncodingType.PNG) { tempPhotoFileName = "camera_cordova_temp_return.png"; + targetContentType = "image/png"; } else { tempPhotoFileName = "camera_cordova_temp_return.jpg"; + targetContentType = "image/jpeg"; } var storageFolder = getAppData().localFolder; @@ -108,7 +112,7 @@ function resizeImage(successCallback, errorCallback, file, targetWidth, targetHe canvas.getContext("2d").drawImage(this, 0, 0, imageWidth, imageHeight); - var fileContent = canvas.toDataURL(file.contentType).split(',')[1]; + var fileContent = canvas.toDataURL(targetContentType).split(',')[1]; var storageFolder = getAppData().localFolder; @@ -745,7 +749,7 @@ function takePictureFromCameraWindows(successCallback, errorCallback, args) { cameraCaptureUI.photoSettings.maxResolution = maxRes; var cameraPicture; - + // define focus handler for windows phone 10.0 var savePhotoOnFocus = function () { window.removeEventListener("focus", savePhotoOnFocus); @@ -760,7 +764,7 @@ function takePictureFromCameraWindows(successCallback, errorCallback, args) { }; // if windows phone 10, add and delete focus eventHandler to capture the focus back from cameraUI to app - if (navigator.appVersion.indexOf('Windows Phone 10.0') >= 0) { + if (navigator.appVersion.indexOf('Windows Phone 10.0') >= 0) { window.addEventListener("focus", savePhotoOnFocus); } diff --git a/tests/tests.js b/tests/tests.js index 1513f14..222a839 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -19,7 +19,7 @@ * */ -/* globals Camera, resolveLocalFileSystemURI, FileEntry, CameraPopoverOptions, FileTransfer, FileUploadOptions, LocalFileSystem, MSApp */ +/* globals Camera, resolveLocalFileSystemURL, FileEntry, CameraPopoverOptions, FileTransfer, FileUploadOptions, LocalFileSystem, MSApp */ /* jshint jasmine: true */ exports.defineAutoTests = function () { @@ -142,11 +142,11 @@ exports.defineManualTests = function (contentEl, createActionButton) { setPicture(data); // TODO: Fix resolveLocalFileSystemURI to work with native-uri. if (pictureUrl.indexOf('file:') === 0 || pictureUrl.indexOf('content:') === 0 || pictureUrl.indexOf('ms-appdata:') === 0 || pictureUrl.indexOf('assets-library:') === 0) { - resolveLocalFileSystemURI(data, function (e) { + resolveLocalFileSystemURL(data, function (e) { fileEntry = e; - logCallback('resolveLocalFileSystemURI()', true)(e.toURL()); + logCallback('resolveLocalFileSystemURL()', true)(e.toURL()); readFile(); - }, logCallback('resolveLocalFileSystemURI()', false)); + }, logCallback('resolveLocalFileSystemURL()', false)); } else if (pictureUrl.indexOf('data:image/jpeg;base64') === 0) { // do nothing } else { @@ -251,7 +251,7 @@ exports.defineManualTests = function (contentEl, createActionButton) { //cleanup //rename moved file back to original name so other tests can reference image - resolveLocalFileSystemURI(destDirEntry.nativeURL+'moved_file.png', function(fileEntry) { + resolveLocalFileSystemURL(destDirEntry.nativeURL+'moved_file.png', function(fileEntry) { fileEntry.moveTo(destDirEntry, origName, logCallback('FileEntry.moveTo', true), logCallback('FileEntry.moveTo', false)); console.log('Cleanup: successfully renamed file back to original name'); }, function () { @@ -259,7 +259,7 @@ exports.defineManualTests = function (contentEl, createActionButton) { }); //remove copied file - resolveLocalFileSystemURI(destDirEntry.nativeURL+'copied_file.png', function(fileEntry) { + resolveLocalFileSystemURL(destDirEntry.nativeURL+'copied_file.png', function(fileEntry) { fileEntry.remove(logCallback('FileEntry.remove', true), logCallback('FileEntry.remove', false)); console.log('Cleanup: successfully removed copied file'); }, function () { diff --git a/www/Camera.js b/www/Camera.js index 2f9154b..d006787 100644 --- a/www/Camera.js +++ b/www/Camera.js @@ -89,26 +89,20 @@ for (var key in Camera) { * * If `Camera.sourceType` is `Camera.PictureSourceType.PHOTOLIBRARY` or * `Camera.PictureSourceType.SAVEDPHOTOALBUM`, then a dialog displays - * that allows users to select an existing image. The - * `camera.getPicture` function returns a [`CameraPopoverHandle`]{@link module:CameraPopoverHandle} object, - * which can be used to reposition the image selection dialog, for - * example, when the device orientation changes. + * that allows users to select an existing image. * * The return value is sent to the [`cameraSuccess`]{@link module:camera.onSuccess} callback function, in * one of the following formats, depending on the specified * `cameraOptions`: * * - A `String` containing the Base64-encoded photo image. - * * - A `String` representing the image file location on local storage (default). * * You can do whatever you want with the encoded image or URI, for * example: * * - Render the image in an `` tag, as in the example below - * * - Save the data locally (`LocalStorage`, [Lawnchair](http://brianleroux.github.com/lawnchair/), etc.) - * * - Post the data to a remote server * * __NOTE__: Photo resolution on newer devices is quite good. Photos diff --git a/www/CameraConstants.js b/www/CameraConstants.js index f4b0694..9974c15 100644 --- a/www/CameraConstants.js +++ b/www/CameraConstants.js @@ -24,6 +24,13 @@ */ module.exports = { /** + * @description + * Defines the output format of `Camera.getPicture` call. + * _Note:_ On iOS passing `DestinationType.NATIVE_URI` along with + * `PictureSourceType.PHOTOLIBRARY` or `PictureSourceType.SAVEDPHOTOALBUM` will + * disable any image modifications (resize, quality change, cropping, etc.) due + * to implementation specific. + * * @enum {number} */ DestinationType:{ @@ -55,14 +62,20 @@ module.exports = { ALLMEDIA : 2 }, /** + * @description + * Defines the output format of `Camera.getPicture` call. + * _Note:_ On iOS passing `PictureSourceType.PHOTOLIBRARY` or `PictureSourceType.SAVEDPHOTOALBUM` + * along with `DestinationType.NATIVE_URI` will disable any image modifications (resize, quality + * change, cropping, etc.) due to implementation specific. + * * @enum {number} */ PictureSourceType:{ - /** Choose image from picture library (same as SAVEDPHOTOALBUM for Android) */ + /** Choose image from the device's photo library (same as SAVEDPHOTOALBUM for Android) */ PHOTOLIBRARY : 0, /** Take picture from camera */ CAMERA : 1, - /** Choose image from picture library (same as PHOTOLIBRARY for Android) */ + /** Choose image only from the device's Camera Roll album (same as PHOTOLIBRARY for Android) */ SAVEDPHOTOALBUM : 2 }, /** diff --git a/www/ios/CameraPopoverHandle.js b/www/ios/CameraPopoverHandle.js index 3990e18..8b92137 100644 --- a/www/ios/CameraPopoverHandle.js +++ b/www/ios/CameraPopoverHandle.js @@ -33,7 +33,7 @@ var exec = require('cordova/exec'); * - iOS * * @example - * var cameraPopoverHandle = navigator.camera.getPicture(onSuccess, onFail, + * navigator.camera.getPicture(onSuccess, onFail, * { * destinationType: Camera.DestinationType.FILE_URI, * sourceType: Camera.PictureSourceType.PHOTOLIBRARY, @@ -42,13 +42,19 @@ var exec = require('cordova/exec'); * * // Reposition the popover if the orientation changes. * window.onorientationchange = function() { + * var cameraPopoverHandle = new CameraPopoverHandle(); * var cameraPopoverOptions = new CameraPopoverOptions(0, 0, 100, 100, Camera.PopoverArrowDirection.ARROW_ANY); * cameraPopoverHandle.setPosition(cameraPopoverOptions); * } * @module CameraPopoverHandle */ var CameraPopoverHandle = function() { - /** Set the position of the popover. + /** + * Can be used to reposition the image selection dialog, + * for example, when the device orientation changes. + * @memberof CameraPopoverHandle + * @instance + * @method setPosition * @param {module:CameraPopoverOptions} popoverOptions */ this.setPosition = function(popoverOptions) {