Skip to content
Permalink
Browse files
CB-10554: Implementing plugin save/restore API for Android
Adds two document events that can be subscribed to on
Android to receive the results of callbacks that were
pending when the webview was destroyed

This closes #51, closes #60
  • Loading branch information
riknoll committed May 10, 2016
1 parent 2df337c commit b2e29fc88c548d5aadb7c49784e3498ae7d7213c
Showing 7 changed files with 243 additions and 16 deletions.
@@ -637,3 +637,41 @@ Supports the following `MediaFileData` properties:
- __width__: Supported: image and video files only.

- __duration__: Supported: audio and video files only.

## Android Lifecycle Quirks

When capturing audio, video, or images on the Android platform, there is a chance that the
application will get destroyed after the Cordova Webview is pushed to the background by
the native capture application. See the [Android Lifecycle Guide][android-lifecycle] for
a full description of the issue. In this case, the success and failure callbacks passed
to the capture method will not be fired and instead the results of the call will be
delivered via a document event that fires after the Cordova [resume event][resume-event].

In your app, you should subscribe to the two possible events like so:

```javascript
function onDeviceReady() {
// pendingcaptureresult is fired if the capture call is successful
document.addEventListener('pendingcaptureresult', function(mediaFiles) {
// Do something with result
});
// pendingcaptureerror is fired if the capture call is unsuccessful
document.addEventListener('pendingcaptureerror', function(error) {
// Handle error case
});
}
// Only subscribe to events after deviceready fires
document.addEventListener('deviceready', onDeviceReady);
```

It is up you to track what part of your code these results are coming from. Be sure to
save and restore your app's state as part of the [pause][pause-event] and
[resume][resume-event] events as appropriate. Please note that these events will only
fire on the Android platform and only when the Webview was destroyed during a capture
operation.

[android-lifecycle]: http://cordova.apache.org/docs/en/latest/guide/platforms/android/index.html#lifecycle-guide
[pause-event]: http://cordova.apache.org/docs/en/latest/cordova/events/events.html#pause
[resume-event]: http://cordova.apache.org/docs/en/latest/cordova/events/events.html#resume
@@ -58,6 +58,10 @@ xmlns:rim="http://www.blackberry.com/ns/widgets"
<clobbers target="MediaFile" />
</js-module>

<js-module src="www/helpers.js" name="helpers">
<runs />
</js-module>

<js-module src="www/capture.js" name="capture">
<clobbers target="navigator.device.capture" />
</js-module>
@@ -79,6 +83,10 @@ xmlns:rim="http://www.blackberry.com/ns/widgets"
<source-file src="src/android/Capture.java" target-dir="src/org/apache/cordova/mediacapture" />
<source-file src="src/android/FileHelper.java" target-dir="src/org/apache/cordova/mediacapture" />
<source-file src="src/android/PendingRequests.java" target-dir="src/org/apache/cordova/mediacapture" />

<js-module src="www/android/init.js" name="init">
<runs />
</js-module>
</platform>

<!-- amazon-fireos -->
@@ -27,6 +27,7 @@ Licensed to the Apache Software Foundation (ASF) under one
import java.lang.reflect.Method;

import android.os.Build;
import android.os.Bundle;

import org.apache.cordova.file.FileUtils;
import org.apache.cordova.file.LocalFilesystemURL;
@@ -603,4 +604,12 @@ public void onRequestPermissionResult(int requestCode, String[] permissions,
}
}
}

public Bundle onSaveInstanceState() {
return pendingRequests.toBundle();
}

public void onRestoreStateForActivityResult(Bundle state, CallbackContext callbackContext) {
pendingRequests.setLastSavedState(state, callbackContext);
}
}
@@ -19,9 +19,11 @@ Licensed to the Apache Software Foundation (ASF) under one

package org.apache.cordova.mediacapture;

import android.os.Bundle;
import android.util.SparseArray;

import org.apache.cordova.CallbackContext;
import org.apache.cordova.LOG;
import org.apache.cordova.PluginResult;
import org.json.JSONArray;
import org.json.JSONException;
@@ -31,9 +33,17 @@ Licensed to the Apache Software Foundation (ASF) under one
* Holds the pending javascript requests for the plugin
*/
public class PendingRequests {
private static final String LOG_TAG = "PendingCaptureRequests";

private static final String CURRENT_ID_KEY = "currentReqId";
private static final String REQUEST_KEY_PREFIX = "request_";

private int currentReqId = 0;
private SparseArray<Request> requests = new SparseArray<Request>();

private Bundle lastSavedState;
private CallbackContext resumeContext;

/**
* Creates a request and adds it to the array of pending requests. Each created request gets a
* unique result code for use with startActivityForResult() and requestPermission()
@@ -56,6 +66,19 @@ public synchronized Request createRequest(int action, JSONObject options, Callba
* request is not found
*/
public synchronized Request get(int requestCode) {
// Check to see if this request was saved
if (lastSavedState != null && lastSavedState.containsKey(REQUEST_KEY_PREFIX + requestCode)) {
Request r = new Request(lastSavedState.getBundle(REQUEST_KEY_PREFIX + requestCode), this.resumeContext, requestCode);
requests.put(requestCode, r);

// Only one of the saved requests will get restored, because that's all cordova-android
// supports. Having more than one is an extremely unlikely scenario anyway
this.lastSavedState = null;
this.resumeContext = null;

return r;
}

return requests.get(requestCode);
}

@@ -90,11 +113,56 @@ private synchronized int incrementCurrentReqId() {
return currentReqId ++;
}

/**
* Restore state saved by calling toBundle along with a callbackContext to be used in
* delivering the results of a pending callback
*
* @param lastSavedState The bundle received from toBundle()
* @param resumeContext The callbackContext to return results to
*/
public synchronized void setLastSavedState(Bundle lastSavedState, CallbackContext resumeContext) {
this.lastSavedState = lastSavedState;
this.resumeContext = resumeContext;
this.currentReqId = lastSavedState.getInt(CURRENT_ID_KEY);
}

/**
* Save the current pending requests to a bundle for saving when the Activity gets destroyed.
*
* @return A Bundle that can be used to restore state using setLastSavedState()
*/
public synchronized Bundle toBundle() {
Bundle bundle = new Bundle();
bundle.putInt(CURRENT_ID_KEY, currentReqId);

for (int i = 0; i < requests.size(); i++) {
Request r = requests.valueAt(i);
int requestCode = requests.keyAt(i);
bundle.putBundle(REQUEST_KEY_PREFIX + requestCode, r.toBundle());
}

if (requests.size() > 1) {
// This scenario is hopefully very unlikely because there isn't much that can be
// done about it. Should only occur if an external Activity is launched while
// there is a pending permission request and the device is on low memory
LOG.w(LOG_TAG, "More than one media capture request pending on Activity destruction. Some requests will be dropped!");
}

return bundle;
}

/**
* Holds the options and CallbackContext for a capture request made to the plugin.
*/
public class Request {

// Keys for use in saving requests to a bundle
private static final String ACTION_KEY = "action";
private static final String LIMIT_KEY = "limit";
private static final String DURATION_KEY = "duration";
private static final String QUALITY_KEY = "quality";
private static final String RESULTS_KEY = "results";

// Unique int used to identify this request in any Android Permission or Activity callbacks
public int requestCode;

@@ -128,5 +196,33 @@ private Request(int action, JSONObject options, CallbackContext callbackContext)

this.requestCode = incrementCurrentReqId();
}

private Request(Bundle bundle, CallbackContext callbackContext, int requestCode) {
this.callbackContext = callbackContext;
this.requestCode = requestCode;
this.action = bundle.getInt(ACTION_KEY);
this.limit = bundle.getLong(LIMIT_KEY);
this.duration = bundle.getInt(DURATION_KEY);
this.quality = bundle.getInt(QUALITY_KEY);

try {
this.results = new JSONArray(bundle.getString(RESULTS_KEY));
} catch(JSONException e) {
// This should never be caught
LOG.e(LOG_TAG, "Error parsing results for request from saved bundle", e);
}
}

private Bundle toBundle() {
Bundle bundle = new Bundle();

bundle.putInt(ACTION_KEY, this.action);
bundle.putLong(LIMIT_KEY, this.limit);
bundle.putInt(DURATION_KEY, this.duration);
bundle.putInt(QUALITY_KEY, this.quality);
bundle.putString(RESULTS_KEY, this.results.toString());

return bundle;
}
}
}
@@ -0,0 +1,44 @@
/*
*
* 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.
*
*/

var cordova = require('cordova'),
helpers = require('./helpers');

var SUCCESS_EVENT = "pendingcaptureresult";
var FAILURE_EVENT = "pendingcaptureerror";

var sChannel = cordova.addStickyDocumentEventHandler(SUCCESS_EVENT);
var fChannel = cordova.addStickyDocumentEventHandler(FAILURE_EVENT);

// We fire one of two events in the case where the activity gets killed while
// the user is capturing audio, image, video, etc. in a separate activity
document.addEventListener("deviceready", function() {
document.addEventListener("resume", function(event) {
if (event.pendingResult && event.pendingResult.pluginServiceName === "Capture") {
if (event.pendingResult.pluginStatus === "OK") {
var mediaFiles = helpers.wrapMediaFiles(event.pendingResult.result);
sChannel.fire(mediaFiles);
} else {
fChannel.fire(event.pendingResult.result);
}
}
});
});
@@ -20,7 +20,7 @@
*/

var exec = require('cordova/exec'),
MediaFile = require('./MediaFile');
helpers = require('./helpers');

/**
* Launches a capture of different types.
@@ -32,24 +32,12 @@ var exec = require('cordova/exec'),
*/
function _capture(type, successCallback, errorCallback, options) {
var win = function(pluginResult) {
var mediaFiles = [];
var i;
for (i = 0; i < pluginResult.length; i++) {
var mediaFile = new MediaFile();
mediaFile.name = pluginResult[i].name;

// Backwards compatibility
mediaFile.localURL = pluginResult[i].localURL || pluginResult[i].fullPath;
mediaFile.fullPath = pluginResult[i].fullPath;
mediaFile.type = pluginResult[i].type;
mediaFile.lastModifiedDate = pluginResult[i].lastModifiedDate;
mediaFile.size = pluginResult[i].size;
mediaFiles.push(mediaFile);
}
successCallback(mediaFiles);
successCallback(helpers.wrapMediaFiles(pluginResult));
};
exec(win, errorCallback, "Capture", type, [options]);
}


/**
* The Capture interface exposes an interface to the camera and microphone of the hosting device.
*/
@@ -0,0 +1,44 @@
/*
*
* 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.
*
*/

var MediaFile = require('./MediaFile');

function wrapMediaFiles(pluginResult) {
var mediaFiles = [];
var i;
for (i = 0; i < pluginResult.length; i++) {
var mediaFile = new MediaFile();
mediaFile.name = pluginResult[i].name;

// Backwards compatibility
mediaFile.localURL = pluginResult[i].localURL || pluginResult[i].fullPath;
mediaFile.fullPath = pluginResult[i].fullPath;
mediaFile.type = pluginResult[i].type;
mediaFile.lastModifiedDate = pluginResult[i].lastModifiedDate;
mediaFile.size = pluginResult[i].size;
mediaFiles.push(mediaFile);
}
return mediaFiles;
}

module.exports = {
wrapMediaFiles: wrapMediaFiles
};

0 comments on commit b2e29fc

Please sign in to comment.