diff --git a/apps/demo/src/plugin-demos/camera.xml b/apps/demo/src/plugin-demos/camera.xml
index 3915ccd1..e48d196a 100644
--- a/apps/demo/src/plugin-demos/camera.xml
+++ b/apps/demo/src/plugin-demos/camera.xml
@@ -1,6 +1,6 @@
-
+
@@ -24,6 +24,7 @@
>
+
\ No newline at end of file
diff --git a/packages/camera/index.android.ts b/packages/camera/index.android.ts
index 8d17722c..1188c024 100644
--- a/packages/camera/index.android.ts
+++ b/packages/camera/index.android.ts
@@ -1,6 +1,7 @@
-import { Utils, Application, Device, Trace, ImageAsset } from '@nativescript/core';
+import { Utils, Application, Device, Trace, ImageAsset, AndroidActivityResultEventData, AndroidApplication } from '@nativescript/core';
+
import * as permissions from 'nativescript-permissions';
-import { CameraOptions } from '.';
+import { CameraOptions, CameraRecordOptions } from '.';
let REQUEST_IMAGE_CAPTURE = 3453;
declare let global: any;
@@ -69,7 +70,7 @@ export let takePicture = function (options?: CameraOptions): Promise {
const sdkVersionInt = parseInt(Device.sdkVersion, 10);
let tempPictureUri;
if (sdkVersionInt >= 21) {
- tempPictureUri = FileProviderPackageName.FileProvider.getUriForFile(Application.android.context, Application.android.nativeApp.getPackageName() + '.provider', nativeFile);
+ tempPictureUri = FileProviderPackageName.FileProvider.getUriForFile(Utils.android.getApplicationContext(), Application.android.nativeApp.getPackageName() + '.provider', nativeFile);
} else {
tempPictureUri = android.net.Uri.fromFile(nativeFile);
}
@@ -164,6 +165,144 @@ export let takePicture = function (options?: CameraOptions): Promise {
});
};
+const RESULT_CANCELED = 0;
+const RESULT_OK = -1;
+const REQUEST_VIDEO_CAPTURE = 999;
+
+export let recordVideo = function (options: CameraRecordOptions): Promise<{ file: string }> {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const pkgName = Utils.ad.getApplication().getPackageName();
+ const state = android.os.Environment.getExternalStorageState();
+ if (state !== android.os.Environment.MEDIA_MOUNTED) {
+ return reject(new Error('Storage not mounted'));
+ }
+ const sdkVersionInt = parseInt(Device.sdkVersion, 10);
+
+ const intent = new android.content.Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE);
+
+ intent.putExtra('android.intent.extra.videoQuality', options?.hd ? 1 : 0);
+
+ if (sdkVersionInt >= 21) {
+ intent.putExtra('android.intent.extras.CAMERA_FACING', options?.cameraFacing === 'front' ? android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT : android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK);
+ intent.putExtra('android.intent.extra.USE_FRONT_CAMERA', options?.cameraFacing === 'front');
+ intent.putExtra('android.intent.extras.LENS_FACING_FRONT', options?.cameraFacing === 'front' ? 1 : 0);
+ } else {
+ intent.putExtra('android.intent.extras.CAMERA_FACING', options?.cameraFacing === 'front' ? android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT : android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK);
+ }
+
+ if (typeof options?.size === 'number' && options?.size > 0) {
+ intent.putExtra(android.provider.MediaStore.EXTRA_SIZE_LIMIT, options.size * 1024 * 1024);
+ }
+
+ let saveToGallery = options?.saveToGallery ?? false;
+
+ const dateStamp = createDateTimeStamp();
+
+ const fileName = `NSVID_${dateStamp}.mp4`;
+ let videoPath: string;
+ let nativeFile: java.io.File;
+ let tempPictureUri;
+
+ if (saveToGallery) {
+ await permissions.requestPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+ if (!permissions.hasPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ saveToGallery = false;
+ }
+ const createTmpFile = () => {
+ videoPath = Utils.android.getApplicationContext().getExternalFilesDir(null).getAbsolutePath() + '/' + fileName;
+ nativeFile = new java.io.File(videoPath);
+ };
+ if (saveToGallery) {
+ const externalDir = Utils.android.getApplicationContext().getExternalFilesDir(android.os.Environment.DIRECTORY_DCIM);
+ if (externalDir == null) {
+ createTmpFile();
+ } else {
+ if (!externalDir.exists()) {
+ externalDir.mkdirs();
+ }
+ const cameraDir = new java.io.File(externalDir, 'Camera');
+
+ if (!cameraDir.exists()) {
+ cameraDir.mkdirs();
+ }
+
+ nativeFile = new java.io.File(cameraDir, fileName);
+ videoPath = nativeFile.getAbsolutePath();
+ }
+ } else {
+ createTmpFile();
+ }
+
+ if (sdkVersionInt >= 21) {
+ tempPictureUri = androidx.core.content.FileProvider.getUriForFile(Utils.android.getApplicationContext(), `${pkgName}.provider`, nativeFile);
+ } else {
+ tempPictureUri = android.net.Uri.fromFile(nativeFile);
+ }
+
+ intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, tempPictureUri);
+
+ intent.setFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION | android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+
+ if (typeof options?.duration === 'number' && options?.duration > 0) {
+ intent.putExtra(android.provider.MediaStore.EXTRA_DURATION_LIMIT, options.duration);
+ }
+
+ const callBack = (args: AndroidActivityResultEventData) => {
+ if (args.requestCode === REQUEST_VIDEO_CAPTURE && args.resultCode === RESULT_OK) {
+ if (saveToGallery) {
+ try {
+ const currentTimeMillis = java.lang.Integer.valueOf(java.lang.System.currentTimeMillis());
+ const values = new android.content.ContentValues();
+
+ values.put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, fileName);
+ values.put(android.provider.MediaStore.MediaColumns.DATE_ADDED, currentTimeMillis);
+ values.put(android.provider.MediaStore.MediaColumns.DATE_MODIFIED, currentTimeMillis);
+ values.put(android.provider.MediaStore.MediaColumns.MIME_TYPE, 'video/*');
+
+ if (sdkVersionInt >= 29) {
+ values.put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, android.os.Environment.DIRECTORY_DCIM);
+ values.put(android.provider.MediaStore.MediaColumns.IS_PENDING, java.lang.Integer.valueOf(1));
+ values.put(android.provider.MediaStore.MediaColumns.DATE_TAKEN, currentTimeMillis);
+ }
+
+ const uri = Utils.android.getApplicationContext().getContentResolver().insert(android.provider.MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
+
+ const fos: java.io.FileOutputStream = Utils.android.getApplicationContext().getContentResolver().openOutputStream(uri);
+
+ const fis = new java.io.FileInputStream(nativeFile);
+
+ (org as any).nativescript.plugins.camera.Utils.copy(fis, fos);
+ if (sdkVersionInt >= 29) {
+ values.clear();
+ // @ts-ignore
+ values.put(android.provider.MediaStore.Video.Media.IS_PENDING, java.lang.Integer.valueOf(0));
+ Utils.android.getApplicationContext().getContentResolver().update(uri, values, null, null);
+ }
+ resolve({ file: videoPath });
+ } catch (e) {
+ reject(e);
+ }
+ } else {
+ resolve({ file: videoPath });
+ }
+ } else if (args.resultCode === RESULT_CANCELED) {
+ // User cancelled the image capture
+ reject(new Error('cancelled'));
+ }
+
+ Application.android.off(AndroidApplication.activityResultEvent, callBack);
+ };
+ Application.android.on(AndroidApplication.activityResultEvent, callBack);
+
+ Application.android.foregroundActivity.startActivityForResult(intent, REQUEST_VIDEO_CAPTURE);
+ } catch (e) {
+ reject(e);
+ }
+ });
+};
+
export let isAvailable = function () {
return Utils.android.getApplicationContext().getPackageManager().hasSystemFeature(android.content.pm.PackageManager.FEATURE_CAMERA);
};
@@ -180,6 +319,10 @@ export let requestCameraPermissions = function () {
return permissions.requestPermissions([android.Manifest.permission.CAMERA]);
};
+export let requestRecordPermissions = function () {
+ return permissions.requestPermissions([android.Manifest.permission.CAMERA, android.Manifest.permission.RECORD_AUDIO]);
+};
+
let createDateTimeStamp = function () {
let result = '';
let date = new Date();
diff --git a/packages/camera/index.d.ts b/packages/camera/index.d.ts
index 626e6f87..7f2e3924 100644
--- a/packages/camera/index.d.ts
+++ b/packages/camera/index.d.ts
@@ -12,6 +12,8 @@ export function takePicture(options?: CameraOptions): Promise;
export function requestPermissions(): Promise;
export function requestCameraPermissions(): Promise;
export function requestPhotosPermissions(): Promise;
+export function requestRecordPermissions(): Promise;
+export function recordVideo(options?: CameraRecordOptions): Promise<{ file: string }>;
/**
* Is the camera available to use
@@ -19,46 +21,55 @@ export function requestPhotosPermissions(): Promise;
export function isAvailable(): Boolean;
export interface CameraOptions {
- /**
- * Defines the desired width (in device independent pixels) of the taken image. It should be used with height property.
- * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image.
- * The actual image width will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions).
- */
- width?: number;
+ /**
+ * Defines the desired width (in device independent pixels) of the taken image. It should be used with height property.
+ * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image.
+ * The actual image width will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions).
+ */
+ width?: number;
- /**
- * Defines the desired height (in device independent pixels) of the taken image. It should be used with width property.
- * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image.
- * The actual image height will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions).
- */
- height?: number;
+ /**
+ * Defines the desired height (in device independent pixels) of the taken image. It should be used with width property.
+ * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image.
+ * The actual image height will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions).
+ */
+ height?: number;
- /**
- * Defines if camera picture aspect ratio should be kept during picture resizing.
- * This property could affect width or height return values.
- */
- keepAspectRatio?: boolean;
+ /**
+ * Defines if camera picture aspect ratio should be kept during picture resizing.
+ * This property could affect width or height return values.
+ */
+ keepAspectRatio?: boolean;
- /**
- * Defines if camera picture should be copied to photo Gallery (Android) or Photos (iOS)
- */
- saveToGallery?: boolean;
+ /**
+ * Defines if camera picture should be copied to photo Gallery (Android) or Photos (iOS)
+ */
+ saveToGallery?: boolean;
- /**
- * iOS Only
- * Defines if camera "Retake" or "Use Photo" screen forces user to crop camera picture to a square and optionally lets them zoom in.
- */
- allowsEditing?: boolean;
+ /**
+ * iOS Only
+ * Defines if camera "Retake" or "Use Photo" screen forces user to crop camera picture to a square and optionally lets them zoom in.
+ */
+ allowsEditing?: boolean;
- /**
- * The initial camera. Default "rear".
- * The current implementation doesn't work on all Android devices, in which case it falls back to the default behavior.
- */
- cameraFacing?: "front" | "rear";
+ /**
+ * The initial camera. Default "rear".
+ * The current implementation doesn't work on all Android devices, in which case it falls back to the default behavior.
+ */
+ cameraFacing?: 'front' | 'rear';
+ /**
+ * (iOS Only) Specify a custom UIModalPresentationStyle (Defaults to UIModalPresentationStyle.FullScreen)
+ */
+ modalPresentationStyle?: number;
+}
- /**
- * (iOS Only) Specify a custom UIModalPresentationStyle (Defaults to UIModalPresentationStyle.FullScreen)
- */
- modalPresentationStyle?: number;
+export interface CameraRecordOptions {
+ size?: number;
+ hd?: boolean;
+ saveToGallery?: boolean;
+ duration?: number;
+ format?: 'default' | 'mp4';
+ cameraFacing?: 'front' | 'back';
+ allowsEditing?: boolean;
}
diff --git a/packages/camera/index.ios.ts b/packages/camera/index.ios.ts
index 712dc026..e82cc95b 100644
--- a/packages/camera/index.ios.ts
+++ b/packages/camera/index.ios.ts
@@ -1,5 +1,5 @@
-import { Utils, ImageSource, ImageAsset, Trace, Frame } from '@nativescript/core';
-import { CameraOptions } from '.';
+import { Utils, ImageSource, ImageAsset, Trace, Frame, knownFolders, path as nsPath, File } from '@nativescript/core';
+import { CameraOptions, CameraRecordOptions } from '.';
@NativeClass()
class UIImagePickerControllerDelegateImpl extends NSObject implements UIImagePickerControllerDelegate {
@@ -118,6 +118,125 @@ class UIImagePickerControllerDelegateImpl extends NSObject implements UIImagePic
}
}
+@NativeClass
+@ObjCClass(UIImagePickerControllerDelegate)
+class UIImagePickerControllerVideoDelegateImpl extends NSObject implements UIImagePickerControllerDelegate {
+ private _saveToGallery: boolean;
+ private _resolve: (result?: { file: string }) => void;
+ private _reject: (error?: any) => void;
+ private _format: 'default' | 'mp4' = 'default';
+ private _hd: boolean;
+
+ public static initWithCallback(resolve: (result?) => void, reject: () => void): UIImagePickerControllerVideoDelegateImpl {
+ const delegate = UIImagePickerControllerVideoDelegateImpl.new() as UIImagePickerControllerVideoDelegateImpl;
+ delegate._resolve = resolve;
+ delegate._reject = reject;
+ return delegate;
+ }
+
+ public static initWithCallbackOptions(resolve: (result?: { file: string }) => void, reject: (result?: { file: string }) => void, options?: CameraRecordOptions): UIImagePickerControllerVideoDelegateImpl {
+ const delegate = UIImagePickerControllerVideoDelegateImpl.new() as UIImagePickerControllerVideoDelegateImpl;
+ if (options) {
+ delegate._saveToGallery = options.saveToGallery;
+ delegate._format = options.format;
+ delegate._hd = options.hd;
+ }
+ delegate._resolve = resolve;
+ delegate._reject = reject;
+ return delegate;
+ }
+
+ imagePickerControllerDidCancel(picker) {
+ picker.presentingViewController.dismissViewControllerAnimatedCompletion(true, null);
+ listener = null;
+ }
+
+ private saveToGallery(currentDate: Date, source: NSURL) {
+ PHPhotoLibrary.sharedPhotoLibrary().performChangesCompletionHandler(
+ () => {
+ PHAssetChangeRequest.creationRequestForAssetFromVideoAtFileURL(source);
+ },
+ (success, err) => {
+ if (success) {
+ let fetchOptions = PHFetchOptions.alloc().init();
+ let sortDescriptors = NSArray.arrayWithObject(NSSortDescriptor.sortDescriptorWithKeyAscending('creationDate', false));
+ fetchOptions.sortDescriptors = sortDescriptors;
+ fetchOptions.predicate = NSPredicate.predicateWithFormatArgumentArray('mediaType = %d', NSArray.arrayWithObject(PHAssetMediaType.Video));
+ let fetchResult = PHAsset.fetchAssetsWithOptions(fetchOptions);
+
+ if (fetchResult.count > 0) {
+ let asset = fetchResult.objectAtIndex(0);
+
+ const opts = PHVideoRequestOptions.new();
+ opts.version = PHVideoRequestOptionsVersion.Original;
+
+ const dateDiff = asset.creationDate.valueOf() - currentDate.valueOf();
+ if (Math.abs(dateDiff) > 1000) {
+ // Video create date is rounded when asset is created.
+ // Display waring if the asset was created more than 1s before/after the current date.
+ console.warn('Video returned was created more than 1 second ago');
+ }
+ PHImageManager.defaultManager().requestAVAssetForVideoOptionsResultHandler(asset, opts, (asset, audioMix, info) => {
+ if (asset instanceof AVURLAsset) {
+ asset.URL;
+ this._resolve({ file: asset.URL?.absoluteString });
+ } else {
+ this._reject('Failed to load asset');
+ }
+ });
+ }
+ } else {
+ Trace.write('An error ocurred while saving image to gallery: ' + err, Trace.categories.Error, Trace.messageType.error);
+ }
+ }
+ );
+ }
+
+ imagePickerControllerDidFinishPickingMediaWithInfo(picker, info) {
+ if (info) {
+ let currentDate: Date = new Date();
+ let source = info.objectForKey(UIImagePickerControllerMediaURL);
+ const fileName = `NSVID_${createDateTimeStamp()}.mp4`;
+ const path = nsPath.join(knownFolders.documents().path, fileName);
+ if (this._saveToGallery) {
+ if (this._format === 'mp4') {
+ let asset = AVAsset.assetWithURL(source);
+ let preset = this._hd ? AVAssetExportPresetHighestQuality : AVAssetExportPresetLowQuality;
+ let session = AVAssetExportSession.exportSessionWithAssetPresetName(asset, preset);
+ session.outputFileType = AVFileTypeMPEG4;
+ const nativePath = NSURL.fileURLWithPath(path);
+ session.outputURL = nativePath;
+ session.exportAsynchronouslyWithCompletionHandler(() => {
+ this.saveToGallery(currentDate, nativePath);
+ });
+ } else {
+ this.saveToGallery(currentDate, NSURL.fileURLWithPath(path));
+ }
+ } else {
+ if (this._format === 'mp4') {
+ const asset = AVAsset.assetWithURL(source);
+ const preset = this._hd ? AVAssetExportPresetHighestQuality : AVAssetExportPresetLowQuality;
+ const session = AVAssetExportSession.exportSessionWithAssetPresetName(asset, preset);
+ session.outputFileType = AVFileTypeMPEG4;
+ session.outputURL = NSURL.fileURLWithPath(path);
+ session.exportAsynchronouslyWithCompletionHandler(() => {
+ if (session.error) {
+ this._reject(session.error.localizedDescription);
+ } else {
+ File.fromPath(source.path).removeSync();
+ this._resolve({ file: path });
+ }
+ });
+ } else {
+ this._resolve({ file: source.path });
+ }
+ }
+ picker.presentingViewController.dismissViewControllerAnimatedCompletion(true, null);
+ listener = null;
+ }
+ }
+}
+
let listener;
export let takePicture = function (options: CameraOptions): Promise {
@@ -190,6 +309,52 @@ export let takePicture = function (options: CameraOptions): Promise {
});
};
+export let recordVideo = function (options: CameraRecordOptions): Promise<{ file: string }> {
+ return new Promise((resolve, reject) => {
+ listener = null;
+ let modalPresentationStyle = UIModalPresentationStyle.FullScreen;
+ let picker = UIImagePickerController.new();
+ picker.mediaTypes = [kUTTypeMovie];
+ picker.sourceType = UIImagePickerControllerSourceType.Camera;
+ picker.cameraCaptureMode = UIImagePickerControllerCameraCaptureMode.Video;
+
+ picker.cameraDevice = options?.cameraFacing === 'front' ? UIImagePickerControllerCameraDevice.Front : UIImagePickerControllerCameraDevice.Rear;
+ picker.allowsEditing = typeof options?.allowsEditing === 'boolean' ? options.allowsEditing : false;
+ picker.videoQuality = options?.hd ? UIImagePickerControllerQualityType.TypeHigh : UIImagePickerControllerQualityType.TypeLow;
+
+ if (typeof options?.duration === 'number' && options.duration > 0) {
+ picker.videoMaximumDuration = options.duration;
+ }
+
+ if (options) {
+ listener = UIImagePickerControllerVideoDelegateImpl.initWithCallbackOptions(resolve, reject, options);
+ } else {
+ listener = UIImagePickerControllerVideoDelegateImpl.initWithCallback(resolve, reject);
+ }
+
+ picker.delegate = listener;
+ picker.modalPresentationStyle = modalPresentationStyle;
+
+ let topMostFrame = Frame.topmost();
+ if (topMostFrame) {
+ let viewController: UIViewController = topMostFrame.currentPage && topMostFrame.currentPage.ios;
+ if (viewController) {
+ while (viewController.parentViewController) {
+ // find top-most view controler
+ viewController = viewController.parentViewController;
+ }
+
+ while (viewController.presentedViewController) {
+ // find last presented modal
+ viewController = viewController.presentedViewController;
+ }
+
+ viewController.presentViewControllerAnimatedCompletion(picker, true, null);
+ }
+ }
+ });
+};
+
export let isAvailable = function () {
return UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.Camera);
};
@@ -267,3 +432,11 @@ export let requestCameraPermissions = function () {
}
});
};
+
+let createDateTimeStamp = function () {
+ let result = '';
+ let date = new Date();
+ result = date.getFullYear().toString() + (date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1).toString() : (date.getMonth() + 1).toString()) + (date.getDate() < 10 ? '0' + date.getDate().toString() : date.getDate().toString()) + '_' + date.getHours().toString() + date.getMinutes().toString() + date.getSeconds().toString();
+
+ return result;
+};
diff --git a/packages/camera/platforms/android/AndroidManifest.xml b/packages/camera/platforms/android/AndroidManifest.xml
index 3555dd70..2e422171 100644
--- a/packages/camera/platforms/android/AndroidManifest.xml
+++ b/packages/camera/platforms/android/AndroidManifest.xml
@@ -13,4 +13,5 @@
+
diff --git a/references.d.ts b/references.d.ts
index 06fff39f..816d7ac3 100644
--- a/references.d.ts
+++ b/references.d.ts
@@ -11,3 +11,4 @@
///
///
///
+///
diff --git a/tools/demo/camera/index.ts b/tools/demo/camera/index.ts
index cd092681..a0a1ab0d 100644
--- a/tools/demo/camera/index.ts
+++ b/tools/demo/camera/index.ts
@@ -1,5 +1,5 @@
import { Dialogs, EventData, ImageAsset } from '@nativescript/core';
-import { requestPermissions, takePicture } from '@nativescript/camera';
+import { requestPermissions, takePicture, recordVideo, requestRecordPermissions } from '@nativescript/camera';
import { DemoSharedBase } from '../utils';
export class DemoSharedCamera extends DemoSharedBase {
@@ -45,4 +45,20 @@ export class DemoSharedCamera extends DemoSharedBase {
() => Dialogs.alert('permissions rejected')
);
}
+
+ onRecordVideoTap(args: EventData) {
+ requestRecordPermissions().then(
+ () => {
+ recordVideo({ saveToGallery: this.saveToGallery, hd: true }).then(
+ (result) => {
+ console.log(result);
+ },
+ (err) => {
+ console.log('Error -> ' + err.message);
+ }
+ );
+ },
+ () => Dialogs.alert('permissions rejected')
+ );
+ }
}