mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-06-04 13:48:01 +02:00
feat(camera): add plugin for Android and iOS
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
Package.resolved
|
||||
/tauri-api
|
||||
@@ -0,0 +1,31 @@
|
||||
// swift-tools-version:5.7
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "tauri-plugin-camera",
|
||||
platforms: [
|
||||
.iOS(.v11),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "tauri-plugin-camera",
|
||||
type: .static,
|
||||
targets: ["tauri-plugin-camera"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Tauri", path: "../../../../../core/tauri/mobile/ios-api")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "tauri-plugin-camera",
|
||||
dependencies: [
|
||||
.byName(name: "Tauri")
|
||||
],
|
||||
path: "Sources")
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Tauri Plugin camera
|
||||
|
||||
A description of this package.
|
||||
@@ -0,0 +1,105 @@
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
internal protocol CameraAuthorizationState {
|
||||
var authorizationState: String { get }
|
||||
}
|
||||
|
||||
extension AVAuthorizationStatus: CameraAuthorizationState {
|
||||
var authorizationState: String {
|
||||
switch self {
|
||||
case .denied, .restricted:
|
||||
return "denied"
|
||||
case .authorized:
|
||||
return "granted"
|
||||
case .notDetermined:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
return "prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PHAuthorizationStatus: CameraAuthorizationState {
|
||||
var authorizationState: String {
|
||||
switch self {
|
||||
case .denied, .restricted:
|
||||
return "denied"
|
||||
case .authorized:
|
||||
return "granted"
|
||||
#if swift(>=5.3)
|
||||
// poor proxy for Xcode 12/iOS 14, should be removed once building with Xcode 12 is required
|
||||
case .limited:
|
||||
return "limited"
|
||||
#endif
|
||||
case .notDetermined:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
return "prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal extension PHAsset {
|
||||
/**
|
||||
Retrieves the image metadata for the asset.
|
||||
*/
|
||||
var imageData: [String: Any] {
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = true
|
||||
options.resizeMode = .none
|
||||
options.isNetworkAccessAllowed = false
|
||||
options.version = .current
|
||||
|
||||
var result: [String: Any] = [:]
|
||||
_ = PHCachingImageManager().requestImageDataAndOrientation(for: self, options: options) { (data, _, _, _) in
|
||||
if let data = data as NSData? {
|
||||
let options = [kCGImageSourceShouldCache as String: kCFBooleanFalse] as CFDictionary
|
||||
if let imgSrc = CGImageSourceCreateWithData(data, options),
|
||||
let metadata = CGImageSourceCopyPropertiesAtIndex(imgSrc, 0, options) as? [String: Any] {
|
||||
result = metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
internal extension UIImage {
|
||||
/**
|
||||
Generates a new image from the existing one, implicitly resetting any orientation.
|
||||
Dimensions greater than 0 will resize the image while preserving the aspect ratio.
|
||||
*/
|
||||
func reformat(to size: CGSize? = nil) -> UIImage {
|
||||
let imageHeight = self.size.height
|
||||
let imageWidth = self.size.width
|
||||
// determine the max dimensions, 0 is treated as 'no restriction'
|
||||
var maxWidth: CGFloat
|
||||
if let size = size, size.width > 0 {
|
||||
maxWidth = size.width
|
||||
} else {
|
||||
maxWidth = imageWidth
|
||||
}
|
||||
let maxHeight: CGFloat
|
||||
if let size = size, size.height > 0 {
|
||||
maxHeight = size.height
|
||||
} else {
|
||||
maxHeight = imageHeight
|
||||
}
|
||||
// adjust to preserve aspect ratio
|
||||
var targetWidth = min(imageWidth, maxWidth)
|
||||
var targetHeight = (imageHeight * targetWidth) / imageWidth
|
||||
if targetHeight > maxHeight {
|
||||
targetWidth = (imageWidth * maxHeight) / imageHeight
|
||||
targetHeight = maxHeight
|
||||
}
|
||||
// generate the new image and return
|
||||
let format: UIGraphicsImageRendererFormat = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1.0
|
||||
format.opaque = false
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: targetWidth, height: targetHeight), format: format)
|
||||
return renderer.image { (_) in
|
||||
self.draw(in: CGRect(origin: .zero, size: CGSize(width: targetWidth, height: targetHeight)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
import Tauri
|
||||
import Photos
|
||||
import PhotosUI
|
||||
|
||||
public class CameraPlugin: Plugin {
|
||||
private var invoke: Invoke?
|
||||
private var settings = CameraSettings()
|
||||
private let defaultSource = CameraSource.prompt
|
||||
private let defaultDirection = CameraDirection.rear
|
||||
private var multiple = false
|
||||
|
||||
private var imageCounter = 0
|
||||
|
||||
@objc override public func checkPermissions(_ invoke: Invoke) {
|
||||
var result: [String: Any] = [:]
|
||||
for permission in CameraPermissionType.allCases {
|
||||
let state: String
|
||||
switch permission {
|
||||
case .camera:
|
||||
state = AVCaptureDevice.authorizationStatus(for: .video).authorizationState
|
||||
case .photos:
|
||||
if #available(iOS 14, *) {
|
||||
state = PHPhotoLibrary.authorizationStatus(for: .readWrite).authorizationState
|
||||
} else {
|
||||
state = PHPhotoLibrary.authorizationStatus().authorizationState
|
||||
}
|
||||
}
|
||||
result[permission.rawValue] = state
|
||||
}
|
||||
invoke.resolve(result)
|
||||
}
|
||||
|
||||
@objc override public func requestPermissions(_ invoke: Invoke) {
|
||||
// get the list of desired types, if passed
|
||||
let typeList = invoke.getArray("permissions", String.self)?.compactMap({ (type) -> CameraPermissionType? in
|
||||
return CameraPermissionType(rawValue: type)
|
||||
}) ?? []
|
||||
// otherwise check everything
|
||||
let permissions: [CameraPermissionType] = (typeList.count > 0) ? typeList : CameraPermissionType.allCases
|
||||
// request the permissions
|
||||
let group = DispatchGroup()
|
||||
for permission in permissions {
|
||||
switch permission {
|
||||
case .camera:
|
||||
group.enter()
|
||||
AVCaptureDevice.requestAccess(for: .video) { _ in
|
||||
group.leave()
|
||||
}
|
||||
case .photos:
|
||||
group.enter()
|
||||
if #available(iOS 14, *) {
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in
|
||||
group.leave()
|
||||
}
|
||||
} else {
|
||||
PHPhotoLibrary.requestAuthorization({ (_) in
|
||||
group.leave()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
group.notify(queue: DispatchQueue.main) { [weak self] in
|
||||
self?.checkPermissions(invoke)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func pickLimitedLibraryPhotos(_ invoke: Invoke) {
|
||||
if #available(iOS 14, *) {
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { (granted) in
|
||||
if granted == .limited {
|
||||
if let viewController = self.manager.viewController {
|
||||
if #available(iOS 15, *) {
|
||||
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) { _ in
|
||||
self.getLimitedLibraryPhotos(invoke)
|
||||
}
|
||||
} else {
|
||||
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController)
|
||||
invoke.resolve([
|
||||
"photos": []
|
||||
])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invoke.resolve([
|
||||
"photos": []
|
||||
])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invoke.unavailable("Not available on iOS 13")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getLimitedLibraryPhotos(_ invoke: Invoke) {
|
||||
if #available(iOS 14, *) {
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { (granted) in
|
||||
if granted == .limited {
|
||||
|
||||
self.invoke = invoke
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let assets = PHAsset.fetchAssets(with: .image, options: nil)
|
||||
var processedImages: [ProcessedImage] = []
|
||||
|
||||
let imageManager = PHImageManager.default()
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .highQualityFormat
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
for index in 0...(assets.count - 1) {
|
||||
let asset = assets.object(at: index)
|
||||
let fullSize = CGSize(width: asset.pixelWidth, height: asset.pixelHeight)
|
||||
|
||||
group.enter()
|
||||
imageManager.requestImage(for: asset, targetSize: fullSize, contentMode: .default, options: options) { image, _ in
|
||||
guard let image = image else {
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
processedImages.append(self.processedImage(from: image, with: asset.imageData))
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .global(qos: .utility)) { [weak self] in
|
||||
self?.returnImages(processedImages)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invoke.resolve([
|
||||
"photos": []
|
||||
])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invoke.unavailable("Not available on iOS 13")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getPhoto(_ invoke: Invoke) {
|
||||
self.multiple = false
|
||||
self.invoke = invoke
|
||||
self.settings = cameraSettings(from: invoke)
|
||||
|
||||
// Make sure they have all the necessary info.plist settings
|
||||
if let missingUsageDescription = checkUsageDescriptions() {
|
||||
Logger.error("[PLUGIN]", "Camera", "-", missingUsageDescription)
|
||||
invoke.reject(missingUsageDescription)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
switch self.settings.source {
|
||||
case .prompt:
|
||||
self.showPrompt()
|
||||
case .camera:
|
||||
self.showCamera()
|
||||
case .photos:
|
||||
self.showPhotos()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func pickImages(_ invoke: Invoke) {
|
||||
self.multiple = true
|
||||
self.invoke = invoke
|
||||
self.settings = cameraSettings(from: invoke)
|
||||
DispatchQueue.main.async {
|
||||
self.showPhotos()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkUsageDescriptions() -> String? {
|
||||
if let dict = Bundle.main.infoDictionary {
|
||||
for key in CameraPropertyListKeys.allCases where dict[key.rawValue] == nil {
|
||||
return key.missingMessage
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cameraSettings(from invoke: Invoke) -> CameraSettings {
|
||||
var settings = CameraSettings()
|
||||
settings.jpegQuality = min(abs(CGFloat(invoke.getFloat("quality") ?? 100.0)) / 100.0, 1.0)
|
||||
settings.allowEditing = invoke.getBool("allowEditing") ?? false
|
||||
settings.source = CameraSource(rawValue: invoke.getString("source") ?? defaultSource.rawValue) ?? defaultSource
|
||||
settings.direction = CameraDirection(rawValue: invoke.getString("direction") ?? defaultDirection.rawValue) ?? defaultDirection
|
||||
if let typeString = invoke.getString("resultType"), let type = CameraResultType(rawValue: typeString) {
|
||||
settings.resultType = type
|
||||
}
|
||||
settings.saveToGallery = invoke.getBool("saveToGallery") ?? false
|
||||
|
||||
// Get the new image dimensions if provided
|
||||
settings.width = CGFloat(invoke.getInt("width") ?? 0)
|
||||
settings.height = CGFloat(invoke.getInt("height") ?? 0)
|
||||
if settings.width > 0 || settings.height > 0 {
|
||||
// We resize only if a dimension was provided
|
||||
settings.shouldResize = true
|
||||
}
|
||||
settings.shouldCorrectOrientation = invoke.getBool("correctOrientation") ?? true
|
||||
settings.userPromptText = CameraPromptText(title: invoke.getString("promptLabelHeader"),
|
||||
photoAction: invoke.getString("promptLabelPhoto"),
|
||||
cameraAction: invoke.getString("promptLabelPicture"),
|
||||
cancelAction: invoke.getString("promptLabelCancel"))
|
||||
if let styleString = invoke.getString("presentationStyle"), styleString == "popover" {
|
||||
settings.presentationStyle = .popover
|
||||
} else {
|
||||
settings.presentationStyle = .fullScreen
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
}
|
||||
|
||||
// public delegate methods
|
||||
extension CameraPlugin: UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIPopoverPresentationControllerDelegate {
|
||||
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
picker.dismiss(animated: true)
|
||||
self.invoke?.reject("User cancelled photos app")
|
||||
}
|
||||
|
||||
public func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) {
|
||||
self.invoke?.reject("User cancelled photos app")
|
||||
}
|
||||
|
||||
public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
self.invoke?.reject("User cancelled photos app")
|
||||
}
|
||||
|
||||
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
picker.dismiss(animated: true) {
|
||||
if let processedImage = self.processImage(from: info) {
|
||||
self.returnProcessedImage(processedImage)
|
||||
} else {
|
||||
self.invoke?.reject("Error processing image")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
extension CameraPlugin: PHPickerViewControllerDelegate {
|
||||
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true, completion: nil)
|
||||
guard let result = results.first else {
|
||||
self.invoke?.reject("User cancelled photos app")
|
||||
return
|
||||
}
|
||||
if multiple {
|
||||
var images: [ProcessedImage] = []
|
||||
var processedCount = 0
|
||||
for img in results {
|
||||
guard img.itemProvider.canLoadObject(ofClass: UIImage.self) else {
|
||||
self.invoke?.reject("Error loading image")
|
||||
return
|
||||
}
|
||||
// extract the image
|
||||
img.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (reading, _) in
|
||||
if let image = reading as? UIImage {
|
||||
var asset: PHAsset?
|
||||
if let assetId = img.assetIdentifier {
|
||||
asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject
|
||||
}
|
||||
if let processedImage = self?.processedImage(from: image, with: asset?.imageData) {
|
||||
images.append(processedImage)
|
||||
}
|
||||
processedCount += 1
|
||||
if processedCount == results.count {
|
||||
self?.returnImages(images)
|
||||
}
|
||||
} else {
|
||||
self?.invoke?.reject("Error loading image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else {
|
||||
self.invoke?.reject("Error loading image")
|
||||
return
|
||||
}
|
||||
// extract the image
|
||||
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (reading, _) in
|
||||
if let image = reading as? UIImage {
|
||||
var asset: PHAsset?
|
||||
if let assetId = result.assetIdentifier {
|
||||
asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject
|
||||
}
|
||||
if var processedImage = self?.processedImage(from: image, with: asset?.imageData) {
|
||||
processedImage.flags = .gallery
|
||||
self?.returnProcessedImage(processedImage)
|
||||
return
|
||||
}
|
||||
}
|
||||
self?.invoke?.reject("Error loading image")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension CameraPlugin {
|
||||
func returnImage(_ processedImage: ProcessedImage, isSaved: Bool) {
|
||||
guard let jpeg = processedImage.generateJPEG(with: settings.jpegQuality) else {
|
||||
self.invoke?.reject("Unable to convert image to jpeg")
|
||||
return
|
||||
}
|
||||
|
||||
if settings.resultType == CameraResultType.uri || multiple {
|
||||
guard let fileURL = try? saveTemporaryImage(jpeg),
|
||||
let webURL = manager.assetUrl(fromLocalURL: fileURL) else {
|
||||
invoke?.reject("Unable to get asset URL to file")
|
||||
return
|
||||
}
|
||||
if self.multiple {
|
||||
invoke?.resolve([
|
||||
"photos": [[
|
||||
"data": fileURL.absoluteString,
|
||||
"exif": processedImage.exifData,
|
||||
"assetUrl": webURL.absoluteString,
|
||||
"format": "jpeg"
|
||||
]]
|
||||
])
|
||||
return
|
||||
}
|
||||
invoke?.resolve([
|
||||
"data": fileURL.absoluteString,
|
||||
"exif": processedImage.exifData,
|
||||
"assetUrl": webURL.absoluteString,
|
||||
"format": "jpeg",
|
||||
"saved": isSaved
|
||||
])
|
||||
} else if settings.resultType == CameraResultType.base64 {
|
||||
self.invoke?.resolve([
|
||||
"data": jpeg.base64EncodedString(),
|
||||
"exif": processedImage.exifData,
|
||||
"format": "jpeg",
|
||||
"saved": isSaved
|
||||
])
|
||||
} else if settings.resultType == CameraResultType.dataURL {
|
||||
invoke?.resolve([
|
||||
"data": "data:image/jpeg;base64," + jpeg.base64EncodedString(),
|
||||
"exif": processedImage.exifData,
|
||||
"format": "jpeg",
|
||||
"saved": isSaved
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func returnImages(_ processedImages: [ProcessedImage]) {
|
||||
var photos: [JsonObject] = []
|
||||
for processedImage in processedImages {
|
||||
guard let jpeg = processedImage.generateJPEG(with: settings.jpegQuality) else {
|
||||
self.invoke?.reject("Unable to convert image to jpeg")
|
||||
return
|
||||
}
|
||||
|
||||
guard let fileURL = try? saveTemporaryImage(jpeg),
|
||||
let webURL = manager.assetUrl(fromLocalURL: fileURL) else {
|
||||
invoke?.reject("Unable to get asset URL to file")
|
||||
return
|
||||
}
|
||||
|
||||
photos.append([
|
||||
"path": fileURL.absoluteString,
|
||||
"exif": processedImage.exifData,
|
||||
"assetUrl": webURL.absoluteString,
|
||||
"format": "jpeg"
|
||||
])
|
||||
}
|
||||
invoke?.resolve([
|
||||
"photos": photos
|
||||
])
|
||||
}
|
||||
|
||||
func returnProcessedImage(_ processedImage: ProcessedImage) {
|
||||
// conditionally save the image
|
||||
if settings.saveToGallery && (processedImage.flags.contains(.edited) == true || processedImage.flags.contains(.gallery) == false) {
|
||||
_ = ImageSaver(image: processedImage.image) { error in
|
||||
var isSaved = false
|
||||
if error == nil {
|
||||
isSaved = true
|
||||
}
|
||||
self.returnImage(processedImage, isSaved: isSaved)
|
||||
}
|
||||
} else {
|
||||
self.returnImage(processedImage, isSaved: false)
|
||||
}
|
||||
}
|
||||
|
||||
func showPrompt() {
|
||||
// Build the action sheet
|
||||
let alert = UIAlertController(title: settings.userPromptText.title, message: nil, preferredStyle: UIAlertController.Style.actionSheet)
|
||||
alert.addAction(UIAlertAction(title: settings.userPromptText.photoAction, style: .default, handler: { [weak self] (_: UIAlertAction) in
|
||||
self?.showPhotos()
|
||||
}))
|
||||
|
||||
alert.addAction(UIAlertAction(title: settings.userPromptText.cameraAction, style: .default, handler: { [weak self] (_: UIAlertAction) in
|
||||
self?.showCamera()
|
||||
}))
|
||||
|
||||
alert.addAction(UIAlertAction(title: settings.userPromptText.cancelAction, style: .cancel, handler: { [weak self] (_: UIAlertAction) in
|
||||
self?.invoke?.reject("User cancelled photos app prompt")
|
||||
}))
|
||||
UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: alert)
|
||||
self.manager.viewController?.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func showCamera() {
|
||||
// check if we have a camera
|
||||
if manager.isSimEnvironment || !UIImagePickerController.isSourceTypeAvailable(UIImagePickerController.SourceType.camera) {
|
||||
Logger.error("[PLUGIN]", "Camera", "-", "Camera not available in simulator")
|
||||
invoke?.reject("Camera not available while running in Simulator")
|
||||
return
|
||||
}
|
||||
// check for permission
|
||||
let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
if authStatus == .restricted || authStatus == .denied {
|
||||
invoke?.reject("User denied access to camera")
|
||||
return
|
||||
}
|
||||
// we either already have permission or can prompt
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
if granted {
|
||||
DispatchQueue.main.async {
|
||||
self?.presentCameraPicker()
|
||||
}
|
||||
} else {
|
||||
self?.invoke?.reject("User denied access to camera")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showPhotos() {
|
||||
// check for permission
|
||||
let authStatus = PHPhotoLibrary.authorizationStatus()
|
||||
if authStatus == .restricted || authStatus == .denied {
|
||||
invoke?.reject("User denied access to photos")
|
||||
return
|
||||
}
|
||||
// we either already have permission or can prompt
|
||||
if authStatus == .authorized {
|
||||
presentSystemAppropriateImagePicker()
|
||||
} else {
|
||||
PHPhotoLibrary.requestAuthorization({ [weak self] (status) in
|
||||
if status == PHAuthorizationStatus.authorized {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.presentSystemAppropriateImagePicker()
|
||||
}
|
||||
} else {
|
||||
self?.invoke?.reject("User denied access to photos")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func presentCameraPicker() {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = self
|
||||
picker.allowsEditing = self.settings.allowEditing
|
||||
// select the input
|
||||
picker.sourceType = .camera
|
||||
if settings.direction == .rear, UIImagePickerController.isCameraDeviceAvailable(.rear) {
|
||||
picker.cameraDevice = .rear
|
||||
} else if settings.direction == .front, UIImagePickerController.isCameraDeviceAvailable(.front) {
|
||||
picker.cameraDevice = .front
|
||||
}
|
||||
// present
|
||||
picker.modalPresentationStyle = settings.presentationStyle
|
||||
if settings.presentationStyle == .popover {
|
||||
picker.popoverPresentationController?.delegate = self
|
||||
UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: picker)
|
||||
}
|
||||
manager.viewController?.present(picker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func presentSystemAppropriateImagePicker() {
|
||||
if #available(iOS 14, *) {
|
||||
presentPhotoPicker()
|
||||
} else {
|
||||
presentImagePicker()
|
||||
}
|
||||
}
|
||||
|
||||
func presentImagePicker() {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = self
|
||||
picker.allowsEditing = self.settings.allowEditing
|
||||
// select the input
|
||||
picker.sourceType = .photoLibrary
|
||||
// present
|
||||
picker.modalPresentationStyle = settings.presentationStyle
|
||||
if settings.presentationStyle == .popover {
|
||||
picker.popoverPresentationController?.delegate = self
|
||||
UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: picker)
|
||||
}
|
||||
manager.viewController?.present(picker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
func presentPhotoPicker() {
|
||||
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
|
||||
configuration.selectionLimit = self.multiple ? (self.invoke?.getInt("limit") ?? 0) : 1
|
||||
configuration.filter = .images
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
// present
|
||||
picker.modalPresentationStyle = settings.presentationStyle
|
||||
if settings.presentationStyle == .popover {
|
||||
picker.popoverPresentationController?.delegate = self
|
||||
UIUtils.centerPopover(rootViewController: manager.viewController, popoverController: picker)
|
||||
}
|
||||
manager.viewController?.present(picker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func saveTemporaryImage(_ data: Data) throws -> URL {
|
||||
var url: URL
|
||||
repeat {
|
||||
imageCounter += 1
|
||||
url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("photo-\(imageCounter).jpg")
|
||||
} while FileManager.default.fileExists(atPath: url.path)
|
||||
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
func processImage(from info: [UIImagePickerController.InfoKey: Any]) -> ProcessedImage? {
|
||||
var selectedImage: UIImage?
|
||||
var flags: PhotoFlags = []
|
||||
// get the image
|
||||
if let edited = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
|
||||
selectedImage = edited // use the edited version
|
||||
flags = flags.union([.edited])
|
||||
} else if let original = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
|
||||
selectedImage = original // use the original version
|
||||
}
|
||||
guard let image = selectedImage else {
|
||||
return nil
|
||||
}
|
||||
var metadata: [String: Any] = [:]
|
||||
// get the image's metadata from the picker or from the photo album
|
||||
if let photoMetadata = info[UIImagePickerController.InfoKey.mediaMetadata] as? [String: Any] {
|
||||
metadata = photoMetadata
|
||||
} else {
|
||||
flags = flags.union([.gallery])
|
||||
}
|
||||
if let asset = info[UIImagePickerController.InfoKey.phAsset] as? PHAsset {
|
||||
metadata = asset.imageData
|
||||
}
|
||||
// get the result
|
||||
var result = processedImage(from: image, with: metadata)
|
||||
result.flags = flags
|
||||
return result
|
||||
}
|
||||
|
||||
func processedImage(from image: UIImage, with metadata: [String: Any]?) -> ProcessedImage {
|
||||
var result = ProcessedImage(image: image, metadata: metadata ?? [:])
|
||||
// resizing the image only makes sense if we have real values to which to constrain it
|
||||
if settings.shouldResize, settings.width > 0 || settings.height > 0 {
|
||||
result.image = result.image.reformat(to: CGSize(width: settings.width, height: settings.height))
|
||||
result.overwriteMetadataOrientation(to: 1)
|
||||
} else if settings.shouldCorrectOrientation {
|
||||
// resizing implicitly reformats the image so this is only needed if we aren't resizing
|
||||
result.image = result.image.reformat()
|
||||
result.overwriteMetadataOrientation(to: 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_camera")
|
||||
func initCameraPlugin(webview: WKWebView?) {
|
||||
Tauri.registerPlugin(webview: webview, name: "camera", plugin: CameraPlugin())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
public enum CameraSource: String {
|
||||
case prompt = "PROMPT"
|
||||
case camera = "CAMERA"
|
||||
case photos = "PHOTOS"
|
||||
}
|
||||
|
||||
public enum CameraDirection: String {
|
||||
case rear = "REAR"
|
||||
case front = "FRONT"
|
||||
}
|
||||
|
||||
public enum CameraResultType: String {
|
||||
case base64
|
||||
case uri
|
||||
case dataURL = "dataUrl"
|
||||
}
|
||||
|
||||
struct CameraPromptText {
|
||||
let title: String
|
||||
let photoAction: String
|
||||
let cameraAction: String
|
||||
let cancelAction: String
|
||||
|
||||
init(title: String? = nil, photoAction: String? = nil, cameraAction: String? = nil, cancelAction: String? = nil) {
|
||||
self.title = title ?? "Photo"
|
||||
self.photoAction = photoAction ?? "From Photos"
|
||||
self.cameraAction = cameraAction ?? "Take Picture"
|
||||
self.cancelAction = cancelAction ?? "Cancel"
|
||||
}
|
||||
}
|
||||
|
||||
public struct CameraSettings {
|
||||
var source: CameraSource = CameraSource.prompt
|
||||
var direction: CameraDirection = CameraDirection.rear
|
||||
var resultType = CameraResultType.base64
|
||||
var userPromptText = CameraPromptText()
|
||||
var jpegQuality: CGFloat = 1.0
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
var allowEditing = false
|
||||
var shouldResize = false
|
||||
var shouldCorrectOrientation = true
|
||||
var saveToGallery = false
|
||||
var presentationStyle = UIModalPresentationStyle.fullScreen
|
||||
}
|
||||
|
||||
public struct CameraResult {
|
||||
let image: UIImage?
|
||||
let metadata: [AnyHashable: Any]
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
internal enum CameraPermissionType: String, CaseIterable {
|
||||
case camera
|
||||
case photos
|
||||
}
|
||||
|
||||
internal enum CameraPropertyListKeys: String, CaseIterable {
|
||||
case photoLibraryAddUsage = "NSPhotoLibraryAddUsageDescription"
|
||||
case photoLibraryUsage = "NSPhotoLibraryUsageDescription"
|
||||
case cameraUsage = "NSCameraUsageDescription"
|
||||
|
||||
var link: String {
|
||||
switch self {
|
||||
case .photoLibraryAddUsage:
|
||||
return "https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251-SW73"
|
||||
case .photoLibraryUsage:
|
||||
return "https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251-SW17"
|
||||
case .cameraUsage:
|
||||
return "https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251-SW24"
|
||||
}
|
||||
}
|
||||
|
||||
var missingMessage: String {
|
||||
return "You are missing \(self.rawValue) in your Info.plist file." +
|
||||
" Camera will not function without it. Learn more: \(self.link)"
|
||||
}
|
||||
}
|
||||
|
||||
internal struct PhotoFlags: OptionSet {
|
||||
let rawValue: Int
|
||||
|
||||
static let edited = PhotoFlags(rawValue: 1 << 0)
|
||||
static let gallery = PhotoFlags(rawValue: 1 << 1)
|
||||
|
||||
static let all: PhotoFlags = [.edited, .gallery]
|
||||
}
|
||||
|
||||
internal struct ProcessedImage {
|
||||
var image: UIImage
|
||||
var metadata: [String: Any]
|
||||
var flags: PhotoFlags = []
|
||||
|
||||
var exifData: [String: Any] {
|
||||
var exifData = metadata["{Exif}"] as? [String: Any]
|
||||
exifData?["Orientation"] = metadata["Orientation"]
|
||||
exifData?["GPS"] = metadata["{GPS}"]
|
||||
return exifData ?? [:]
|
||||
}
|
||||
|
||||
mutating func overwriteMetadataOrientation(to orientation: Int) {
|
||||
replaceDictionaryOrientation(atNode: &metadata, to: orientation)
|
||||
}
|
||||
|
||||
func replaceDictionaryOrientation(atNode node: inout [String: Any], to orientation: Int) {
|
||||
for key in node.keys {
|
||||
if key == "Orientation", (node[key] as? Int) != nil {
|
||||
node[key] = orientation
|
||||
} else if var child = node[key] as? [String: Any] {
|
||||
replaceDictionaryOrientation(atNode: &child, to: orientation)
|
||||
node[key] = child
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateJPEG(with quality: CGFloat) -> Data? {
|
||||
// convert the UIImage to a jpeg
|
||||
guard let data = self.image.jpegData(compressionQuality: quality) else {
|
||||
return nil
|
||||
}
|
||||
// define our jpeg data as an image source and get its type
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil), let type = CGImageSourceGetType(source) else {
|
||||
return data
|
||||
}
|
||||
// allocate an output buffer and create the destination to receive the new data
|
||||
guard let output = NSMutableData(capacity: data.count), let destination = CGImageDestinationCreateWithData(output, type, 1, nil) else {
|
||||
return data
|
||||
}
|
||||
// pipe the source into the destination while overwriting the metadata, this encodes the metadata information into the image
|
||||
CGImageDestinationAddImageFromSource(destination, source, 0, self.metadata as CFDictionary)
|
||||
// finish
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return data
|
||||
}
|
||||
return output as Data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import UIKit
|
||||
|
||||
class ImageSaver: NSObject {
|
||||
|
||||
var onResult: ((Error?) -> Void) = {_ in }
|
||||
|
||||
init(image: UIImage, onResult:@escaping ((Error?) -> Void)) {
|
||||
self.onResult = onResult
|
||||
super.init()
|
||||
UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveResult), nil)
|
||||
}
|
||||
|
||||
@objc func saveResult(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
|
||||
if let error = error {
|
||||
onResult(error)
|
||||
} else {
|
||||
onResult(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import ExamplePlugin
|
||||
|
||||
final class ExamplePluginTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
let plugin = ExamplePlugin()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user