feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,32 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoImagePicker'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platform = :ios, '13.4'
s.swift_version = '5.4'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
s.source_files = "**/*.h"
s.vendored_frameworks = "#{s.name}.xcframework"
else
s.source_files = "**/*.{h,m,swift}"
end
end

View File

@@ -0,0 +1,135 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal class PermissionsModuleNotFoundException: Exception {
override var reason: String {
"Permissions module not found. Are you sure that Expo modules are properly linked?"
}
}
internal class FileSystemModuleNotFoundException: Exception {
override var reason: String {
"FileSystem module not found. Are you sure that Expo modules are properly linked?"
}
}
internal class LoggerModuleNotFoundException: Exception {
override var reason: String {
"Logger module not found. Are you sure that Expo modules are properly linked?"
}
}
internal class MissingCameraPermissionException: Exception {
override var reason: String {
"Missing camera or camera roll permission"
}
}
internal class MissingMicrophonePermissionException: Exception {
override var reason: String {
"Missing microphone permission. Please enable it with the `expo-image-picker` config plugin"
}
}
internal class MissingPhotoLibraryPermissionException: Exception {
override var reason: String {
"Missing photo library permission"
}
}
internal class CameraUnavailableOnSimulatorException: Exception {
override var reason: String {
"Camera not available on simulator"
}
}
internal class MultiselectUnavailableException: Exception {
override var reason: String {
"Multiple selection is only available on iOS 14+"
}
}
internal class MissingCurrentViewControllerException: Exception {
override var reason: String {
"Cannot determine currently presented view controller"
}
}
internal class MaxDurationWhileEditingExceededException: Exception {
override var reason: String {
"'videoMaxDuration' limits to 600 when 'allowsEditing=true'"
}
}
internal class InvalidMediaTypeException: GenericException<String?> {
override var reason: String {
"Cannot handle '\(param ?? "nil")' media type"
}
}
internal class FailedToCreateGifException: Exception {
override var reason: String {
"Failed to create image destination for GIF export"
}
}
internal class FailedToExportGifException: Exception {
override var reason: String {
"Failed to export requested GIF"
}
}
internal class FailedToWriteImageException: Exception {
override var reason: String {
"Failed to write data to a file"
}
}
internal class FailedToReadImageException: Exception {
override var reason: String {
"Failed to read picked image"
}
}
internal class FailedToReadImageDataException: Exception {
override var reason: String {
"Failed to read data from a file"
}
}
internal class FailedToReadVideoSizeException: Exception {
override var reason: String {
"Failed to read the video size"
}
}
internal class FailedToReadVideoException: Exception {
override var reason: String {
"Failed to read picked video"
}
}
internal class FailedToTranscodeVideoException: Exception {
override var reason: String {
"Failed to transcode picked video"
}
}
internal class UnsupportedVideoExportPresetException: GenericException<String> {
override var reason: String {
"Video cannot be transcoded with export preset: \(param)"
}
}
internal class FailedToPickVideoException: Exception {
override var reason: String {
"Video could not be picked"
}
}
internal class FailedToReadImageDataForBase64Exception: Exception {
override var reason: String {
"Failed to read image data to perform base64 encoding"
}
}

View File

@@ -0,0 +1,121 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import PhotosUI
/**
Protocol that describes scenarios we care about while the user is picking media.
*/
protocol OnMediaPickingResultHandler {
@available(iOS 14, *)
func didPickMultipleMedia(selection: [PHPickerResult])
func didPickMedia(mediaInfo: MediaInfo)
func didCancelPicking()
}
/**
This class is responsible for responding to any events that are happening in `UIImagePickerController`.
It then forwards them back in unified way via `OnMediaPickingResultHandler`.
The functionality of this delegate is separated from the main module class for two reasons:
1) main module cannot inherit from `NSObject` (and that's required by three protocols we must conform to),
because it already inherits from `Module` class and Swift language does not allow multiple inheritance,
2) it separates some logic from the main module class and hopefully makes it cleaner.
*/
internal class ImagePickerHandler: NSObject,
PHPickerViewControllerDelegate,
UINavigationControllerDelegate,
UIImagePickerControllerDelegate,
UIAdaptivePresentationControllerDelegate {
private let onMediaPickingResultHandler: OnMediaPickingResultHandler
private let hideStatusBarWhenPresented: Bool
private var statusBarVisibilityController = StatusBarVisibilityController()
init(onMediaPickingResultHandler: OnMediaPickingResultHandler, hideStatusBarWhenPresented: Bool) {
self.onMediaPickingResultHandler = onMediaPickingResultHandler
self.hideStatusBarWhenPresented = hideStatusBarWhenPresented
}
private func handlePickedMedia(mediaInfo: MediaInfo) {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didPickMedia(mediaInfo: mediaInfo)
}
@available(iOS 14, *)
private func handlePickedMedia(selection: [PHPickerResult]) {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didPickMultipleMedia(selection: selection)
}
private func handlePickingCancellation() {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didCancelPicking()
}
// MARK: - UIImagePickerControllerDelegate
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: MediaInfo) {
DispatchQueue.main.async {
picker.dismiss(animated: true) { [weak self] in
self?.handlePickedMedia(mediaInfo: info)
}
}
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
DispatchQueue.main.async {
picker.dismiss(animated: true) { [weak self] in
self?.handlePickingCancellation()
}
}
}
// MARK: - PHPickerViewControllerDelegate
@available(iOS 14, *)
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
DispatchQueue.main.async {
picker.dismiss(animated: true) { [weak self] in
// The PHPickerViewController returns empty collection when canceled
if results.isEmpty {
self?.handlePickingCancellation()
} else {
self?.handlePickedMedia(selection: results)
}
}
}
}
// MARK: - UIAdaptivePresentationControllerDelegate
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
handlePickingCancellation()
}
// MARK: - UINavigationControllerDelegate
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
statusBarVisibilityController.maybePreserveVisibilityAndHideStatusBar(hideStatusBarWhenPresented)
}
}
/**
Protocol that is a common type for supported picker controllers.
*/
internal protocol PickerUIController: UIViewController {
func setResultHandler(_ handler: ImagePickerHandler)
}
extension UIImagePickerController: PickerUIController {
func setResultHandler(_ handler: ImagePickerHandler) {
self.delegate = handler
self.presentationController?.delegate = handler
}
}
@available(iOS 14, *)
extension PHPickerViewController: PickerUIController {
func setResultHandler(_ handler: ImagePickerHandler) {
self.delegate = handler
self.presentationController?.delegate = handler
}
}

View File

@@ -0,0 +1,253 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import UIKit
import PhotosUI
import ExpoModulesCore
typealias MediaInfo = [UIImagePickerController.InfoKey: Any]
/**
Helper struct storing single picking operation context variables that have their own non-sharable state.
*/
struct PickingContext {
let promise: Promise
let options: ImagePickerOptions
let imagePickerHandler: ImagePickerHandler
}
enum OperationType {
case ask
case get
}
public class ImagePickerModule: Module, OnMediaPickingResultHandler {
public func definition() -> ModuleDefinition {
// TODO: (@bbarthec) change to "ExpoImagePicker" and propagate to other platforms
Name("ExponentImagePicker")
OnCreate {
self.appContext?.permissions?.register([
CameraPermissionRequester(),
MediaLibraryPermissionRequester(),
MediaLibraryWriteOnlyPermissionRequester()
])
}
AsyncFunction("getCameraPermissionsAsync", { (promise: Promise) in
self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .get, promise: promise)
})
AsyncFunction("getMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .get, promise: promise)
})
AsyncFunction("requestCameraPermissionsAsync", { (promise: Promise) in
self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .ask, promise: promise)
})
AsyncFunction("requestMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .ask, promise: promise)
})
AsyncFunction("launchCameraAsync", { (options: ImagePickerOptions, promise: Promise) -> Void in
guard let permissions = self.appContext?.permissions else {
return promise.reject(PermissionsModuleNotFoundException())
}
guard permissions.hasGrantedPermission(usingRequesterClass: CameraPermissionRequester.self) else {
return promise.reject(MissingCameraPermissionException())
}
self.launchImagePicker(sourceType: .camera, options: options, promise: promise)
})
.runOnQueue(DispatchQueue.main)
AsyncFunction("launchImageLibraryAsync", { (options: ImagePickerOptions, promise: Promise) in
self.launchImagePicker(sourceType: .photoLibrary, options: options, promise: promise)
})
.runOnQueue(DispatchQueue.main)
}
private var currentPickingContext: PickingContext?
private func handlePermissionRequest(requesterClass: AnyClass, operationType: OperationType, promise: Promise) {
guard let permissions = self.appContext?.permissions else {
return promise.reject(PermissionsModuleNotFoundException())
}
switch operationType {
case .get: permissions.getPermissionUsingRequesterClass(requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
case .ask: permissions.askForPermission(usingRequesterClass: requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
}
}
private func getMediaLibraryPermissionRequester(_ writeOnly: Bool) -> AnyClass {
return writeOnly ? MediaLibraryWriteOnlyPermissionRequester.self : MediaLibraryPermissionRequester.self
}
private func launchImagePicker(sourceType: UIImagePickerController.SourceType, options: ImagePickerOptions, promise: Promise) {
let imagePickerDelegate = ImagePickerHandler(onMediaPickingResultHandler: self, hideStatusBarWhenPresented: options.allowsEditing && !options.allowsMultipleSelection)
let pickingContext = PickingContext(promise: promise,
options: options,
imagePickerHandler: imagePickerDelegate)
if #available(iOS 14, *), !options.allowsEditing && sourceType != .camera {
self.launchMultiSelectPicker(pickingContext: pickingContext)
} else {
self.launchLegacyImagePicker(sourceType: sourceType, pickingContext: pickingContext)
}
}
private func launchLegacyImagePicker(sourceType: UIImagePickerController.SourceType, pickingContext: PickingContext) {
let options = pickingContext.options
let picker = UIImagePickerController()
picker.fixCannotMoveEditingBox()
if sourceType == .camera {
#if targetEnvironment(simulator)
return pickingContext.promise.reject(CameraUnavailableOnSimulatorException())
#else
picker.sourceType = .camera
picker.cameraDevice = options.cameraType == .front ? .front : .rear
#endif
}
if sourceType == .photoLibrary {
picker.sourceType = .photoLibrary
}
picker.mediaTypes = options.mediaTypes.toArray()
if options.mediaTypes.requiresMicrophonePermission() && sourceType == .camera {
do {
try checkMicrophonePermissions()
} catch {
pickingContext.promise.reject(error)
return
}
}
picker.videoExportPreset = options.videoExportPreset.toAVAssetExportPreset()
picker.videoQuality = options.videoQuality.toQualityType()
picker.videoMaximumDuration = options.videoMaxDuration
if options.allowsEditing {
picker.allowsEditing = options.allowsEditing
if options.videoMaxDuration > 600 {
return pickingContext.promise.reject(MaxDurationWhileEditingExceededException())
}
if options.videoMaxDuration == 0 {
picker.videoMaximumDuration = 600.0
}
}
presentPickerUI(picker, pickingContext: pickingContext)
}
private func checkMicrophonePermissions() throws {
guard Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") != nil else {
throw MissingMicrophonePermissionException()
}
}
@available(iOS 14, *)
private func launchMultiSelectPicker(pickingContext: PickingContext) {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
let options = pickingContext.options
// selection limit = 1 --> single selection, reflects the old picker behavior
configuration.selectionLimit = options.allowsMultipleSelection ? options.selectionLimit : SINGLE_SELECTION
configuration.filter = options.mediaTypes.toPickerFilter()
if #available(iOS 14, *) {
configuration.preferredAssetRepresentationMode = options.preferredAssetRepresentationMode.toAssetRepresentationMode()
}
if #available(iOS 15, *) {
configuration.selection = options.orderedSelection ? .ordered : .default
}
let picker = PHPickerViewController(configuration: configuration)
presentPickerUI(picker, pickingContext: pickingContext)
}
private func presentPickerUI(_ picker: PickerUIController, pickingContext context: PickingContext) {
guard let currentViewController = self.appContext?.utilities?.currentViewController() else {
return context.promise.reject(MissingCurrentViewControllerException())
}
picker.modalPresentationStyle = context.options.presentationStyle.toPresentationStyle()
if UIDevice.current.userInterfaceIdiom == .pad {
let viewFrame = currentViewController.view.frame
picker.popoverPresentationController?.sourceRect = CGRect(
x: viewFrame.midX,
y: viewFrame.maxY,
width: 0,
height: 0
)
picker.popoverPresentationController?.sourceView = currentViewController.view
}
picker.setResultHandler(context.imagePickerHandler)
// Store picking context as we're navigating to the different view controller (starting asynchronous flow)
self.currentPickingContext = context
currentViewController.present(picker, animated: true, completion: nil)
}
// MARK: - OnMediaPickingResultHandler
func didCancelPicking() {
self.currentPickingContext?.promise.resolve(ImagePickerResponse(assets: nil, canceled: true))
self.currentPickingContext = nil
}
@available(iOS 14, *)
func didPickMultipleMedia(selection: [PHPickerResult]) {
guard let options = self.currentPickingContext?.options,
let promise = self.currentPickingContext?.promise else {
log.error("Picking operation context has been lost.")
return
}
guard let fileSystem = self.appContext?.fileSystem else {
return promise.reject(FileSystemModuleNotFoundException())
}
let mediaHandler = MediaHandler(fileSystem: fileSystem,
options: options)
// Clean up the currently stored picking context
self.currentPickingContext = nil
mediaHandler.handleMultipleMedia(selection) { result -> Void in
switch result {
case .failure(let error): return promise.reject(error)
case .success(let response): return promise.resolve(response)
}
}
}
func didPickMedia(mediaInfo: MediaInfo) {
guard let options = self.currentPickingContext?.options,
let promise = self.currentPickingContext?.promise else {
log.error("Picking operation context has been lost.")
return
}
guard let fileSystem = self.appContext?.fileSystem else {
return promise.reject(FileSystemModuleNotFoundException())
}
// Clean up the currently stored picking context
self.currentPickingContext = nil
let mediaHandler = MediaHandler(fileSystem: fileSystem,
options: options)
mediaHandler.handleMedia(mediaInfo) { result -> Void in
switch result {
case .failure(let error): return promise.reject(error)
case .success(let response): return promise.resolve(response)
}
}
}
}

View File

@@ -0,0 +1,228 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
import MobileCoreServices
import PhotosUI
internal let DEFAULT_QUALITY = 0.2
internal let MAXIMUM_QUALITY = 1.0
internal let UNLIMITED_SELECTION = 0
internal let SINGLE_SELECTION = 1
internal struct ImagePickerOptions: Record {
@Field
var allowsEditing: Bool = false
@Field
var aspect: [Double]
@Field
var quality: Double?
@Field
var mediaTypes: MediaType = .images
@Field
var exif: Bool
@Field
var base64: Bool = false
@Field
var videoExportPreset: VideoExportPreset = .passthrough
@Field
var videoQuality: VideoQuality = .typeHigh
@Field
var videoMaxDuration: Double = 0
@Field
var presentationStyle: PresentationStyle = .automatic
@Field
var preferredAssetRepresentationMode: PreferredAssetRepresentationMode = .automatic
@Field
var cameraType: CameraType = .back
@Field
var allowsMultipleSelection: Bool = false
@Field
var selectionLimit: Int = UNLIMITED_SELECTION
@Field
var orderedSelection: Bool = false
}
internal enum PresentationStyle: String, EnumArgument {
case fullScreen
case pageSheet
case formSheet
case currentContext
case overFullScreen
case overCurrentContext
case popover
case none
case automatic
func toPresentationStyle() -> UIModalPresentationStyle {
switch self {
case .fullScreen:
return .fullScreen
case .pageSheet:
return .pageSheet
case .formSheet:
return .formSheet
case .currentContext:
return .currentContext
case .overFullScreen:
return .overFullScreen
case .overCurrentContext:
return .overCurrentContext
case .popover:
return .popover
case .none:
return .none
case .automatic:
if #available(iOS 13.0, *) {
return .automatic
}
// default prior iOS 13
return .fullScreen
}
}
}
internal enum PreferredAssetRepresentationMode: String, EnumArgument {
case automatic
case compatible
case current
@available(iOS 14.0, *)
func toAssetRepresentationMode() -> PHPickerConfiguration.AssetRepresentationMode {
switch self {
case .automatic:
return .automatic
case .compatible:
return .compatible
case .current:
return .current
}
}
}
internal enum VideoQuality: Int, EnumArgument {
case typeHigh = 0
case typeMedium = 1
case typeLow = 2
case type640x480 = 3
case typeIFrame1280x720 = 4
case typeIFrame960x540 = 5
func toQualityType() -> UIImagePickerController.QualityType {
switch self {
case .typeHigh:
return .typeHigh
case .typeMedium:
return .typeMedium
case .typeLow:
return .typeLow
case .type640x480:
return .type640x480
case .typeIFrame1280x720:
return .typeIFrame1280x720
case .typeIFrame960x540:
return .typeIFrame960x540
}
}
}
internal enum MediaType: String, EnumArgument {
case all = "All"
case videos = "Videos"
case images = "Images"
func toArray() -> [String] {
switch self {
case .images:
return [kUTTypeImage as String]
case .videos:
return [kUTTypeMovie as String]
case .all:
return [kUTTypeImage as String, kUTTypeMovie as String]
}
}
func requiresMicrophonePermission() -> Bool {
switch self {
case .images:
return false
case .videos:
return true
case .all:
return true
}
}
@available(iOS 14, *)
func toPickerFilter() -> PHPickerFilter {
// TODO: (barthap) Maybe add support for live photos
switch self {
case .images:
return .images
case .videos:
return .videos
case .all:
return .any(of: [.images, .videos])
}
}
}
internal enum VideoExportPreset: Int, EnumArgument {
case passthrough = 0
case lowQuality = 1
case mediumQuality = 2
case highestQuality = 3
case h264_640x480 = 4
case h264_960x540 = 5
case h264_1280x720 = 6
case h264_1920x1080 = 7
case h264_3840x2160 = 8
case hevc_1920x1080 = 9
case hevc_3840_2160 = 10
func toAVAssetExportPreset() -> String {
switch self {
case .passthrough:
return AVAssetExportPresetPassthrough
case .lowQuality:
return AVAssetExportPresetLowQuality
case .mediumQuality:
return AVAssetExportPresetMediumQuality
case .highestQuality:
return AVAssetExportPresetHighestQuality
case .h264_640x480:
return AVAssetExportPreset640x480
case .h264_960x540:
return AVAssetExportPreset960x540
case .h264_1280x720:
return AVAssetExportPreset1280x720
case .h264_1920x1080:
return AVAssetExportPreset1920x1080
case .h264_3840x2160:
return AVAssetExportPreset3840x2160
case .hevc_1920x1080:
return AVAssetExportPresetHEVC1920x1080
case .hevc_3840_2160:
return AVAssetExportPresetHEVC3840x2160
}
}
}
internal enum CameraType: String, EnumArgument {
case back
case front
}

View File

@@ -0,0 +1,136 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import Photos
import ExpoModulesCore
public class CameraPermissionRequester: NSObject, EXPermissionsRequester {
static public func permissionType() -> String {
return "camera"
}
public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock) {
AVCaptureDevice.requestAccess(for: AVMediaType.video) { [weak self] _ in
resolve(self?.getPermissions())
}
}
public func getPermissions() -> [AnyHashable: Any] {
var systemStatus: AVAuthorizationStatus
var status: EXPermissionStatus
let cameraUsageDescription = Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription")
if cameraUsageDescription == nil {
EXFatal(EXErrorWithMessage("""
This app is missing 'NSCameraUsageDescription', video services will fail. \
Ensure this key exists in the app's Info.plist
"""))
systemStatus = AVAuthorizationStatus.denied
} else {
systemStatus = AVCaptureDevice.authorizationStatus(for: AVMediaType.video)
}
switch systemStatus {
case .authorized:
status = EXPermissionStatusGranted
case .restricted,
.denied:
status = EXPermissionStatusDenied
case .notDetermined:
fallthrough
@unknown default:
status = EXPermissionStatusUndetermined
}
return [
"status": status.rawValue
]
}
}
public class MediaLibraryPermissionRequester: DefaultMediaLibraryPermissionRequester,
EXPermissionsRequester {
public static func permissionType() -> String {
return "mediaLibrary"
}
}
public class MediaLibraryWriteOnlyPermissionRequester: DefaultMediaLibraryPermissionRequester,
EXPermissionsRequester {
public static func permissionType() -> String {
return "mediaLibraryWriteOnly"
}
@available(iOS 14, *)
override internal func accessLevel() -> PHAccessLevel {
return PHAccessLevel.addOnly
}
}
// MARK: - Permission requesters shared implementation extracted to an extension (mixin pattern)
/**
* Dummy class just to prevent extending NSObject publicly/globally.
*/
public class DefaultMediaLibraryPermissionRequester: NSObject {}
/**
* This extension is adding default implmentation for EXPermissionsRequester that can be shared by many classe.
* In Swift language you cannot override static methods in subclasses, so you cannot subclass any already implemented
* PermissionRequester as instances of this class are registered by the unique name coming from `static func permissionType()`.
* To prevent repeating the similar code for every MediaLibrary PermissionRequester (the only differences so far are
* aforementioned permissionType and accessLevel, while the latter can be easily overritten) I've extracted the code
* to this extension. I'm using as a mixin that implements major part of EXPermissionsRequester protocol.
*/
extension DefaultMediaLibraryPermissionRequester {
@objc
public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock) {
let authorizationHandler = { [weak self] (_: PHAuthorizationStatus) in
resolve(self?.getPermissions())
}
if #available(iOS 14.0, *) {
PHPhotoLibrary.requestAuthorization(for: self.accessLevel(), handler: authorizationHandler)
} else {
PHPhotoLibrary.requestAuthorization(authorizationHandler)
}
}
@objc
public func getPermissions() -> [AnyHashable: Any] {
var authorizationStatus: PHAuthorizationStatus
if #available(iOS 14.0, *) {
authorizationStatus = PHPhotoLibrary.authorizationStatus(for: self.accessLevel())
} else {
authorizationStatus = PHPhotoLibrary.authorizationStatus()
}
var status: EXPermissionStatus
var scope: String
switch authorizationStatus {
case .authorized:
status = EXPermissionStatusGranted
scope = "all"
case .limited:
status = EXPermissionStatusGranted
scope = "limited"
case .denied, .restricted:
status = EXPermissionStatusDenied
scope = "none"
case .notDetermined:
fallthrough
@unknown default:
status = EXPermissionStatusUndetermined
scope = "none"
}
return [
"status": status.rawValue,
"accessPrivileges": scope
]
}
@available(iOS 14, *)
@objc
internal func accessLevel() -> PHAccessLevel {
return PHAccessLevel.readWrite
}
}

View File

@@ -0,0 +1,42 @@
// Copyright 2022-present 650 Industries. All rights reserved.
// swiftlint:disable redundant_optional_initialization
// Unfortunately, property wrappers must be initialized in those records, otherwise the memberwise initializer
// would require `Field<FieldType?>` as an argument instead of `FieldType?`.
// TODO: (@tsapeta) Figure out if we can fix that
import ExpoModulesCore
internal typealias ImagePickerResult = Result<ImagePickerResponse, Exception>
internal typealias SelectedMediaResult = Result<AssetInfo, Exception>
/**
Convenience alias, a dictionary representing EXIF data
*/
internal typealias ExifInfo = [String: Any]
/**
Represents a picker response.
*/
internal struct ImagePickerResponse: Record {
@Field var assets: [AssetInfo]? = nil
@Field var canceled: Bool = true
}
/**
Represents a single asset (image or video).
*/
internal struct AssetInfo: Record {
@Field var assetId: String? = nil
@Field var type: String = "image"
@Field var uri: String = ""
@Field var width: Double = 0
@Field var height: Double = 0
@Field var fileName: String? = nil
@Field var fileSize: Int? = nil
@Field var mimeType: String? = nil
@Field var base64: String? = nil
@Field var exif: ExifInfo? = nil
@Field var duration: Double? = nil
}

View File

@@ -0,0 +1,711 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
import MobileCoreServices
import Photos
import PhotosUI
internal struct MediaHandler {
internal weak var fileSystem: EXFileSystemInterface?
internal let options: ImagePickerOptions
internal func handleMedia(_ mediaInfo: MediaInfo, completion: @escaping (ImagePickerResult) -> Void) {
let mediaType: String? = mediaInfo[UIImagePickerController.InfoKey.mediaType] as? String
let imageType = kUTTypeImage as String
let videoType = kUTTypeMovie as String
switch mediaType {
case imageType: return handleImage(mediaInfo: mediaInfo, completion: completion)
case videoType: return handleVideo(mediaInfo: mediaInfo, completion: completion)
default: return completion(.failure(InvalidMediaTypeException(mediaType)))
}
}
@available(iOS 14, *)
internal func handleMultipleMedia(_ selection: [PHPickerResult], completion: @escaping (ImagePickerResult) -> Void) {
var results = [AssetInfo?](repeating: nil, count: selection.count)
let dispatchGroup = DispatchGroup()
let dispatchQueue = DispatchQueue(label: "expo.imagepicker.multipleMediaHandler")
let resultHandler = { (index: Int, result: SelectedMediaResult) -> Void in
switch result {
case .failure(let exception):
return completion(.failure(exception))
case .success(let mediaInfo):
dispatchQueue.async {
results[index] = mediaInfo
dispatchGroup.leave()
}
}
}
for (index, selectedItem) in selection.enumerated() {
let itemProvider = selectedItem.itemProvider
dispatchGroup.enter()
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
handleImage(from: selectedItem, atIndex: index, completion: resultHandler)
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
handleVideo(from: selectedItem, atIndex: index, completion: resultHandler)
} else {
completion(.failure(InvalidMediaTypeException(itemProvider.registeredTypeIdentifiers.first)))
}
}
dispatchGroup.notify(queue: .main) {
completion(.success(
ImagePickerResponse(assets: results.compactMap({ $0 }), canceled: false)
))
}
}
// MARK: - Image
// TODO: convert to async/await syntax once we drop support for iOS 12
private func handleImage(mediaInfo: MediaInfo, completion: @escaping (ImagePickerResult) -> Void) {
do {
guard let image = ImageUtils.readImageFrom(mediaInfo: mediaInfo, shouldReadCroppedImage: options.allowsEditing) else {
return completion(.failure(FailedToReadImageException()))
}
let (imageData, fileExtension) = try ImageUtils.readDataAndFileExtension(image: image,
mediaInfo: mediaInfo,
options: options)
let targetUrl = try generateUrl(withFileExtension: fileExtension)
let mimeType = getMimeType(from: ".\(targetUrl.pathExtension)")
// no modification requested
let imageModified = options.allowsEditing || options.quality != nil
let fileWasCopied = !imageModified && ImageUtils.tryCopyingOriginalImageFrom(mediaInfo: mediaInfo, to: targetUrl)
if !fileWasCopied {
try ImageUtils.write(imageData: imageData, to: targetUrl)
}
// as calling this already requires media library permission, we can access it here
// if user gave limited permissions, in the worst case this will be null
let asset = mediaInfo[.phAsset] as? PHAsset
var fileName = asset?.value(forKey: "filename") as? String
// Extension will change to png when editing BMP files, reflect that change in fileName
if let unwrappedName = fileName {
fileName = replaceFileExtension(fileName: unwrappedName, targetExtension: fileExtension.lowercased())
}
let fileSize = getFileSize(from: targetUrl)
let base64 = try ImageUtils.optionallyReadBase64From(imageData: imageData,
orImageFileUrl: targetUrl,
tryReadingFile: fileWasCopied,
shouldReadBase64: self.options.base64)
ImageUtils.optionallyReadExifFrom(mediaInfo: mediaInfo, shouldReadExif: self.options.exif) { exif in
let imageInfo = AssetInfo(assetId: asset?.localIdentifier,
uri: targetUrl.absoluteString,
width: image.size.width,
height: image.size.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
base64: base64,
exif: exif)
let response = ImagePickerResponse(assets: [imageInfo], canceled: false)
completion(.success(response))
}
} catch let exception as Exception {
return completion(.failure(exception))
} catch {
return completion(.failure(UnexpectedException(error)))
}
}
@available(iOS 14, *)
private func handleImage(from selectedImage: PHPickerResult,
atIndex index: Int = -1,
completion: @escaping (Int, SelectedMediaResult) -> Void) {
let itemProvider = selectedImage.itemProvider
itemProvider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { rawData, error in
do {
guard error == nil,
let rawData = rawData,
let image = try? UIImage(data: rawData) else {
return completion(index, .failure(FailedToReadImageException().causedBy(error)))
}
let (imageData, fileExtension) = try ImageUtils.readDataAndFileExtension(image: image,
rawData: rawData,
itemProvider: itemProvider,
options: self.options)
let mimeType = getMimeType(from: fileExtension)
let targetUrl = try generateUrl(withFileExtension: fileExtension)
try ImageUtils.write(imageData: imageData, to: targetUrl)
let fileSize = getFileSize(from: targetUrl)
let fileName = itemProvider.suggestedName.map { $0 + fileExtension }
// We need to get EXIF from original image data, as it is being lost in UIImage
let exif = ImageUtils.optionallyReadExifFrom(data: rawData, shouldReadExif: self.options.exif)
let base64 = try ImageUtils.optionallyReadBase64From(imageData: imageData,
orImageFileUrl: targetUrl,
tryReadingFile: false,
shouldReadBase64: self.options.base64)
let imageInfo = AssetInfo(assetId: selectedImage.assetIdentifier,
uri: targetUrl.absoluteString,
width: image.size.width,
height: image.size.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
base64: base64,
exif: exif)
completion(index, .success(imageInfo))
} catch let exception as Exception {
return completion(index, .failure(exception))
} catch {
return completion(index, .failure(UnexpectedException(error)))
}
} // loadObject
}
private func getMimeType(from pathExtension: String) -> String? {
let filenameExtension = String(pathExtension.dropFirst())
if #available(iOS 14, *) {
return UTType(filenameExtension: filenameExtension)?.preferredMIMEType
}
if let uti = UTTypeCreatePreferredIdentifierForTag(
kUTTagClassFilenameExtension,
pathExtension as NSString, nil
)?.takeRetainedValue() {
if let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue() {
return mimetype as String
}
}
return nil
}
// MARK: - Video
// TODO: convert to async/await syntax once we drop support for iOS 12
func handleVideo(mediaInfo: MediaInfo, completion: (ImagePickerResult) -> Void) {
do {
guard let pickedVideoUrl = VideoUtils.readVideoUrlFrom(mediaInfo: mediaInfo) else {
return completion(.failure(FailedToReadVideoException()))
}
let targetUrl = try generateUrl(withFileExtension: ".mov")
try VideoUtils.tryCopyingVideo(at: pickedVideoUrl, to: targetUrl)
guard let dimensions = VideoUtils.readSizeFrom(url: targetUrl) else {
return completion(.failure(FailedToReadVideoSizeException()))
}
// If video was edited (the duration is affected) then read the duration from the original edited video.
// Otherwise read the duration from the target video file.
// TODO: (@bbarthec): inspect whether it makes sense to read duration from two different assets
let videoUrlToReadDurationFrom = self.options.allowsEditing ? pickedVideoUrl : targetUrl
let duration = VideoUtils.readDurationFrom(url: videoUrlToReadDurationFrom)
let asset = mediaInfo[.phAsset] as? PHAsset
let mimeType = getMimeType(from: ".\(targetUrl.pathExtension)")
let fileName = asset?.value(forKey: "filename") as? String
let fileSize = getFileSize(from: targetUrl)
let videoInfo = AssetInfo(assetId: asset?.localIdentifier,
type: "video",
uri: targetUrl.absoluteString,
width: dimensions.width,
height: dimensions.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
duration: duration)
completion(.success(ImagePickerResponse(assets: [videoInfo], canceled: false)))
} catch let exception as Exception {
return completion(.failure(exception))
} catch {
return completion(.failure(UnexpectedException(error)))
}
}
@available(iOS 14, *)
private func handleVideo(from selectedVideo: PHPickerResult,
atIndex index: Int = -1,
completion: @escaping (Int, SelectedMediaResult) -> Void) {
let itemProvider = selectedVideo.itemProvider
itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [self] url, error in
do {
guard error == nil,
let videoUrl = url as? URL else {
return completion(index, .failure(FailedToReadVideoException().causedBy(error)))
}
// In case of passthrough, we want original file extension, mp4 otherwise
// TODO: (barthap) Support other file extensions?
let transcodeFileType = AVFileType.mp4
let transcodeFileExtension = ".mp4"
let originalExtension = ".\(videoUrl.pathExtension)"
let mimeType = getMimeType(from: originalExtension)
// We need to copy the result into a place that we control, because the picker
// can remove the original file during conversion.
// Also, the transcoding may need a separate url - one of these will be used as a final result
let assetUrl = try generateUrl(withFileExtension: originalExtension)
let transcodedUrl = try generateUrl(withFileExtension: transcodeFileExtension)
try VideoUtils.tryCopyingVideo(at: videoUrl, to: assetUrl)
VideoUtils.transcodeVideoAsync(sourceAssetUrl: assetUrl,
destinationUrl: transcodedUrl,
outputFileType: transcodeFileType,
exportPreset: options.videoExportPreset) { result in
switch result {
case .failure(let exception):
return completion(index, .failure(exception))
case .success(let targetUrl):
let fileName = itemProvider.suggestedName.map { $0 + transcodeFileExtension }
let videoResult = buildVideoResult(for: targetUrl, withName: fileName, mimeType: mimeType, assetId: selectedVideo.assetIdentifier)
return completion(index, videoResult)
}
}
} catch let exception as Exception {
return completion(index, .failure(exception))
} catch {
return completion(index, .failure(UnexpectedException(error)))
}
}
}
// MARK: - utils
private func replaceFileExtension(fileName: String, targetExtension: String) -> String {
if !fileName.lowercased().hasSuffix(targetExtension.lowercased()) {
return deleteFileExtension(fileName: fileName) + targetExtension
}
return fileName
}
private func deleteFileExtension(fileName: String) -> String {
var components = fileName.components(separatedBy: ".")
guard components.count > 1 else {
return fileName
}
components.removeLast()
return components.joined(separator: ".")
}
private func generateUrl(withFileExtension: String) throws -> URL {
guard let fileSystem = self.fileSystem else {
throw FileSystemModuleNotFoundException()
}
let directory = fileSystem.cachesDirectory.appending(
fileSystem.cachesDirectory.hasSuffix("/") ? "" : "/" + "ImagePicker"
)
let path = fileSystem.generatePath(inDirectory: directory, withExtension: withFileExtension)
let url = URL(fileURLWithPath: path)
return url
}
private func buildVideoResult(for videoUrl: URL, withName fileName: String?, mimeType: String?, assetId: String?) -> SelectedMediaResult {
guard let size = VideoUtils.readSizeFrom(url: videoUrl) else {
return .failure(FailedToReadVideoSizeException())
}
let duration = VideoUtils.readDurationFrom(url: videoUrl)
let fileSize = getFileSize(from: videoUrl)
let result = AssetInfo(assetId: assetId,
type: "video",
uri: videoUrl.absoluteString,
width: size.width,
height: size.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
duration: duration)
return .success(result)
}
private func getFileSize(from fileUrl: URL) -> Int? {
do {
let resources = try fileUrl.resourceValues(forKeys: [.fileSizeKey])
return resources.fileSize
} catch {
log.error("Failed to get file size for \(fileUrl.absoluteString)")
return nil
}
}
}
private struct ImageUtils {
static func readImageFrom(mediaInfo: MediaInfo, shouldReadCroppedImage: Bool) -> UIImage? {
guard let originalImage = mediaInfo[.originalImage] as? UIImage,
let image = originalImage.fixOrientation()
else {
return nil
}
if !shouldReadCroppedImage {
return image
}
guard let cropRect = mediaInfo[.cropRect] as? CGRect,
let croppedImage = ImageUtils.crop(image: image, to: cropRect)
else {
return nil
}
return croppedImage
}
static func crop(image: UIImage, to: CGRect) -> UIImage? {
guard let cgImage = image.cgImage?.cropping(to: to) else {
return nil
}
return UIImage(cgImage: cgImage,
scale: image.scale,
orientation: image.imageOrientation)
}
static func readDataAndFileExtension(
image: UIImage,
mediaInfo: MediaInfo,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
let compressionQuality = options.quality ?? DEFAULT_QUALITY
// nil when an image is picked from camera
let referenceUrl = mediaInfo[.referenceURL] as? URL
switch referenceUrl?.absoluteString {
case .some(let s) where s.contains("ext=PNG"):
let data = image.pngData()
return (data, ".png")
case .some(let s) where s.contains("ext=WEBP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".webp")
case .some(let s) where s.contains("ext=BMP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".bmp")
case .some(let s) where s.contains("ext=GIF"):
var rawData: Data?
if let imgUrl = mediaInfo[.imageURL] as? URL {
rawData = try? Data(contentsOf: imgUrl)
}
let inputData = rawData ?? image.jpegData(compressionQuality: compressionQuality)
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
let cropRect = options.allowsEditing ? mediaInfo[.cropRect] as? CGRect : nil
let gifData = try processGifData(inputData: inputData,
compressionQuality: options.quality,
initialMetadata: metadata,
cropRect: cropRect)
return (gifData, ".gif")
default:
let data = image.jpegData(compressionQuality: compressionQuality)
return (data, ".jpg")
}
}
@available(iOS 14, *)
static func readDataAndFileExtension(
image: UIImage,
rawData: Data,
itemProvider: NSItemProvider,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
let compressionQuality = options.quality ?? DEFAULT_QUALITY
let preferredFormat = itemProvider.registeredTypeIdentifiers.first
switch preferredFormat {
case UTType.bmp.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".bmp")
case UTType.png.identifier:
let data = image.pngData()
return (data, ".png")
case UTType.webP.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".webp")
case UTType.gif.identifier:
let gifData = try processGifData(inputData: rawData,
compressionQuality: options.quality,
initialMetadata: nil)
return (gifData, ".gif")
default:
let data = image.jpegData(compressionQuality: compressionQuality)
return (data, ".jpg")
}
}
static func write(imageData: Data?, to: URL) throws {
do {
try imageData?.write(to: to, options: [.atomic])
} catch {
throw FailedToWriteImageException()
.causedBy(error)
}
}
/**
@returns `true` upon copying success and `false` otherwise
*/
static func tryCopyingOriginalImageFrom(mediaInfo: MediaInfo, to: URL) -> Bool {
guard let from = mediaInfo[.imageURL] as? URL else {
return false
}
do {
try FileManager.default.copyItem(atPath: from.path, toPath: to.path)
return true
} catch {
return false
}
}
/**
Reads base64 representation of the image data. If the data is `nil` fallbacks to reading the data from the url.
*/
static func optionallyReadBase64From(
imageData: Data?,
orImageFileUrl url: URL,
tryReadingFile: Bool,
shouldReadBase64: Bool
) throws -> String? {
if !shouldReadBase64 {
return nil
}
if tryReadingFile {
do {
let data = try Data(contentsOf: url)
return data.base64EncodedString()
} catch {
throw FailedToReadImageDataException()
.causedBy(error)
}
}
guard let data = imageData else {
throw FailedToReadImageDataForBase64Exception()
}
return data.base64EncodedString()
}
static func optionallyReadExifFrom(
mediaInfo: MediaInfo,
shouldReadExif: Bool,
completion: @escaping (_ result: ExifInfo?) -> Void
) {
if !shouldReadExif {
return completion(nil)
}
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
if metadata != nil {
let exif = ImageUtils.readExifFrom(imageMetadata: metadata!)
return completion(exif)
}
guard let imageUrl = mediaInfo[.referenceURL] as? URL else {
log.error("Could not fetch metadata for image")
return completion(nil)
}
let assets = PHAsset.fetchAssets(withALAssetURLs: [imageUrl], options: nil)
guard let asset = assets.firstObject else {
log.error("Could not fetch metadata for image '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
asset.requestContentEditingInput(with: options) { input, _ in
guard let imageUrl = input?.fullSizeImageURL,
let properties = CIImage(contentsOf: imageUrl)?.properties
else {
log.error("Could not fetch metadata for '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let exif = ImageUtils.readExifFrom(imageMetadata: properties)
return completion(exif)
}
}
static func optionallyReadExifFrom(data: Data, shouldReadExif: Bool) -> ExifInfo? {
if shouldReadExif,
let cgImageSource = CGImageSourceCreateWithData(data as CFData, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(cgImageSource, 0, nil) {
return ImageUtils.readExifFrom(imageMetadata: properties as! [String: Any])
}
return nil
}
static func readExifFrom(imageMetadata: [String: Any]) -> ExifInfo {
var exif: ExifInfo = imageMetadata[kCGImagePropertyExifDictionary as String] as? ExifInfo ?? [:]
// Copy ["{GPS}"]["<tag>"] to ["GPS<tag>"]
let gps = imageMetadata[kCGImagePropertyGPSDictionary as String] as? [String: Any]
if gps != nil {
gps!.forEach { key, value in
exif["GPS\(key)"] = value
}
}
// Inject orientation into exif
let orientationKey = kCGImagePropertyOrientation as String
let orientationValue = imageMetadata[orientationKey]
if orientationValue != nil {
exif[orientationKey] = orientationValue
}
return exif
}
static func processGifData(
inputData: Data?,
compressionQuality: Double?,
initialMetadata: [String: Any]?,
cropRect: CGRect? = nil
) throws -> Data? {
let quality = compressionQuality ?? MAXIMUM_QUALITY
// for uncropped, maximum quality image we can just pass through the raw data
if cropRect == nil && quality >= MAXIMUM_QUALITY {
return inputData
}
guard let sourceData = inputData,
let imageSource = CGImageSourceCreateWithData(sourceData as CFData, nil)
else {
throw FailedToReadImageException()
}
let gifProperties = CGImageSourceCopyProperties(imageSource, nil) as? [String: Any]
let frameCount = CGImageSourceGetCount(imageSource)
let destinationData = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(destinationData, kUTTypeGIF, frameCount, nil)
else {
throw FailedToCreateGifException()
}
let gifMetadata = initialMetadata ?? gifProperties
CGImageDestinationSetProperties(imageDestination, gifMetadata as CFDictionary?)
for frameIndex in 0 ..< frameCount {
guard var cgImage = CGImageSourceCreateImageAtIndex(imageSource, frameIndex, nil),
var frameProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, frameIndex, nil) as? [String: Any]
else {
throw FailedToCreateGifException()
}
if cropRect != nil {
cgImage = cgImage.cropping(to: cropRect!)!
}
if quality != nil {
frameProperties[kCGImageDestinationLossyCompressionQuality as String] = quality
}
CGImageDestinationAddImage(imageDestination, cgImage, frameProperties as CFDictionary)
}
if !CGImageDestinationFinalize(imageDestination) {
throw FailedToExportGifException()
}
return destinationData as Data
}
}
private struct VideoUtils {
static func tryCopyingVideo(at: URL, to: URL) throws {
do {
// we copy the file as `moveItem(at:,to:)` throws an error in iOS 13 due to missing permissions
try FileManager.default.copyItem(at: at, to: to)
} catch {
throw FailedToPickVideoException()
.causedBy(error)
}
}
/**
@returns duration in milliseconds
*/
static func readDurationFrom(url: URL) -> Double {
let asset = AVURLAsset(url: url)
return Double(asset.duration.value) / Double(asset.duration.timescale) * 1_000
}
static func readSizeFrom(url: URL) -> CGSize? {
let asset = AVURLAsset(url: url)
guard let assetTrack = asset.tracks(withMediaType: .video).first else {
return nil
}
// The video could be rotated and the resulting transform can result in a negative width/height.
let size = assetTrack.naturalSize.applying(assetTrack.preferredTransform)
return CGSize(width: abs(size.width), height: abs(size.height))
}
static func readVideoUrlFrom(mediaInfo: MediaInfo) -> URL? {
return mediaInfo[.mediaURL] as? URL
?? mediaInfo[.referenceURL] as? URL
}
/**
Asynchronously transcodes asset provided as `sourceAssetUrl` according to `exportPreset`.
Result URL is returned to the `completion` closure.
Transcoded video is saved at `destinationUrl`, unless `exportPreset` is set to `passthrough`.
In this case, `sourceAssetUrl` is returned.
*/
static func transcodeVideoAsync(sourceAssetUrl: URL,
destinationUrl: URL,
outputFileType: AVFileType,
exportPreset: VideoExportPreset,
completion: @escaping (Result<URL, Exception>) -> Void) {
if case .passthrough = exportPreset {
return completion(.success((sourceAssetUrl)))
}
let asset = AVURLAsset(url: sourceAssetUrl)
let preset = exportPreset.toAVAssetExportPreset()
AVAssetExportSession.determineCompatibility(ofExportPreset: preset,
with: asset,
outputFileType: outputFileType) { canBeTranscoded in
guard canBeTranscoded else {
return completion(.failure(UnsupportedVideoExportPresetException(preset.description)))
}
guard let exportSession = AVAssetExportSession(asset: asset,
presetName: preset) else {
return completion(.failure(FailedToTranscodeVideoException()))
}
exportSession.outputFileType = outputFileType
exportSession.outputURL = destinationUrl
exportSession.exportAsynchronously {
switch exportSession.status {
case .failed:
let error = exportSession.error
completion(.failure(FailedToTranscodeVideoException().causedBy(error)))
default:
completion(.success((destinationUrl)))
}
}
}
}
}

View File

@@ -0,0 +1,52 @@
// Copyright 2022-present 650 Industries. All rights reserved.
/**
Since iOS 11, launching ImagePicker with `allowsEditing` option makes cropping rectangle
slightly moved upwards, because of StatusBar visibility.
Hiding StatusBar during picking process solves the displacement issue.
See https://forums.developer.apple.com/thread/98274
*/
internal class StatusBarVisibilityController {
private var shouldRestoreStatusBarVisibility = false
func maybePreserveVisibilityAndHideStatusBar(_ shouldHideStatusBar: Bool) {
guard shouldHideStatusBar && !UIApplication.shared.isStatusBarHidden else {
return
}
shouldRestoreStatusBarVisibility = true
setStatusBarHidden(true)
}
func maybeRestoreStatusBarVisibility() {
guard shouldRestoreStatusBarVisibility else {
return
}
shouldRestoreStatusBarVisibility = false
setStatusBarHidden(false)
}
/**
Calling -[UIApplication setStatusBarHidden:withAnimation:] triggers a warning
that should be suppressable with -Wdeprecated-declarations, but is not.
The warning suggests to use -[UIViewController prefersStatusBarHidden].
Unfortunately until we stop presenting view controllers on detached VCs
the setting doesn't have any effect and we need to set status bar like that.
*/
private func setStatusBarHidden(_ hidden: Bool) {
let selector = NSSelectorFromString("setStatusBarHidden:withAnimation:")
UIApplication.shared.perform(selector, with: hidden, with: false)
// TODO: (@bbarthec) below is possible alternative
// let obj = X()
// let sel = #selector(obj.sayHiTo)
// let meth = class_getInstanceMethod(object_getClass(obj), sel)
// let imp = method_getImplementation(meth)
//
// typealias ClosureType = @convention(c) (AnyObject, Selector, String) -> Void
// let sayHiTo : ClosureType = unsafeBitCast(imp, ClosureType.self)
// sayHiTo(obj, sel, "Fabio")
// prints "Hello Fabio!"
}
}

View File

@@ -0,0 +1,82 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import UIKit
extension UIImage {
func fixOrientation() -> UIImage? {
if self.imageOrientation == UIImage.Orientation.up {
return self
}
var transform = CGAffineTransform.identity
// rotation
switch self.imageOrientation {
case .down,
.downMirrored:
transform = transform
.translatedBy(x: self.size.width, y: self.size.height)
.rotated(by: .pi)
case .left,
.leftMirrored:
transform = transform
.translatedBy(x: self.size.width, y: 0)
.rotated(by: .pi / 2)
case .right,
.rightMirrored:
transform = transform
.translatedBy(x: 0, y: self.size.height)
.rotated(by: -.pi / 2)
default:
break
}
// mirroring
switch self.imageOrientation {
case .upMirrored,
.downMirrored:
transform = transform
.translatedBy(x: self.size.width, y: 0)
.scaledBy(x: -1, y: 1)
case .leftMirrored,
.rightMirrored:
transform = transform
.translatedBy(x: self.size.height, y: 0)
.scaledBy(x: -1, y: 1)
default:
break
}
guard let cgImage = self.cgImage,
let colorSpace = cgImage.colorSpace,
let ctx = CGContext(data: nil,
width: Int(self.size.width),
height: Int(self.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: cgImage.bitmapInfo.rawValue)
else {
return nil
}
ctx.concatenate(transform)
switch self.imageOrientation {
case .left,
.leftMirrored,
.right,
.rightMirrored:
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: self.size.height, height: self.size.width))
default:
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height))
}
guard let resultCgImage = ctx.makeImage() else {
return nil
}
let result = UIImage(cgImage: resultCgImage)
return result
}
}

View File

@@ -0,0 +1,57 @@
// Copyright 2016-present 650 Industries. All rights reserved.
extension UIImagePickerController {
func fixCannotMoveEditingBox() {
if let cropView = cropView,
let scrollView = scrollView,
scrollView.contentOffset.y == 0 {
let top = cropView.frame.minY + self.view.safeAreaInsets.top
let bottom = scrollView.frame.height - cropView.frame.height - top
scrollView.contentInset = UIEdgeInsets(top: top, left: 0, bottom: bottom, right: 0)
var offset: CGFloat = 0
if scrollView.contentSize.height > scrollView.contentSize.width {
offset = 0.5 * (scrollView.contentSize.height - scrollView.contentSize.width)
}
scrollView.contentOffset = CGPoint(x: 0, y: -top + offset)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.fixCannotMoveEditingBox()
}
}
var cropView: UIView? {
return findCropView(from: self.view)
}
var scrollView: UIScrollView? {
return findScrollView(from: self.view)
}
func findCropView(from view: UIView) -> UIView? {
let width = UIScreen.main.bounds.width
let size = view.bounds.size
if width == size.height, width == size.height {
return view
}
for view in view.subviews {
if let cropView = findCropView(from: view) {
return cropView
}
}
return nil
}
func findScrollView(from view: UIView) -> UIScrollView? {
if let scrollView = view as? UIScrollView {
return scrollView
}
for view in view.subviews {
if let scrollView = findScrollView(from: view) {
return scrollView
}
}
return nil
}
}