From 8dd42188656f0432588b45bc8aba48eb59f527cc Mon Sep 17 00:00:00 2001 From: Steve McNiven-Scott Date: Fri, 11 Sep 2026 09:02:21 -0400 Subject: [PATCH 1/9] feat(imagepicker): use PHPickerViewController on iOS Replace the QBImagePickerController pod with the system photo picker (iOS 14+). The public API is unchanged: create(), authorize(), present() and the ImagePickerSelection shape all behave as before, and cancel still rejects with Error('Canceled'). - No CocoaPods dependency any more; the picker runs out of process. - authorize() is now optional. With library access, picks resolve to their PHAsset as before; without it, the picker's file copy is used. - Ordered multi-select honours maximumNumberOfSelection. - The iOS-only UI options (prompt, column counts, etc.) are marked deprecated since the system picker owns its own UI. --- packages/imagepicker/README.md | 32 +- packages/imagepicker/common.ts | 15 +- packages/imagepicker/index.d.ts | 15 +- packages/imagepicker/index.ios.ts | 479 ++++++++++++------ packages/imagepicker/package.json | 2 +- packages/imagepicker/platforms/ios/Podfile | 1 - packages/imagepicker/references.d.ts | 2 +- .../typings/objc!QBImagePickerController.d.ts | 229 --------- 8 files changed, 370 insertions(+), 405 deletions(-) delete mode 100644 packages/imagepicker/platforms/ios/Podfile delete mode 100644 packages/imagepicker/typings/objc!QBImagePickerController.d.ts diff --git a/packages/imagepicker/README.md b/packages/imagepicker/README.md index a1e7ce6f..b7bfb34e 100644 --- a/packages/imagepicker/README.md +++ b/packages/imagepicker/README.md @@ -19,7 +19,7 @@ Imagepicker plugin supporting both single and multiple selection. -- Plugin supports **iOS8+** and uses [QBImagePicker](https://github.com/questbeat/QBImagePicker) cocoapod. +- Plugin supports **iOS 14+** and uses the system [PHPickerViewController](https://developer.apple.com/documentation/photokit/phpickerviewcontroller) (the modern Photos picker with search and albums). No CocoaPods dependency is required. - For **Android** it uses [Intents](https://developer.android.com/reference/android/content/Intent) to open the stock images or file pickers. For Android 6 (API 23) and above, the permissions to read file storage should be explicitly required. ## Installation @@ -28,6 +28,11 @@ Install the plugin by running the following command in the root directory of you ```cli npm install @nativescript/imagepicker ``` +**Note: Version 5.1 changes on iOS:** +* The picker is now the system `PHPickerViewController`. It runs out of process, so it can be presented without photo-library permission; calling `authorize()` first is still recommended so that selections resolve to their `PHAsset` (see [iOS required permissions](#ios-required-permissions)). +* `minimumNumberOfSelection`, `showsNumberOfSelectedAssets`, `prompt`, `numberOfColumnsInPortrait` and `numberOfColumnsInLandscape` are accepted but have no effect, because the system picker owns its own UI. +* Requires iOS 14 or later. + **Note: Version 3.1 contains breaking changes:** * New behavior on iOS when the user selects `Limit AccessLim..` detailed in [iOS Limited permission](#ios-limited-permission). @@ -82,7 +87,9 @@ For phones running < Android 13, this `use_photo_picker` option has no effect. ### iOS required permissions -Using the plugin on iOS requires the `NSPhotoLibraryUsageDescription` permission. Modify the `app/App_Resources/iOS/Info.plist` file to add it as follows: +The system picker itself needs no permission. `authorize()` requests photo-library access so that picked items resolve to their `PHAsset` (giving you `asset`, `filesize`, `duration` and `thumbnail` straight from the library). If access is not granted, `present()` still works: the picker hands over a copy of each selected file, which the plugin stores in the app's temporary folder and exposes through `path` and `asset`. + +Calling `authorize()` requires the `NSPhotoLibraryUsageDescription` permission. Modify the `app/App_Resources/iOS/Info.plist` file to add it as follows: ```xml NSPhotoLibraryUsageDescription @@ -96,6 +103,8 @@ Apple introduced the `PHAuthorizationStatusLimited` permission status with iOS 1 In this case `authorise()` will return an `AuthorizationResult` where `authorized` will be `true` and the `details` will contain `'limited'`. +With limited access the system picker still lets the user browse their whole library. Items inside the limited selection resolve to their `PHAsset`; any other item falls back to a copy of the file, exactly as when access was not granted. A single `present()` call can therefore return a mix of both. + Every time the app is launched anew, and the authorize method is called, if the current permission is `limited` the user will be prompted to update the image selection. To prevent this prompt, add the following values to your `App_Resources/iOS/Info.plist`: @@ -159,6 +168,15 @@ imagePickerObj }); ``` +On iOS you may also skip `authorize()` altogether: `present()` shows the system picker without any permission and every selection comes back as a file copy (with `asset`, `path`, `filename`, `filesize`, `type`, `duration` and `thumbnail` still populated). + + +```ts +if (isIOS) { + const selection = await imagePickerObj.present(); // rejects with Error('Canceled') if dismissed +} +``` + ### Demo You can play with the plugin on StackBlitz at any of the following links: @@ -187,12 +205,12 @@ An object passed to the `create` method to specify the characteristics of a medi | Option | Type | Default |Description |:---------------------------|:-------- |:---------|:------- | `mode` | `string` | `multiple` | The mode of the imagepicker. Possible values are `single` for single selection and `multiple` for multiple selection. | -| `minimumNumberOfSelection` | `number` | `0` | _Optional_: (`iOS-only`) The minumum number of selected assets. | +| `minimumNumberOfSelection` | `number` | `0` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. | | `maximumNumberOfSelection` | `number` | `0` | _Optional_: (`iOS-only`, `Android-Photo Picker-Only`) The maximum number of selected assets. | -| `showsNumberOfSelectedAssets` | `boolean` | `true` | _Optional_: (`iOS-only`) Display the number of selected assets. | -| `prompt` | `string` | `undefined` | _Optional_: (`iOS-only`) Display prompt text when selecting assets. | -| `numberOfColumnsInPortrait` | `number` | `4` | _Optional_: (`iOS-only`) Sets the number of columns in Portrait orientation | -| `numberOfColumnsInLandscape` | `number` | `7` | _Optional_: (`iOS-only`) Sets the number of columns in Landscape orientation. | +| `showsNumberOfSelectedAssets` | `boolean` | `true` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. | +| `prompt` | `string` | `undefined` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. | +| `numberOfColumnsInPortrait` | `number` | `4` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. | +| `numberOfColumnsInLandscape` | `number` | `7` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. | | `mediaType` | [ImagePickerMediaType](#imagepickermediatype) | `Any` |_Optional_: The type of media asset to pick whether to pick Image/Video/Any type of assets. | | `copyToAppFolder` | `string` | `undefined` | _Optional_: If passed, a new folder will be created in your applications folder and the asset will be copied there. | | `renameFileTo` | `string` | `undefined` | _Optional_: If passed, the copied file will be named what you choose. If you select multiple, -index will be appended. | diff --git a/packages/imagepicker/common.ts b/packages/imagepicker/common.ts index a6c55761..a98cb845 100644 --- a/packages/imagepicker/common.ts +++ b/packages/imagepicker/common.ts @@ -59,7 +59,8 @@ export interface Options { mode?: string; /** - * Set the minumum number of selected assets in iOS + * Set the minumum number of selected assets in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ minimumNumberOfSelection?: number; @@ -69,22 +70,26 @@ export interface Options { maximumNumberOfSelection?: number; /** - * Display the number of selected assets in iOS + * Display the number of selected assets in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ showsNumberOfSelectedAssets?: boolean; /** - * Display prompt text when selecting assets in iOS + * Display prompt text when selecting assets in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ prompt?: string; /** - * Set the number of columns in Portrait in iOS + * Set the number of columns in Portrait in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ numberOfColumnsInPortrait?: number; /** - * Set the number of columns in Landscape in iOS + * Set the number of columns in Landscape in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ numberOfColumnsInLandscape?: number; diff --git a/packages/imagepicker/index.d.ts b/packages/imagepicker/index.d.ts index 418f574d..16800eaf 100644 --- a/packages/imagepicker/index.d.ts +++ b/packages/imagepicker/index.d.ts @@ -74,7 +74,8 @@ interface Options { mode?: string; /** - * Set the minumum number of selected assets in iOS + * Set the minumum number of selected assets in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ minimumNumberOfSelection?: number; @@ -84,22 +85,26 @@ interface Options { maximumNumberOfSelection?: number; /** - * Display the number of selected assets in iOS + * Display the number of selected assets in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ showsNumberOfSelectedAssets?: boolean; /** - * Display prompt text when selecting assets in iOS + * Display prompt text when selecting assets in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ prompt?: string; /** - * Set the number of columns in Portrait in iOS + * Set the number of columns in Portrait in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ numberOfColumnsInPortrait?: number; /** - * Set the number of columns in Landscape in iOS + * Set the number of columns in Landscape in iOS. + * @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI. */ numberOfColumnsInLandscape?: number; diff --git a/packages/imagepicker/index.ios.ts b/packages/imagepicker/index.ios.ts index 473f3db6..a5741de4 100644 --- a/packages/imagepicker/index.ios.ts +++ b/packages/imagepicker/index.ios.ts @@ -1,23 +1,24 @@ -import { ImageAsset, View, Utils, Application, path, knownFolders, ImageSource } from '@nativescript/core'; -import { AuthorizationResult, ImagePickerBase, ImagePickerSelection, Options } from './common'; -import { getFile } from '@nativescript/core/http'; +import { ImageAsset, View, Utils, Application, path, knownFolders, ImageSource, Folder, File } from '@nativescript/core'; import * as permissions from '@nativescript-community/perms'; +import { AuthorizationResult, ImagePickerBase, ImagePickerMediaType, ImagePickerSelection, Options } from './common'; export * from './common'; -type FileMap = { - [key: string]: ImagePickerSelection; -}; -const defaultAssetCollectionSubtypes: NSArray = NSArray.arrayWithArray([PHAssetCollectionSubtype.SmartAlbumRecentlyAdded, PHAssetCollectionSubtype.SmartAlbumUserLibrary, PHAssetCollectionSubtype.AlbumMyPhotoStream, PHAssetCollectionSubtype.SmartAlbumFavorites, PHAssetCollectionSubtype.SmartAlbumPanoramas, PHAssetCollectionSubtype.SmartAlbumBursts, PHAssetCollectionSubtype.AlbumCloudShared, PHAssetCollectionSubtype.SmartAlbumSelfPortraits, PHAssetCollectionSubtype.SmartAlbumScreenshots, PHAssetCollectionSubtype.SmartAlbumLivePhotos]); -let copyToAppFolder; -let renameFileTo; -let augmentedAssetsInfo; -let resolveWhenDismissed; -let fileMap: FileMap = {}; + +// iOS picker built on PHPickerViewController (iOS 14+): the system Photos +// picker with search, albums and the full library browser. It runs out of +// process, so it needs no photo-library permission to show. Calling +// authorize() first is still recommended: with permission granted the picked +// items resolve to their PHAsset (exactly as before), otherwise the plugin +// falls back to a copy of the file that the picker hands over. + +const IMAGE_UTI = 'public.image'; +const MOVIE_UTI = 'public.movie'; + export class ImagePicker extends ImagePickerBase { - _imagePickerController: QBImagePickerController; + _imagePickerController: PHPickerViewController; _hostView: View; _delegate: ImagePickerControllerDelegate; + _options: Options; - // lazy-load latest frame.topmost() if _hostName is not used get hostView() { return this._hostView; } @@ -33,36 +34,18 @@ export class ImagePicker extends ImagePickerBase { constructor(options: Options = {}, hostView: View) { super(); this._hostView = hostView; - - const imagePickerController = QBImagePickerController.alloc().init(); - - imagePickerController.assetCollectionSubtypes = defaultAssetCollectionSubtypes; - imagePickerController.mediaType = options.mediaType ? options.mediaType.valueOf() : QBImagePickerMediaType.Any; - imagePickerController.allowsMultipleSelection = options.mode !== 'single'; - imagePickerController.minimumNumberOfSelection = options.minimumNumberOfSelection || 0; - imagePickerController.maximumNumberOfSelection = options.maximumNumberOfSelection || 0; - imagePickerController.showsNumberOfSelectedAssets = options.showsNumberOfSelectedAssets || true; - imagePickerController.numberOfColumnsInPortrait = options.numberOfColumnsInPortrait || imagePickerController.numberOfColumnsInPortrait; - imagePickerController.numberOfColumnsInLandscape = options.numberOfColumnsInLandscape || imagePickerController.numberOfColumnsInLandscape; - imagePickerController.prompt = options.prompt || imagePickerController.prompt; - copyToAppFolder = options.copyToAppFolder || false; - renameFileTo = options.renameFileTo || false; - augmentedAssetsInfo = options.augmentedAssetsInfo ?? true; - resolveWhenDismissed = options.resolveWhenDismissed ?? false; - this._imagePickerController = imagePickerController; + this._options = options; + this._imagePickerController = createPickerController(options); } authorize(): Promise { - console.log('authorizing...'); return permissions.request('photo').then((result) => this.mapResult(result)); } present(): Promise { - fileMap = {}; return new Promise((resolve, reject) => { this._delegate = ImagePickerControllerDelegate.initWithOwner(this, resolve, reject); this._imagePickerController.delegate = this._delegate; - this.hostController.presentViewControllerAnimatedCompletion(this._imagePickerController, true, null); }); } @@ -73,148 +56,332 @@ export class ImagePicker extends ImagePickerBase { } } +function createPickerController(options: Options): PHPickerViewController { + if (typeof PHPickerViewController === 'undefined') { + throw new Error('@nativescript/imagepicker requires iOS 14 or later.'); + } + + // Binding the configuration to the shared photo library is what makes + // PHPickerResult.assetIdentifier available, so picks can resolve to PHAssets. + const config = PHPickerConfiguration.alloc().initWithPhotoLibrary(PHPhotoLibrary.sharedPhotoLibrary()); + config.filter = pickerFilter(options.mediaType); + config.selectionLimit = options.mode === 'single' ? 1 : options.maximumNumberOfSelection || 0; + if (config.selectionLimit !== 1 && config.respondsToSelector('setSelection:')) { + config.selection = PHPickerConfigurationSelection.Ordered; + } + + return PHPickerViewController.alloc().initWithConfiguration(config); +} + +function pickerFilter(mediaType: ImagePickerMediaType | undefined): PHPickerFilter | null { + switch (mediaType) { + case ImagePickerMediaType.Image: + return PHPickerFilter.imagesFilter; + case ImagePickerMediaType.Video: + return PHPickerFilter.videosFilter; + default: + return null; // images and videos + } +} + @NativeClass() -class ImagePickerControllerDelegate extends NSObject implements QBImagePickerControllerDelegate { - _resolve: any; - _reject: any; +class ImagePickerControllerDelegate extends NSObject implements PHPickerViewControllerDelegate { + static ObjCProtocols = [PHPickerViewControllerDelegate]; + + _resolve: (selections: ImagePickerSelection[]) => void; + _reject: (error: Error) => void; owner: WeakRef; - qb_imagePickerControllerDidCancel?(imagePickerController: QBImagePickerController): void { - imagePickerController.dismissViewControllerAnimatedCompletion(true, () => { - if (this._reject) { - this._reject(new Error('Canceled')); + static initWithOwner(owner: ImagePicker, resolve, reject) { + const delegate = new ImagePickerControllerDelegate(); + delegate.owner = new WeakRef(owner); + delegate._resolve = resolve; + delegate._reject = reject; + return delegate; + } + + // Cancel (button or swipe-down) arrives here with an empty results array. + pickerDidFinishPicking(picker: PHPickerViewController, results: NSArray): void { + const owner = this.owner.deref(); + const pickerResults = toArray(results); + + if (pickerResults.length === 0) { + dismiss(picker, owner).then(() => this._reject?.(new Error('Canceled'))); + return; + } + + this.finishPicking(picker, owner, pickerResults).catch((error) => this._reject?.(error)); + } + + private async finishPicking(picker: PHPickerViewController, owner: ImagePicker | undefined, results: PHPickerResult[]): Promise { + const options = owner?._options ?? {}; + const selections: ImagePickerSelection[] = []; + + try { + for (const result of results) { + selections.push(await toSelection(result)); } + } catch (error) { + await dismiss(picker, owner); + throw error; + } - if (imagePicker) { - imagePicker._cleanup(); + // Start dismissing as soon as the picks are known; copying and + // thumbnailing continue behind the dismiss animation. + const dismissed = dismiss(picker, owner); + + try { + const augment = options.copyToAppFolder || options.augmentedAssetsInfo !== false; + if (augment) { + await Promise.all(selections.map((selection, index) => augmentSelection(selection, index, selections.length, options))); } - imagePicker = null; - }); + } catch (error) { + await dismissed; + throw error; + } + + if (options.resolveWhenDismissed) { + await dismissed; + } + this._resolve?.(selections); } +} - async qb_imagePickerControllerDidFinishPickingAssets?(imagePickerController: QBImagePickerController, iosAssets: NSArray) { - for (let i = 0; i < iosAssets.count; i++) { - const asset = new ImageAsset(iosAssets.objectAtIndex(i)); - const phAssetImage: PHAsset = (asset)._ios; - // this fixes the image aspect ratio in tns-core-modules version < 4.0 - if (!asset.options) asset.options = { keepAspectRatio: true }; - const existingFileName = phAssetImage.valueForKey('filename'); - const pickerSelection: ImagePickerSelection = { - asset: asset, - type: phAssetImage.mediaType == 2 ? 'video' : 'image', - filename: existingFileName, - originalFilename: existingFileName, - filesize: 0, - path: '', - }; - if (pickerSelection.type == 'video') pickerSelection.duration = parseInt(phAssetImage.duration.toFixed(0)); - fileMap[existingFileName] = pickerSelection; - if (pickerSelection.type == 'video') { - const manager = new PHImageManager(); - const options = new PHVideoRequestOptions(); - options.networkAccessAllowed = true; - await new Promise((resolve) => { - manager.requestAVAssetForVideoOptionsResultHandler(phAssetImage, options, (urlAsset: AVURLAsset, audioMix, info) => { - fileMap[existingFileName].path = urlAsset.URL.toString().replace('file://', ''); - resolve(); - }); - }); - } else { - const imageOptions = new PHContentEditingInputRequestOptions(); - imageOptions.networkAccessAllowed = true; - await new Promise((resolve) => { - phAssetImage.requestContentEditingInputWithOptionsCompletionHandler(imageOptions, (thing) => { - fileMap[existingFileName].path = thing.fullSizeImageURL.toString().replace('file://', ''); - resolve(); - }); - }); +function dismiss(picker: PHPickerViewController, owner: ImagePicker | undefined): Promise { + return new Promise((resolve) => { + picker.dismissViewControllerAnimatedCompletion(true, () => { + owner?._cleanup(); + if (imagePicker === owner) { + imagePicker = null; } + // Picking repeatedly without a collection could leak native memory + // https://github.com/NativeScript/nativescript-imagepicker/issues/222 + setTimeout(Utils.GC, 200); + resolve(); + }); + }); +} + +function toArray(results: NSArray): PHPickerResult[] { + const items: PHPickerResult[] = []; + for (let i = 0; i < results.count; i++) { + items.push(results.objectAtIndex(i)); + } + return items; +} + +// A picked item resolves to its PHAsset when the app has photo-library access +// and Photos can hand out a file for it; otherwise the picker still vends the +// file itself and a copy of that is used instead. +async function toSelection(result: PHPickerResult): Promise { + const phAsset = fetchAsset(result.assetIdentifier); + if (phAsset) { + const selection = await selectionFromAsset(phAsset); + if (selection.path) { + return selection; } - let wasDismissed = false; - const closePromise = new Promise((resolve) => { - imagePickerController.dismissViewControllerAnimatedCompletion(true, () => { - wasDismissed = true; - resolve(); - if (imagePicker) { - imagePicker._cleanup(); - } - imagePicker = null; - // FIX: possible memory issue when picking images many times. - // Not the best solution, but the only one working for now - // https://github.com/NativeScript/nativescript-imagepicker/issues/222 - setTimeout(Utils.GC, 200); - }); + } + return selectionFromItemProvider(result.itemProvider); +} + +function hasLibraryAccess(): boolean { + const status = PHPhotoLibrary.authorizationStatusForAccessLevel(PHAccessLevel.ReadWrite); + return status === PHAuthorizationStatus.Authorized || status === PHAuthorizationStatus.Limited; +} + +// Fetching assets without permission would trigger the system prompt, which +// the item-provider fallback exists to avoid. +function fetchAsset(identifier: string | null): PHAsset | null { + if (!identifier || !hasLibraryAccess()) { + return null; + } + const fetched = PHAsset.fetchAssetsWithLocalIdentifiersOptions([identifier], null); + return fetched.count > 0 ? fetched.firstObject : null; +} + +async function selectionFromAsset(phAsset: PHAsset): Promise { + const asset = new ImageAsset(phAsset); + if (!asset.options) { + asset.options = { keepAspectRatio: true }; + } + const filename: string = phAsset.valueForKey('filename'); + const isVideo = phAsset.mediaType === PHAssetMediaType.Video; + + const selection: ImagePickerSelection = { + asset, + type: isVideo ? 'video' : 'image', + filename, + originalFilename: filename, + filesize: 0, + path: isVideo ? await videoPath(phAsset) : await imagePath(phAsset), + }; + if (isVideo) { + selection.duration = Math.round(phAsset.duration); + } + return selection; +} + +function imagePath(phAsset: PHAsset): Promise { + return new Promise((resolve) => { + const options = new PHContentEditingInputRequestOptions(); + options.networkAccessAllowed = true; + phAsset.requestContentEditingInputWithOptionsCompletionHandler(options, (input) => { + Utils.dispatchToMainThread(() => resolve(filePath(input?.fullSizeImageURL))); + }); + }); +} + +function videoPath(phAsset: PHAsset): Promise { + return new Promise((resolve) => { + const options = new PHVideoRequestOptions(); + options.networkAccessAllowed = true; + PHImageManager.defaultManager().requestAVAssetForVideoOptionsResultHandler(phAsset, options, (avAsset) => { + const url = avAsset instanceof AVURLAsset ? avAsset.URL : null; + Utils.dispatchToMainThread(() => resolve(filePath(url))); }); - const resolvedFunction = this._resolve; - if (resolvedFunction) { - if (!copyToAppFolder && augmentedAssetsInfo === false) { - if (resolveWhenDismissed && !wasDismissed) { - await closePromise; + }); +} + +// Without photo-library access the picker copies the item to a temporary URL +// that is only valid inside the completion handler, so it is copied out again +// into the app's temp folder before anything else touches it. +function selectionFromItemProvider(provider: NSItemProvider): Promise { + const isVideo = provider.hasItemConformingToTypeIdentifier(MOVIE_UTI); + const uti = isVideo ? MOVIE_UTI : IMAGE_UTI; + + return new Promise((resolve, reject) => { + const settle = (work: () => ImagePickerSelection) => { + Utils.dispatchToMainThread(() => { + try { + resolve(work()); + } catch (error) { + reject(error); } - return resolvedFunction?.(Object.values(fileMap)); + }); + }; + + provider.loadFileRepresentationForTypeIdentifierCompletionHandler(uti, (url, error) => { + if (error || !url) { + settle(() => { + throw new Error(error?.localizedDescription ?? 'Could not load the selected item.'); + }); + return; } - const promises = []; - let count = 0; - for (const key in fileMap) { - const item = fileMap[key]; - const folder = knownFolders.documents(); - const extension = item.filename.split('.').pop(); - let filename = renameFileTo ? renameFileTo + '.' + extension : item.filename; - if (iosAssets.count > 1) filename = renameFileTo ? renameFileTo + '-' + count + '.' + extension : item.filename; - fileMap[item.filename].filename = filename; - const fileManager = new NSFileManager(); - if (copyToAppFolder) { - const filePath = path.join(folder.path + '/' + copyToAppFolder, filename); - promises.push( - getFile('file://' + item.path, filePath) - .then((result) => { - fileMap[item.originalFilename].path = filePath; - fileMap[item.originalFilename].filesize = fileManager.attributesOfItemAtPathError(filePath).fileSize(); - if (item.type == 'video') { - return ImageSource.fromAsset(item.asset).then((source) => { - fileMap[item.originalFilename].thumbnail = source; - }); - } - }) - .catch((error) => { - console.log('Error copying file: ', error); - }) - ); - } else { - fileMap[item.originalFilename].filesize = fileManager.attributesOfItemAtPathError(fileMap[item.filename].path).fileSize(); - if (item.type == 'video') { - promises.push( - ImageSource.fromAsset(item.asset).then((source) => { - fileMap[item.originalFilename].thumbnail = source; - }) - ); - } - } - count++; + + // Copy synchronously: the source URL is gone once this handler returns. + let copiedPath: string; + try { + copiedPath = copyToTempFolder(filePath(url)); + } catch (copyError) { + settle(() => { + throw copyError; + }); + return; } - Promise.all(promises).then(async () => { - const results: ImagePickerSelection[] = []; - for (const key in fileMap) { - results.push(fileMap[key]); - } - if (resolveWhenDismissed && !wasDismissed) { - await closePromise; - } - resolvedFunction?.(results); - }); + settle(() => (isVideo ? videoSelectionFromFile(copiedPath) : imageSelectionFromFile(copiedPath))); + }); + }); +} + +// Each pick gets its own folder so two files with the same name never collide. +function copyToTempFolder(sourcePath: string): string { + const folder = knownFolders.temp().getFolder('imagepicker').getFolder(NSUUID.UUID().UUIDString); + const destination = path.join(folder.path, NSString.stringWithString(sourcePath).lastPathComponent || 'file'); + copyFile(sourcePath, destination); + return destination; +} + +function imageSelectionFromFile(filePath: string): ImagePickerSelection { + const filename = NSString.stringWithString(filePath).lastPathComponent; + return { + asset: new ImageAsset(filePath), + type: 'image', + filename, + originalFilename: filename, + filesize: 0, + path: filePath, + }; +} + +function videoSelectionFromFile(filePath: string): ImagePickerSelection { + const filename = NSString.stringWithString(filePath).lastPathComponent; + const avAsset = AVURLAsset.assetWithURL(NSURL.fileURLWithPath(filePath)); + const frame = firstVideoFrame(avAsset); + return { + // Like a PHAsset-backed video, the asset renders a preview frame. + asset: new ImageAsset(frame ? UIImage.imageWithCGImage(frame) : filePath), + type: 'video', + filename, + originalFilename: filename, + filesize: 0, + path: filePath, + duration: Math.round(CMTimeGetSeconds(avAsset.duration)) || 0, + }; +} + +function firstVideoFrame(avAsset: AVAsset): any { + const generator = AVAssetImageGenerator.assetImageGeneratorWithAsset(avAsset); + generator.appliesPreferredTrackTransform = true; + try { + return generator.copyCGImageAtTimeActualTimeError(CMTimeMake(0, 1), null); + } catch { + return null; + } +} + +// Optional post-processing: rename, copy into the app folder, and fill in +// filesize plus a video thumbnail. A failed copy keeps the original path, as +// before, rather than failing the whole selection. +async function augmentSelection(selection: ImagePickerSelection, index: number, total: number, options: Options): Promise { + selection.filename = targetFilename(selection.originalFilename, index, total, options.renameFileTo); + + if (options.copyToAppFolder) { + const folder = knownFolders.documents().getFolder(options.copyToAppFolder); + const destination = path.join(folder.path, selection.filename); + try { + copyFile(selection.path, destination); + selection.path = destination; + } catch (error) { + console.log('Error copying file: ', selection.path, error); } } - static ObjCProtocols = [QBImagePickerControllerDelegate]; + selection.filesize = fileSize(selection.path); + if (selection.type === 'video') { + const thumbnail = await ImageSource.fromAsset(selection.asset).catch(() => null); + if (thumbnail) { + selection.thumbnail = thumbnail; + } + } +} - static initWithOwner(owner: ImagePicker, resolve, reject) { - const delegate = new ImagePickerControllerDelegate(); - delegate.owner = new WeakRef(owner); - delegate._resolve = resolve; - delegate._reject = reject; - return delegate; +function targetFilename(original: string, index: number, total: number, renameTo: string | undefined): string { + if (!renameTo) { + return original; } + const extension = original.split('.').pop(); + return total > 1 ? `${renameTo}-${index}.${extension}` : `${renameTo}.${extension}`; +} + +function fileSize(filePath: string): number { + const attributes = NSFileManager.defaultManager.attributesOfItemAtPathError(filePath); + return attributes ? attributes.fileSize() : 0; +} + +// Replaces any file already at the destination. NativeScript throws the +// NSError of a failed copy, so callers decide how to handle it. +function copyFile(source: string, destination: string): void { + if (File.exists(destination)) { + File.fromPath(destination).removeSync(); + } + if (!NSFileManager.defaultManager.copyItemAtPathToPathError(source, destination)) { + throw new Error(`Could not copy ${source} to ${destination}`); + } +} + +function filePath(url: NSURL | null): string { + return url ? url.path : ''; } let imagePicker: ImagePicker; diff --git a/packages/imagepicker/package.json b/packages/imagepicker/package.json index 34f694f6..d800ab9e 100644 --- a/packages/imagepicker/package.json +++ b/packages/imagepicker/package.json @@ -6,7 +6,7 @@ "typings": "index.d.ts", "nativescript": { "platforms": { - "ios": "~8.5.0", + "ios": "~9.0.0", "android": "~8.5.0" } }, diff --git a/packages/imagepicker/platforms/ios/Podfile b/packages/imagepicker/platforms/ios/Podfile deleted file mode 100644 index fac45727..00000000 --- a/packages/imagepicker/platforms/ios/Podfile +++ /dev/null @@ -1 +0,0 @@ -pod "QBImagePickerController", :git => 'https://github.com/smartmobilefactory/QBImagePicker.git', :commit => '1e5cd05d3be0c56fd654d36b77768339faf29248' diff --git a/packages/imagepicker/references.d.ts b/packages/imagepicker/references.d.ts index b5a87c29..06b836c7 100644 --- a/packages/imagepicker/references.d.ts +++ b/packages/imagepicker/references.d.ts @@ -1,3 +1,3 @@ -/// /// /// +/// diff --git a/packages/imagepicker/typings/objc!QBImagePickerController.d.ts b/packages/imagepicker/typings/objc!QBImagePickerController.d.ts deleted file mode 100644 index c51fc838..00000000 --- a/packages/imagepicker/typings/objc!QBImagePickerController.d.ts +++ /dev/null @@ -1,229 +0,0 @@ -declare class QBAlbumCell extends UITableViewCell { - static alloc(): QBAlbumCell; // inherited from NSObject - - static appearance(): QBAlbumCell; // inherited from UIAppearance - - static appearanceForTraitCollection(trait: UITraitCollection): QBAlbumCell; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedIn(trait: UITraitCollection, ContainerClass: typeof NSObject): QBAlbumCell; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedInInstancesOfClasses(trait: UITraitCollection, containerTypes: NSArray): QBAlbumCell; // inherited from UIAppearance - - static appearanceWhenContainedIn(ContainerClass: typeof NSObject): QBAlbumCell; // inherited from UIAppearance - - static appearanceWhenContainedInInstancesOfClasses(containerTypes: NSArray): QBAlbumCell; // inherited from UIAppearance - - static new(): QBAlbumCell; // inherited from NSObject - - borderWidth: number; - - countLabel: UILabel; - - imageView1: UIImageView; - - imageView2: UIImageView; - - imageView3: UIImageView; - - titleLabel: UILabel; -} - -declare class QBAlbumsViewController extends UITableViewController { - - static alloc(): QBAlbumsViewController; // inherited from NSObject - - static new(): QBAlbumsViewController; // inherited from NSObject - - imagePickerController: QBImagePickerController; -} - -declare class QBAssetCell extends UICollectionViewCell { - - static alloc(): QBAssetCell; // inherited from NSObject - - static appearance(): QBAssetCell; // inherited from UIAppearance - - static appearanceForTraitCollection(trait: UITraitCollection): QBAssetCell; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedIn(trait: UITraitCollection, ContainerClass: typeof NSObject): QBAssetCell; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedInInstancesOfClasses(trait: UITraitCollection, containerTypes: NSArray): QBAssetCell; // inherited from UIAppearance - - static appearanceWhenContainedIn(ContainerClass: typeof NSObject): QBAssetCell; // inherited from UIAppearance - - static appearanceWhenContainedInInstancesOfClasses(containerTypes: NSArray): QBAssetCell; // inherited from UIAppearance - - static new(): QBAssetCell; // inherited from NSObject - - imageView: UIImageView; - - showsOverlayViewWhenSelected: boolean; - - videoIndicatorView: QBVideoIndicatorView; -} - -declare class QBAssetsViewController extends UICollectionViewController { - - static alloc(): QBAssetsViewController; // inherited from NSObject - - static new(): QBAssetsViewController; // inherited from NSObject - - assetCollection: PHAssetCollection; - - imagePickerController: QBImagePickerController; -} - -declare class QBCheckmarkView extends UIView { - - static alloc(): QBCheckmarkView; // inherited from NSObject - - static appearance(): QBCheckmarkView; // inherited from UIAppearance - - static appearanceForTraitCollection(trait: UITraitCollection): QBCheckmarkView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedIn(trait: UITraitCollection, ContainerClass: typeof NSObject): QBCheckmarkView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedInInstancesOfClasses(trait: UITraitCollection, containerTypes: NSArray): QBCheckmarkView; // inherited from UIAppearance - - static appearanceWhenContainedIn(ContainerClass: typeof NSObject): QBCheckmarkView; // inherited from UIAppearance - - static appearanceWhenContainedInInstancesOfClasses(containerTypes: NSArray): QBCheckmarkView; // inherited from UIAppearance - - static new(): QBCheckmarkView; // inherited from NSObject - - bodyColor: UIColor; - - borderColor: UIColor; - - borderWidth: number; - - checkmarkColor: UIColor; - - checkmarkLineWidth: number; -} - -declare class QBImagePickerController extends UIViewController { - - static alloc(): QBImagePickerController; // inherited from NSObject - - static new(): QBImagePickerController; // inherited from NSObject - - allowsMultipleSelection: boolean; - - assetCollectionSubtypes: NSArray; - - delegate: QBImagePickerControllerDelegate; - - maximumNumberOfSelection: number; - - mediaType: QBImagePickerMediaType; - - minimumNumberOfSelection: number; - - numberOfColumnsInLandscape: number; - - numberOfColumnsInPortrait: number; - - prompt: string; - - readonly selectedAssets: NSMutableOrderedSet; - - showsNumberOfSelectedAssets: boolean; -} - -interface QBImagePickerControllerDelegate extends NSObjectProtocol { - - qb_imagePickerControllerDidCancel?(imagePickerController: QBImagePickerController): void; - - qb_imagePickerControllerDidDeselectAsset?(imagePickerController: QBImagePickerController, asset: PHAsset): void; - - qb_imagePickerControllerDidFinishPickingAssets?(imagePickerController: QBImagePickerController, assets: NSArray): void; - - qb_imagePickerControllerDidSelectAsset?(imagePickerController: QBImagePickerController, asset: PHAsset): void; - - qb_imagePickerControllerShouldSelectAsset?(imagePickerController: QBImagePickerController, asset: PHAsset): boolean; -} -declare var QBImagePickerControllerDelegate: { - - prototype: QBImagePickerControllerDelegate; -}; - -declare var QBImagePickerControllerVersionNumber: number; - -declare var QBImagePickerControllerVersionString: interop.Reference; - -declare const enum QBImagePickerMediaType { - - Any = 0, - - Image = 1, - - Video = 2 -} - -declare class QBSlomoIconView extends UIView { - - static alloc(): QBSlomoIconView; // inherited from NSObject - - static appearance(): QBSlomoIconView; // inherited from UIAppearance - - static appearanceForTraitCollection(trait: UITraitCollection): QBSlomoIconView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedIn(trait: UITraitCollection, ContainerClass: typeof NSObject): QBSlomoIconView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedInInstancesOfClasses(trait: UITraitCollection, containerTypes: NSArray): QBSlomoIconView; // inherited from UIAppearance - - static appearanceWhenContainedIn(ContainerClass: typeof NSObject): QBSlomoIconView; // inherited from UIAppearance - - static appearanceWhenContainedInInstancesOfClasses(containerTypes: NSArray): QBSlomoIconView; // inherited from UIAppearance - - static new(): QBSlomoIconView; // inherited from NSObject - - iconColor: UIColor; -} - -declare class QBVideoIconView extends UIView { - - static alloc(): QBVideoIconView; // inherited from NSObject - - static appearance(): QBVideoIconView; // inherited from UIAppearance - - static appearanceForTraitCollection(trait: UITraitCollection): QBVideoIconView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedIn(trait: UITraitCollection, ContainerClass: typeof NSObject): QBVideoIconView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedInInstancesOfClasses(trait: UITraitCollection, containerTypes: NSArray): QBVideoIconView; // inherited from UIAppearance - - static appearanceWhenContainedIn(ContainerClass: typeof NSObject): QBVideoIconView; // inherited from UIAppearance - - static appearanceWhenContainedInInstancesOfClasses(containerTypes: NSArray): QBVideoIconView; // inherited from UIAppearance - - static new(): QBVideoIconView; // inherited from NSObject - - iconColor: UIColor; -} - -declare class QBVideoIndicatorView extends UIView { - - static alloc(): QBVideoIndicatorView; // inherited from NSObject - - static appearance(): QBVideoIndicatorView; // inherited from UIAppearance - - static appearanceForTraitCollection(trait: UITraitCollection): QBVideoIndicatorView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedIn(trait: UITraitCollection, ContainerClass: typeof NSObject): QBVideoIndicatorView; // inherited from UIAppearance - - static appearanceForTraitCollectionWhenContainedInInstancesOfClasses(trait: UITraitCollection, containerTypes: NSArray): QBVideoIndicatorView; // inherited from UIAppearance - - static appearanceWhenContainedIn(ContainerClass: typeof NSObject): QBVideoIndicatorView; // inherited from UIAppearance - - static appearanceWhenContainedInInstancesOfClasses(containerTypes: NSArray): QBVideoIndicatorView; // inherited from UIAppearance - - static new(): QBVideoIndicatorView; // inherited from NSObject - - slomoIcon: QBSlomoIconView; - - timeLabel: UILabel; - - videoIcon: QBVideoIconView; -} From 820d66ee8c274e47f5f779a27bb3aa8f9138c81a Mon Sep 17 00:00:00 2001 From: Steve McNiven-Scott Date: Fri, 11 Sep 2026 09:36:15 -0400 Subject: [PATCH 2/9] imagepicker: copy files off the main thread iOS copies now run on a global dispatch queue and Android uses the async File.copy(), so large videos no longer block the UI while the picker resolves. Android also drops the two duplicated inline handlers in favour of a shared toSelection() helper. Public API is unchanged. --- packages/imagepicker/index.android.ts | 165 +++++++++----------------- packages/imagepicker/index.ios.ts | 40 +++++-- 2 files changed, 89 insertions(+), 116 deletions(-) diff --git a/packages/imagepicker/index.android.ts b/packages/imagepicker/index.android.ts index 7c2c3e80..824835df 100644 --- a/packages/imagepicker/index.android.ts +++ b/packages/imagepicker/index.android.ts @@ -151,6 +151,52 @@ class UriHelper { } } +// Builds the selection for one picked asset. The optional copy into the app +// folder runs on a background thread via File.copy(); only the video thumbnail +// and duration are still read on the main thread, because Android offers no +// asynchronous API for them and JavaScript cannot run on a Java worker thread. +async function toSelection(selectedAsset: ImageAsset, index?: number): Promise { + const file = File.fromPath(selectedAsset.android); + + const item: ImagePickerSelection = { + asset: selectedAsset, + filename: file.name, + originalFilename: file.name, + type: videoFiles[file.extension.replace('.', '')] ? 'video' : 'image', + path: file.path, + filesize: file.size, + }; + + if (copyToAppFolder) { + const filename = targetFilename(file.name, index); + // getFolder() creates the destination folder if it does not exist yet. + const newPath = knownFolders.documents().getFolder(copyToAppFolder).path + '/' + filename; + await file.copy(newPath); + item.filename = filename; + item.path = newPath; + item.asset.android = newPath; + item.filesize = new java.io.File(newPath).length(); + } + + if (item.type == 'video') { + const thumb = android.media.ThumbnailUtils.createVideoThumbnail(item.path, android.provider.MediaStore.Video.Thumbnails.MINI_KIND); + const retriever = new android.media.MediaMetadataRetriever(); + retriever.setDataSource(item.path); + item.thumbnail = new ImageSource(thumb); + const time = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION); + item.duration = parseInt(time) / 1000; + } + return item; +} + +function targetFilename(original: string, index?: number): string { + if (!renameFileTo) { + return original; + } + const extension = original.split('.').pop(); + return index || index === 0 ? renameFileTo + '-' + index + '.' + extension : renameFileTo + '.' + extension; +} + export class ImagePicker extends ImagePickerBase { private _options: Options; @@ -259,56 +305,8 @@ export class ImagePicker extends ImagePickerBase { uris = [uri]; } - const handle = (selectedAsset, i?) => { - const file = File.fromPath(selectedAsset.android); - let copiedFile: any = false; - - const item: ImagePickerSelection = { - asset: selectedAsset, - filename: file.name, - originalFilename: file.name, - type: videoFiles[file.extension.replace('.', '')] ? 'video' : 'image', - path: file.path, - filesize: file.size, - }; - if (copyToAppFolder) { - let extension = file.name.split('.').pop(); - let filename = file.name; - if (renameFileTo) { - if (i || i === 0) { - filename = renameFileTo + '-' + i + '.' + extension; - } else { - filename = renameFileTo + '.' + extension; - } - item.filename = filename; - } - let newPath = knownFolders.documents().path + '/' + copyToAppFolder + '/' + filename; - copiedFile = File.fromPath(newPath); - item.path = newPath; - item.asset.android = item.path; - copiedFile.writeSync(file.readSync()); - item.filesize = new java.io.File(item.path).length(); - } - if (item.type == 'video') { - const thumb = android.media.ThumbnailUtils.createVideoThumbnail(copiedFile ? copiedFile.path : file.path, android.provider.MediaStore.Video.Thumbnails.MINI_KIND); - let retriever = new android.media.MediaMetadataRetriever(); - retriever.setDataSource(item.path); - item.thumbnail = new ImageSource(thumb); - let time = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION); - let duration = parseInt(time) / 1000; - item.duration = duration; - } - return item; - }; - - let results = []; - for (let i = 0; i <= uris.length - 1; ++i) { - const selectedAsset = new ImageAsset(uris[i].toString()); - let item = handle(selectedAsset, i); - results.push(item); - } Application.android.off(Application.android.activityResultEvent, onResult); - resolve(results); + Promise.all(uris.map((uri, i) => toSelection(new ImageAsset(uri), i))).then(resolve, reject); } catch (e) { Application.android.off(Application.android.activityResultEvent, onResult); reject(e); @@ -355,78 +353,31 @@ export class ImagePicker extends ImagePickerBase { let resultCode = args.resultCode; let data = args.intent; - const handle = (selectedAsset, i?) => { - const file = File.fromPath(selectedAsset.android); - let copiedFile: any = false; - - const item: ImagePickerSelection = { - asset: selectedAsset, - filename: file.name, - originalFilename: file.name, - type: videoFiles[file.extension.replace('.', '')] ? 'video' : 'image', - path: file.path, - filesize: file.size, - }; - if (copyToAppFolder) { - let extension = file.name.split('.').pop(); - let filename = file.name; - if (renameFileTo) { - if (i || i === 0) { - filename = renameFileTo + '-' + i + '.' + extension; - } else { - filename = renameFileTo + '.' + extension; - } - item.filename = filename; - } - let newPath = knownFolders.documents().path + '/' + copyToAppFolder + '/' + filename; - copiedFile = File.fromPath(newPath); - item.path = newPath; - item.asset.android = item.path; - copiedFile.writeSync(file.readSync()); - item.filesize = new java.io.File(item.path).length(); - } - if (item.type == 'video') { - const thumb = android.media.ThumbnailUtils.createVideoThumbnail(copiedFile ? copiedFile.path : file.path, android.provider.MediaStore.Video.Thumbnails.MINI_KIND); - let retriever = new android.media.MediaMetadataRetriever(); - retriever.setDataSource(item.path); - item.thumbnail = new ImageSource(thumb); - let time = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION); - let duration = parseInt(time) / 1000; - item.duration = duration; - } - return item; - }; - if (requestCode === RESULT_CODE_PICKER_IMAGES) { if (resultCode === android.app.Activity.RESULT_OK) { try { - let results = []; - let clip = data.getClipData(); + // Resolve every URI before starting any copy, so a failure here + // cannot leave copies running with nobody listening for the result. const useHelper = (android).os.Build.VERSION.SDK_INT <= 28; + const toPath = (uri: android.net.Uri) => (useHelper ? UriHelper._calculateFileUri(uri) : uri.toString()); + const paths: string[] = []; + let clip = data.getClipData(); if (clip) { let count = clip.getItemCount(); for (let i = 0; i < count; i++) { let clipItem = clip.getItemAt(i); - if (clipItem) { - let uri = clipItem.getUri(); - if (uri) { - const val = useHelper ? UriHelper._calculateFileUri(uri) : uri.toString(); - const selectedAsset = new ImageAsset(val); - let item = handle(selectedAsset, i); - results.push(item); - } + let uri = clipItem ? clipItem.getUri() : null; + if (uri) { + paths.push(toPath(uri)); } } } else { - const uri = data.getData(); - const val = useHelper ? UriHelper._calculateFileUri(uri) : uri.toString(); - const selectedAsset = new ImageAsset(val); - let item = handle(selectedAsset); - results.push(item); + paths.push(toPath(data.getData())); } Application.android.off(AndroidApplication.activityResultEvent, onResult); - resolve(results); + const pending = clip ? paths.map((path, i) => toSelection(new ImageAsset(path), i)) : paths.map((path) => toSelection(new ImageAsset(path))); + Promise.all(pending).then(resolve, reject); return; } catch (e) { Application.android.off(Application.android.activityResultEvent, onResult); diff --git a/packages/imagepicker/index.ios.ts b/packages/imagepicker/index.ios.ts index a5741de4..69426964 100644 --- a/packages/imagepicker/index.ios.ts +++ b/packages/imagepicker/index.ios.ts @@ -1,4 +1,4 @@ -import { ImageAsset, View, Utils, Application, path, knownFolders, ImageSource, Folder, File } from '@nativescript/core'; +import { ImageAsset, View, Utils, Application, path, knownFolders, ImageSource } from '@nativescript/core'; import * as permissions from '@nativescript-community/perms'; import { AuthorizationResult, ImagePickerBase, ImagePickerMediaType, ImagePickerSelection, Options } from './common'; export * from './common'; @@ -285,6 +285,8 @@ function selectionFromItemProvider(provider: NSItemProvider): Promise { selection.filename = targetFilename(selection.originalFilename, index, total, options.renameFileTo); @@ -340,7 +343,7 @@ async function augmentSelection(selection: ImagePickerSelection, index: number, const folder = knownFolders.documents().getFolder(options.copyToAppFolder); const destination = path.join(folder.path, selection.filename); try { - copyFile(selection.path, destination); + await copyFileInBackground(selection.path, destination); selection.path = destination; } catch (error) { console.log('Error copying file: ', selection.path, error); @@ -369,17 +372,36 @@ function fileSize(filePath: string): number { return attributes ? attributes.fileSize() : 0; } -// Replaces any file already at the destination. NativeScript throws the -// NSError of a failed copy, so callers decide how to handle it. +// Replaces any file already at the destination and throws a descriptive +// error when the copy fails, so callers decide how to handle it. function copyFile(source: string, destination: string): void { - if (File.exists(destination)) { - File.fromPath(destination).removeSync(); + const fileManager = NSFileManager.defaultManager; + if (fileManager.fileExistsAtPath(destination)) { + fileManager.removeItemAtPathError(destination, null); } - if (!NSFileManager.defaultManager.copyItemAtPathToPathError(source, destination)) { - throw new Error(`Could not copy ${source} to ${destination}`); + const error = new interop.Reference(); + if (!fileManager.copyItemAtPathToPathError(source, destination, error)) { + const reason = error.value ? error.value.localizedDescription : 'unknown error'; + throw new Error(`Could not copy ${source} to ${destination}: ${reason}`); } } +// Same as copyFile, but off the main thread: the copy itself runs on a global +// dispatch queue and only the result is handed back to the main thread. +function copyFileInBackground(source: string, destination: string): Promise { + return new Promise((resolve, reject) => { + dispatch_async(dispatch_get_global_queue(21 /* qos_class_t.QOS_CLASS_DEFAULT */, 0), () => { + let failure: Error | null = null; + try { + copyFile(source, destination); + } catch (error) { + failure = error; + } + Utils.dispatchToMainThread(() => (failure ? reject(failure) : resolve())); + }); + }); +} + function filePath(url: NSURL | null): string { return url ? url.path : ''; } From e966c5926433254b3de9bb8d555d139d1c304ced Mon Sep 17 00:00:00 2001 From: Steve McNiven-Scott Date: Fri, 11 Sep 2026 10:05:08 -0400 Subject: [PATCH 3/9] imagepicker: add optional onProgress callback iOS streams per-item download progress (iCloud items via PhotoKit and item-provider NSProgress); Android has nothing to report so it emits a single 100% per item. Demo shows a progress bar while items resolve. --- apps/demo/src/plugin-demos/imagepicker.xml | 10 ++- packages/imagepicker/README.md | 19 +++++ packages/imagepicker/common.ts | 29 +++++++ packages/imagepicker/index.android.ts | 11 ++- packages/imagepicker/index.d.ts | 29 +++++++ packages/imagepicker/index.ios.ts | 93 +++++++++++++++++++--- tools/demo/imagepicker/index.ts | 39 +++++++++ 7 files changed, 213 insertions(+), 17 deletions(-) diff --git a/apps/demo/src/plugin-demos/imagepicker.xml b/apps/demo/src/plugin-demos/imagepicker.xml index 63172bfd..4d934c78 100644 --- a/apps/demo/src/plugin-demos/imagepicker.xml +++ b/apps/demo/src/plugin-demos/imagepicker.xml @@ -2,7 +2,7 @@ - + @@ -28,7 +28,11 @@ -