Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix(android): save media capture to File Provider #302

Merged
merged 2 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ xmlns:android="http://schemas.android.com/apk/res/android"
</feature>
</config-file>

<config-file target="AndroidManifest.xml" parent="application">
<provider
android:name="org.apache.cordova.mediacapture.FileProvider"
android:authorities="${applicationId}.cordova.plugin.mediacapture.provider"
android:exported="false"
android:grantUriPermissions="true" >
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/mediacapture_provider_paths"/>
</provider>
</config-file>

<config-file target="AndroidManifest.xml" parent="/*">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
Expand All @@ -85,6 +97,8 @@ xmlns:android="http://schemas.android.com/apk/res/android"
<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" />
<source-file src="src/android/FileProvider.java" target-dir="src/org/apache/cordova/mediacapture" />
<source-file src="src/android/res/xml/mediacapture_provider_paths.xml" target-dir="res/xml" />

<js-module src="www/android/init.js" name="init">
<runs />
Expand Down
147 changes: 75 additions & 72 deletions src/android/Capture.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ Licensed to the Apache Software Foundation (ASF) under one
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Date;

import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
Expand Down Expand Up @@ -93,15 +95,11 @@ public class Capture extends CordovaPlugin {
private final PendingRequests pendingRequests = new PendingRequests();

private int numPics; // Number of pictures before capture activity
private Uri imageUri;
private String audioAbsolutePath;
private String imageAbsolutePath;
private String videoAbsolutePath;

// public void setContext(Context mCtx)
// {
// if (CordovaInterface.class.isInstance(mCtx))
// cordova = (CordovaInterface) mCtx;
// else
// LOG.d(LOG_TAG, "ERROR: You must use the CordovaInterface for this to work correctly. Please implement it in your activity");
// }
private String applicationId;

@Override
protected void pluginInitialize() {
Expand Down Expand Up @@ -132,6 +130,8 @@ protected void pluginInitialize() {

@Override
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
this.applicationId = cordova.getContext().getPackageName();

if (action.equals("getFormatData")) {
JSONObject obj = getFormatData(args.getString(0), args.getString(1));
callbackContext.success(obj);
Expand All @@ -142,14 +142,11 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo

if (action.equals("captureAudio")) {
this.captureAudio(pendingRequests.createRequest(CAPTURE_AUDIO, options, callbackContext));
}
else if (action.equals("captureImage")) {
} else if (action.equals("captureImage")) {
this.captureImage(pendingRequests.createRequest(CAPTURE_IMAGE, options, callbackContext));
}
else if (action.equals("captureVideo")) {
} else if (action.equals("captureVideo")) {
this.captureVideo(pendingRequests.createRequest(CAPTURE_VIDEO, options, callbackContext));
}
else {
} else {
return false;
}

Expand All @@ -175,18 +172,16 @@ private JSONObject getFormatData(String filePath, String mimeType) throws JSONEx

// If the mimeType isn't set the rest will fail
// so let's see if we can determine it.
if (mimeType == null || mimeType.equals("") || "null".equals(mimeType)) {
if (mimeType == null || mimeType.isEmpty() || "null".equals(mimeType)) {
mimeType = FileHelper.getMimeType(fileUrl, cordova);
}
LOG.d(LOG_TAG, "Mime type = " + mimeType);

if (mimeType.equals(IMAGE_JPEG) || filePath.endsWith(".jpg")) {
obj = getImageData(fileUrl, obj);
}
else if (Arrays.asList(AUDIO_TYPES).contains(mimeType)) {
} else if (Arrays.asList(AUDIO_TYPES).contains(mimeType)) {
obj = getAudioVideoData(filePath, obj, false);
}
else if (mimeType.equals(VIDEO_3GPP) || mimeType.equals(VIDEO_MP4)) {
} else if (mimeType.equals(VIDEO_3GPP) || mimeType.equals(VIDEO_MP4)) {
obj = getAudioVideoData(filePath, obj, true);
}
return obj;
Expand Down Expand Up @@ -242,7 +237,7 @@ private boolean isMissingPermissions(Request req, List<String> permissions) {
}
}

boolean isMissingPermissions = missingPermissions.size() > 0;
boolean isMissingPermissions = !missingPermissions.isEmpty();
if (isMissingPermissions) {
String[] missing = missingPermissions.toArray(new String[missingPermissions.size()]);
PermissionHelper.requestPermissions(this, req.requestCode, missing);
Expand All @@ -262,6 +257,14 @@ private boolean isMissingCameraPermissions(Request req) {
return isMissingPermissions(req, cameraPermissions);
}

private String getTempDirectoryPath() {
File cache = new File(cordova.getActivity().getCacheDir(), "org.apache.cordova.mediacapture");

// Create the cache directory if it doesn't exist
cache.mkdirs();
return cache.getAbsolutePath();
}

/**
* Sets up an intent to capture audio. Result handled by onActivityResult()
*/
Expand All @@ -270,6 +273,16 @@ private void captureAudio(Request req) {

try {
Intent intent = new Intent(android.provider.MediaStore.Audio.Media.RECORD_SOUND_ACTION);
String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
String fileName = "cdv_media_capture_audio_" + timeStamp + ".m4a";
File audio = new File(getTempDirectoryPath(), fileName);
Uri audioUri = FileProvider.getUriForFile(this.cordova.getActivity(),
this.applicationId + ".cordova.plugin.mediacapture.provider",
audio);
this.audioAbsolutePath = audio.getAbsolutePath();
intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, audioUri);
LOG.d(LOG_TAG, "Recording an audio and saving to: " + this.audioAbsolutePath);

this.cordova.startActivityForResult((CordovaPlugin) this, intent, req.requestCode);
} catch (ActivityNotFoundException ex) {
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NOT_SUPPORTED, "No Activity found to handle Audio Capture."));
Expand All @@ -287,11 +300,16 @@ private void captureImage(Request req) {

Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE);

ContentResolver contentResolver = this.cordova.getActivity().getContentResolver();
ContentValues cv = new ContentValues();
cv.put(MediaStore.Images.Media.MIME_TYPE, IMAGE_JPEG);
imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv);
LOG.d(LOG_TAG, "Taking a picture and saving to: " + imageUri.toString());
String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
String fileName = "cdv_media_capture_image_" + timeStamp + ".jpg";
File image = new File(getTempDirectoryPath(), fileName);

Uri imageUri = FileProvider.getUriForFile(this.cordova.getActivity(),
this.applicationId + ".cordova.plugin.mediacapture.provider",
image);
this.imageAbsolutePath = image.getAbsolutePath();
intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri);
LOG.d(LOG_TAG, "Taking a picture and saving to: " + this.imageAbsolutePath);

intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri);

Expand All @@ -305,6 +323,16 @@ private void captureVideo(Request req) {
if (isMissingCameraPermissions(req)) return;

Intent intent = new Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE);
String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
String fileName = "cdv_media_capture_video_" + timeStamp + ".mp4";
File movie = new File(getTempDirectoryPath(), fileName);

Uri videoUri = FileProvider.getUriForFile(this.cordova.getActivity(),
this.applicationId + ".cordova.plugin.mediacapture.provider",
movie);
this.videoAbsolutePath = movie.getAbsolutePath();
intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, videoUri);
LOG.d(LOG_TAG, "Recording a video and saving to: " + this.videoAbsolutePath);

if(Build.VERSION.SDK_INT > 7){
intent.putExtra("android.intent.extra.durationLimit", req.duration);
Expand Down Expand Up @@ -332,13 +360,13 @@ public void onActivityResult(int requestCode, int resultCode, final Intent inten
public void run() {
switch(req.action) {
case CAPTURE_AUDIO:
onAudioActivityResult(req, intent);
onAudioActivityResult(req);
break;
case CAPTURE_IMAGE:
onImageActivityResult(req);
break;
case CAPTURE_VIDEO:
onVideoActivityResult(req, intent);
onVideoActivityResult(req);
break;
}
}
Expand Down Expand Up @@ -371,18 +399,11 @@ else if (resultCode == Activity.RESULT_CANCELED) {
}


public void onAudioActivityResult(Request req, Intent intent) {
// Get the uri of the audio clip
Uri data = intent.getData();
if (data == null) {
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NO_MEDIA_FILES, "Error: data is null"));
return;
}

// Create a file object from the uri
JSONObject mediaFile = createMediaFile(data);
public void onAudioActivityResult(Request req) {
// create a file object from the audio absolute path
JSONObject mediaFile = createMediaFileWithAbsolutePath(this.audioAbsolutePath);
if (mediaFile == null) {
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + data));
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + this.audioAbsolutePath));
return;
}

Expand All @@ -398,17 +419,10 @@ public void onAudioActivityResult(Request req, Intent intent) {
}

public void onImageActivityResult(Request req) {
// Get the uri of the image
Uri data = imageUri;
if (data == null) {
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NO_MEDIA_FILES, "Error: data is null"));
return;
}

// Create a file object from the uri
JSONObject mediaFile = createMediaFile(data);
// create a file object from the image absolute path
JSONObject mediaFile = createMediaFileWithAbsolutePath(this.imageAbsolutePath);
if (mediaFile == null) {
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + data));
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + this.imageAbsolutePath));
return;
}

Expand All @@ -425,18 +439,11 @@ public void onImageActivityResult(Request req) {
}
}

public void onVideoActivityResult(Request req, Intent intent) {
// Get the uri of the video clip
Uri data = intent.getData();
if (data == null) {
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NO_MEDIA_FILES, "Error: data is null"));
return;
}

// Create a file object from the uri
JSONObject mediaFile = createMediaFile(data);
public void onVideoActivityResult(Request req) {
// create a file object from the video absolute path
JSONObject mediaFile = createMediaFileWithAbsolutePath(this.videoAbsolutePath);
if (mediaFile == null) {
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + data));
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + this.videoAbsolutePath));
return;
}

Expand All @@ -452,35 +459,30 @@ public void onVideoActivityResult(Request req, Intent intent) {
}

/**
* Creates a JSONObject that represents a File from the Uri
* Creates a JSONObject that represents a File from the absolute path
*
* @param data the Uri of the audio/image/video
* @param path the absolute path saved in FileProvider of the audio/image/video
* @return a JSONObject that represents a File
* @throws IOException
*/
private JSONObject createMediaFile(Uri data) {
File fp = webView.getResourceApi().mapUriToFile(data);
if (fp == null) {
return null;
}

private JSONObject createMediaFileWithAbsolutePath(String path) {
File fp = new File(path);
JSONObject obj = new JSONObject();

Class webViewClass = webView.getClass();
PluginManager pm = null;
try {
Method gpm = webViewClass.getMethod("getPluginManager");
pm = (PluginManager) gpm.invoke(webView);
} catch (NoSuchMethodException e) {
} catch (IllegalAccessException e) {
} catch (InvocationTargetException e) {
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
// Do Nothing
}
if (pm == null) {
try {
Field pmf = webViewClass.getField("pluginManager");
pm = (PluginManager)pmf.get(webView);
} catch (NoSuchFieldException e) {
} catch (IllegalAccessException e) {
} catch (NoSuchFieldException | IllegalAccessException e) {
// Do Nothing
}
}
FileUtils filePlugin = (FileUtils) pm.getPlugin("File");
Expand All @@ -497,6 +499,7 @@ private JSONObject createMediaFile(Uri data) {
// are reported as video/3gpp. I'm doing this hacky check of the URI to see if it
// is stored in the audio or video content store.
if (fp.getAbsoluteFile().toString().endsWith(".3gp") || fp.getAbsoluteFile().toString().endsWith(".3gpp")) {
Uri data = Uri.fromFile(fp);
if (data.toString().contains("/audio/")) {
obj.put("type", AUDIO_3GPP);
} else {
Expand Down
20 changes: 20 additions & 0 deletions src/android/FileProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
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.mediacapture;

public class FileProvider extends androidx.core.content.FileProvider {
}
21 changes: 21 additions & 0 deletions src/android/res/xml/mediacapture_provider_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->

<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache_files" path="org.apache.cordova.mediacapture" />
</paths>
Loading