Files
Eric FELIXINE e30ae8ed09 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
2026-06-01 18:00:35 -04:00

712 lines
27 KiB
Swift

// 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)))
}
}
}
}
}