Skip to content

Commit 7dc2f87

Browse files
XavierMallatxaviermallat
andauthored
Fix(android): save media capture to File Provider (#302)
* Fix(android): save media capture to File Provider - Save image/audio/video to FileProvider instead of MediaStore external storage * Chore(android): save media to plugin's cache folder --------- Co-authored-by: xaviermallat <[email protected]>
1 parent 5a3439a commit 7dc2f87

File tree

4 files changed

+130
-72
lines changed

4 files changed

+130
-72
lines changed

plugin.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ xmlns:android="http://schemas.android.com/apk/res/android"
7676
</feature>
7777
</config-file>
7878

79+
<config-file target="AndroidManifest.xml" parent="application">
80+
<provider
81+
android:name="org.apache.cordova.mediacapture.FileProvider"
82+
android:authorities="${applicationId}.cordova.plugin.mediacapture.provider"
83+
android:exported="false"
84+
android:grantUriPermissions="true" >
85+
<meta-data
86+
android:name="android.support.FILE_PROVIDER_PATHS"
87+
android:resource="@xml/mediacapture_provider_paths"/>
88+
</provider>
89+
</config-file>
90+
7991
<config-file target="AndroidManifest.xml" parent="/*">
8092
<uses-permission android:name="android.permission.RECORD_AUDIO" />
8193
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -85,6 +97,8 @@ xmlns:android="http://schemas.android.com/apk/res/android"
8597
<source-file src="src/android/Capture.java" target-dir="src/org/apache/cordova/mediacapture" />
8698
<source-file src="src/android/FileHelper.java" target-dir="src/org/apache/cordova/mediacapture" />
8799
<source-file src="src/android/PendingRequests.java" target-dir="src/org/apache/cordova/mediacapture" />
100+
<source-file src="src/android/FileProvider.java" target-dir="src/org/apache/cordova/mediacapture" />
101+
<source-file src="src/android/res/xml/mediacapture_provider_paths.xml" target-dir="res/xml" />
88102

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

src/android/Capture.java

Lines changed: 75 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ Licensed to the Apache Software Foundation (ASF) under one
2323
import java.lang.reflect.Field;
2424
import java.lang.reflect.InvocationTargetException;
2525
import java.lang.reflect.Method;
26+
import java.text.SimpleDateFormat;
2627
import java.util.ArrayList;
2728
import java.util.Arrays;
2829
import java.util.List;
30+
import java.util.Date;
2931

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

9597
private int numPics; // Number of pictures before capture activity
96-
private Uri imageUri;
98+
private String audioAbsolutePath;
99+
private String imageAbsolutePath;
100+
private String videoAbsolutePath;
97101

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

106104
@Override
107105
protected void pluginInitialize() {
@@ -132,6 +130,8 @@ protected void pluginInitialize() {
132130

133131
@Override
134132
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
133+
this.applicationId = cordova.getContext().getPackageName();
134+
135135
if (action.equals("getFormatData")) {
136136
JSONObject obj = getFormatData(args.getString(0), args.getString(1));
137137
callbackContext.success(obj);
@@ -142,14 +142,11 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo
142142

143143
if (action.equals("captureAudio")) {
144144
this.captureAudio(pendingRequests.createRequest(CAPTURE_AUDIO, options, callbackContext));
145-
}
146-
else if (action.equals("captureImage")) {
145+
} else if (action.equals("captureImage")) {
147146
this.captureImage(pendingRequests.createRequest(CAPTURE_IMAGE, options, callbackContext));
148-
}
149-
else if (action.equals("captureVideo")) {
147+
} else if (action.equals("captureVideo")) {
150148
this.captureVideo(pendingRequests.createRequest(CAPTURE_VIDEO, options, callbackContext));
151-
}
152-
else {
149+
} else {
153150
return false;
154151
}
155152

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

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

183180
if (mimeType.equals(IMAGE_JPEG) || filePath.endsWith(".jpg")) {
184181
obj = getImageData(fileUrl, obj);
185-
}
186-
else if (Arrays.asList(AUDIO_TYPES).contains(mimeType)) {
182+
} else if (Arrays.asList(AUDIO_TYPES).contains(mimeType)) {
187183
obj = getAudioVideoData(filePath, obj, false);
188-
}
189-
else if (mimeType.equals(VIDEO_3GPP) || mimeType.equals(VIDEO_MP4)) {
184+
} else if (mimeType.equals(VIDEO_3GPP) || mimeType.equals(VIDEO_MP4)) {
190185
obj = getAudioVideoData(filePath, obj, true);
191186
}
192187
return obj;
@@ -242,7 +237,7 @@ private boolean isMissingPermissions(Request req, List<String> permissions) {
242237
}
243238
}
244239

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

260+
private String getTempDirectoryPath() {
261+
File cache = new File(cordova.getActivity().getCacheDir(), "org.apache.cordova.mediacapture");
262+
263+
// Create the cache directory if it doesn't exist
264+
cache.mkdirs();
265+
return cache.getAbsolutePath();
266+
}
267+
265268
/**
266269
* Sets up an intent to capture audio. Result handled by onActivityResult()
267270
*/
@@ -270,6 +273,16 @@ private void captureAudio(Request req) {
270273

271274
try {
272275
Intent intent = new Intent(android.provider.MediaStore.Audio.Media.RECORD_SOUND_ACTION);
276+
String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
277+
String fileName = "cdv_media_capture_audio_" + timeStamp + ".m4a";
278+
File audio = new File(getTempDirectoryPath(), fileName);
279+
Uri audioUri = FileProvider.getUriForFile(this.cordova.getActivity(),
280+
this.applicationId + ".cordova.plugin.mediacapture.provider",
281+
audio);
282+
this.audioAbsolutePath = audio.getAbsolutePath();
283+
intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, audioUri);
284+
LOG.d(LOG_TAG, "Recording an audio and saving to: " + this.audioAbsolutePath);
285+
273286
this.cordova.startActivityForResult((CordovaPlugin) this, intent, req.requestCode);
274287
} catch (ActivityNotFoundException ex) {
275288
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NOT_SUPPORTED, "No Activity found to handle Audio Capture."));
@@ -287,11 +300,16 @@ private void captureImage(Request req) {
287300

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

290-
ContentResolver contentResolver = this.cordova.getActivity().getContentResolver();
291-
ContentValues cv = new ContentValues();
292-
cv.put(MediaStore.Images.Media.MIME_TYPE, IMAGE_JPEG);
293-
imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv);
294-
LOG.d(LOG_TAG, "Taking a picture and saving to: " + imageUri.toString());
303+
String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
304+
String fileName = "cdv_media_capture_image_" + timeStamp + ".jpg";
305+
File image = new File(getTempDirectoryPath(), fileName);
306+
307+
Uri imageUri = FileProvider.getUriForFile(this.cordova.getActivity(),
308+
this.applicationId + ".cordova.plugin.mediacapture.provider",
309+
image);
310+
this.imageAbsolutePath = image.getAbsolutePath();
311+
intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri);
312+
LOG.d(LOG_TAG, "Taking a picture and saving to: " + this.imageAbsolutePath);
295313

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

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

307325
Intent intent = new Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE);
326+
String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
327+
String fileName = "cdv_media_capture_video_" + timeStamp + ".mp4";
328+
File movie = new File(getTempDirectoryPath(), fileName);
329+
330+
Uri videoUri = FileProvider.getUriForFile(this.cordova.getActivity(),
331+
this.applicationId + ".cordova.plugin.mediacapture.provider",
332+
movie);
333+
this.videoAbsolutePath = movie.getAbsolutePath();
334+
intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, videoUri);
335+
LOG.d(LOG_TAG, "Recording a video and saving to: " + this.videoAbsolutePath);
308336

309337
if(Build.VERSION.SDK_INT > 7){
310338
intent.putExtra("android.intent.extra.durationLimit", req.duration);
@@ -332,13 +360,13 @@ public void onActivityResult(int requestCode, int resultCode, final Intent inten
332360
public void run() {
333361
switch(req.action) {
334362
case CAPTURE_AUDIO:
335-
onAudioActivityResult(req, intent);
363+
onAudioActivityResult(req);
336364
break;
337365
case CAPTURE_IMAGE:
338366
onImageActivityResult(req);
339367
break;
340368
case CAPTURE_VIDEO:
341-
onVideoActivityResult(req, intent);
369+
onVideoActivityResult(req);
342370
break;
343371
}
344372
}
@@ -371,18 +399,11 @@ else if (resultCode == Activity.RESULT_CANCELED) {
371399
}
372400

373401

374-
public void onAudioActivityResult(Request req, Intent intent) {
375-
// Get the uri of the audio clip
376-
Uri data = intent.getData();
377-
if (data == null) {
378-
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NO_MEDIA_FILES, "Error: data is null"));
379-
return;
380-
}
381-
382-
// Create a file object from the uri
383-
JSONObject mediaFile = createMediaFile(data);
402+
public void onAudioActivityResult(Request req) {
403+
// create a file object from the audio absolute path
404+
JSONObject mediaFile = createMediaFileWithAbsolutePath(this.audioAbsolutePath);
384405
if (mediaFile == null) {
385-
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + data));
406+
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + this.audioAbsolutePath));
386407
return;
387408
}
388409

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

400421
public void onImageActivityResult(Request req) {
401-
// Get the uri of the image
402-
Uri data = imageUri;
403-
if (data == null) {
404-
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NO_MEDIA_FILES, "Error: data is null"));
405-
return;
406-
}
407-
408-
// Create a file object from the uri
409-
JSONObject mediaFile = createMediaFile(data);
422+
// create a file object from the image absolute path
423+
JSONObject mediaFile = createMediaFileWithAbsolutePath(this.imageAbsolutePath);
410424
if (mediaFile == null) {
411-
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + data));
425+
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + this.imageAbsolutePath));
412426
return;
413427
}
414428

@@ -425,18 +439,11 @@ public void onImageActivityResult(Request req) {
425439
}
426440
}
427441

428-
public void onVideoActivityResult(Request req, Intent intent) {
429-
// Get the uri of the video clip
430-
Uri data = intent.getData();
431-
if (data == null) {
432-
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_NO_MEDIA_FILES, "Error: data is null"));
433-
return;
434-
}
435-
436-
// Create a file object from the uri
437-
JSONObject mediaFile = createMediaFile(data);
442+
public void onVideoActivityResult(Request req) {
443+
// create a file object from the video absolute path
444+
JSONObject mediaFile = createMediaFileWithAbsolutePath(this.videoAbsolutePath);
438445
if (mediaFile == null) {
439-
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + data));
446+
pendingRequests.resolveWithFailure(req, createErrorObject(CAPTURE_INTERNAL_ERR, "Error: no mediaFile created from " + this.videoAbsolutePath));
440447
return;
441448
}
442449

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

454461
/**
455-
* Creates a JSONObject that represents a File from the Uri
462+
* Creates a JSONObject that represents a File from the absolute path
456463
*
457-
* @param data the Uri of the audio/image/video
464+
* @param path the absolute path saved in FileProvider of the audio/image/video
458465
* @return a JSONObject that represents a File
459466
* @throws IOException
460467
*/
461-
private JSONObject createMediaFile(Uri data) {
462-
File fp = webView.getResourceApi().mapUriToFile(data);
463-
if (fp == null) {
464-
return null;
465-
}
466-
468+
private JSONObject createMediaFileWithAbsolutePath(String path) {
469+
File fp = new File(path);
467470
JSONObject obj = new JSONObject();
468471

469472
Class webViewClass = webView.getClass();
470473
PluginManager pm = null;
471474
try {
472475
Method gpm = webViewClass.getMethod("getPluginManager");
473476
pm = (PluginManager) gpm.invoke(webView);
474-
} catch (NoSuchMethodException e) {
475-
} catch (IllegalAccessException e) {
476-
} catch (InvocationTargetException e) {
477+
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
478+
// Do Nothing
477479
}
478480
if (pm == null) {
479481
try {
480482
Field pmf = webViewClass.getField("pluginManager");
481483
pm = (PluginManager)pmf.get(webView);
482-
} catch (NoSuchFieldException e) {
483-
} catch (IllegalAccessException e) {
484+
} catch (NoSuchFieldException | IllegalAccessException e) {
485+
// Do Nothing
484486
}
485487
}
486488
FileUtils filePlugin = (FileUtils) pm.getPlugin("File");
@@ -497,6 +499,7 @@ private JSONObject createMediaFile(Uri data) {
497499
// are reported as video/3gpp. I'm doing this hacky check of the URI to see if it
498500
// is stored in the audio or video content store.
499501
if (fp.getAbsoluteFile().toString().endsWith(".3gp") || fp.getAbsoluteFile().toString().endsWith(".3gpp")) {
502+
Uri data = Uri.fromFile(fp);
500503
if (data.toString().contains("/audio/")) {
501504
obj.put("type", AUDIO_3GPP);
502505
} else {

src/android/FileProvider.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
Unless required by applicable law or agreed to in writing,
11+
software distributed under the License is distributed on an
12+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
KIND, either express or implied. See the License for the
14+
specific language governing permissions and limitations
15+
under the License.
16+
*/
17+
package org.apache.cordova.mediacapture;
18+
19+
public class FileProvider extends androidx.core.content.FileProvider {
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Licensed to the Apache Software Foundation (ASF) under one
4+
or more contributor license agreements. See the NOTICE file
5+
distributed with this work for additional information
6+
regarding copyright ownership. The ASF licenses this file
7+
to you under the Apache License, Version 2.0 (the
8+
"License"); you may not use this file except in compliance
9+
with the License. You may obtain a copy of the License at
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
Unless required by applicable law or agreed to in writing,
12+
software distributed under the License is distributed on an
13+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
KIND, either express or implied. See the License for the
15+
specific language governing permissions and limitations
16+
under the License.
17+
-->
18+
19+
<paths xmlns:android="http://schemas.android.com/apk/res/android">
20+
<cache-path name="cache_files" path="org.apache.cordova.mediacapture" />
21+
</paths>

0 commit comments

Comments
 (0)