diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index a8062ae..f260741 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -20,8 +20,11 @@ jobs: brew install sunshinejr/formulae/pouch - name: Generate Secrets.swift env: - CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} - CLOUDINARY_UPLOAD_PRESET_NAME: ${{ secrets.CLOUDINARY_UPLOAD_PRESET_NAME }} + AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} + AWS_BUCKET_NAME: ${{ vars.AWS_BUCKET_NAME }} + AWS_BUCKET_REGION: ${{ vars.AWS_BUCKET_REGION }} + AWS_BUCKET_PREFIX: ${{ vars.AWS_BUCKET_PREFIX }} PROTECT_EARTH_API_TOKEN: ${{ secrets.PROTECT_EARTH_API_TOKEN }} PROTECT_EARTH_API_BASE_URL: ${{ secrets.PROTECT_EARTH_API_BASE_URL }} PROTECT_EARTH_ENV_NAME: ${{ secrets.PROTECT_EARTH_ENV_NAME }} @@ -51,8 +54,11 @@ jobs: brew install sunshinejr/formulae/pouch - name: Generate Secrets.swift env: - CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} - CLOUDINARY_UPLOAD_PRESET_NAME: ${{ secrets.CLOUDINARY_UPLOAD_PRESET_NAME }} + AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} + AWS_BUCKET_NAME: ${{ vars.AWS_BUCKET_NAME }} + AWS_BUCKET_REGION: ${{ vars.AWS_BUCKET_REGION }} + AWS_BUCKET_PREFIX: ${{ vars.AWS_BUCKET_PREFIX }} PROTECT_EARTH_API_TOKEN: ${{ secrets.PROTECT_EARTH_API_TOKEN }} PROTECT_EARTH_API_BASE_URL: ${{ secrets.PROTECT_EARTH_API_BASE_URL }} PROTECT_EARTH_ENV_NAME: ${{ secrets.PROTECT_EARTH_ENV_NAME }} @@ -120,8 +126,11 @@ jobs: brew install sunshinejr/formulae/pouch - name: Generate Secrets.swift env: - CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} - CLOUDINARY_UPLOAD_PRESET_NAME: ${{ secrets.CLOUDINARY_UPLOAD_PRESET_NAME }} + AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} + AWS_BUCKET_NAME: ${{ vars.AWS_BUCKET_NAME }} + AWS_BUCKET_REGION: ${{ vars.AWS_BUCKET_REGION }} + AWS_BUCKET_PREFIX: ${{ vars.AWS_BUCKET_PREFIX }} PROTECT_EARTH_API_TOKEN: ${{ secrets.PROTECT_EARTH_API_TOKEN }} PROTECT_EARTH_API_BASE_URL: ${{ secrets.PROTECT_EARTH_API_BASE_URL }} PROTECT_EARTH_ENV_NAME: ${{ secrets.PROTECT_EARTH_ENV_NAME }} diff --git a/.pouch.yml b/.pouch.yml index d3be74c..134e14b 100644 --- a/.pouch.yml +++ b/.pouch.yml @@ -1,6 +1,9 @@ secrets: -- CLOUDINARY_CLOUD_NAME -- CLOUDINARY_UPLOAD_PRESET_NAME +- AWS_BUCKET_NAME +- AWS_BUCKET_REGION +- AWS_BUCKET_PREFIX +- AWS_ACCESS_KEY +- AWS_SECRET_KEY - PROTECT_EARTH_API_TOKEN - PROTECT_EARTH_API_BASE_URL - PROTECT_EARTH_ENV_NAME diff --git a/Tree Tracker.xcodeproj/project.pbxproj b/Tree Tracker.xcodeproj/project.pbxproj index ed1ca77..8eaa168 100644 --- a/Tree Tracker.xcodeproj/project.pbxproj +++ b/Tree Tracker.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -100,11 +100,14 @@ 9D01D566285CD2E50009F753 /* RollbarSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9D01D565285CD2E50009F753 /* RollbarSwift */; }; 9D2B454F2944F54000B09C84 /* LocationWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D2B454E2944F54000B09C84 /* LocationWarningOverlayView.swift */; }; 9D2DB5F629606B220040B1DB /* UploadedTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D2DB5F529606B220040B1DB /* UploadedTree.swift */; }; + 9D36C7EB2A22950B00E04552 /* AWSS3Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D36C7EA2A22950B00E04552 /* AWSS3Configuration.swift */; }; + 9D375F912AC8873C009AF2D2 /* AWSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9D375F902AC8873C009AF2D2 /* AWSCore */; }; + 9D375F932AC8873C009AF2D2 /* AWSS3 in Frameworks */ = {isa = PBXBuildFile; productRef = 9D375F922AC8873C009AF2D2 /* AWSS3 */; }; + 9D3C323B29F5BDEA00462558 /* UploadCompletionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D3C323A29F5BDEA00462558 /* UploadCompletionHolder.swift */; }; 9D47D97F286F293000F7B92F /* ProtectEarthSupervisor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D47D97E286F293000F7B92F /* ProtectEarthSupervisor.swift */; }; 9D47D982286F29E100F7B92F /* ProtectEarthCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D47D981286F29E100F7B92F /* ProtectEarthCodableTests.swift */; }; 9D562D6528B81BDE00B66716 /* ProtectEarthUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D562D6428B81BDE00B66716 /* ProtectEarthUpload.swift */; }; 9D562D6728B81D5400B66716 /* ProtectEarthIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D562D6628B81D5400B66716 /* ProtectEarthIdentifier.swift */; }; - 9D562D7228C4129000B66716 /* CloudinarySessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D562D7128C4129000B66716 /* CloudinarySessionFactory.swift */; }; 9D5CDBD727BBC080007D4F0A /* ExportOptions.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9D5CDBD627BBC080007D4F0A /* ExportOptions.plist */; }; 9D5D5E28284B630D00F3AD3E /* SpeciesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */; }; 9D5D5E2C284B66BB00F3AD3E /* SupervisorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */; }; @@ -240,11 +243,12 @@ 85E0E06125B35744009D8FC0 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 9D2B454E2944F54000B09C84 /* LocationWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationWarningOverlayView.swift; sourceTree = ""; }; 9D2DB5F529606B220040B1DB /* UploadedTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedTree.swift; sourceTree = ""; }; + 9D36C7EA2A22950B00E04552 /* AWSS3Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSS3Configuration.swift; sourceTree = ""; }; + 9D3C323A29F5BDEA00462558 /* UploadCompletionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCompletionHolder.swift; sourceTree = ""; }; 9D47D97E286F293000F7B92F /* ProtectEarthSupervisor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthSupervisor.swift; sourceTree = ""; }; 9D47D981286F29E100F7B92F /* ProtectEarthCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthCodableTests.swift; sourceTree = ""; }; 9D562D6428B81BDE00B66716 /* ProtectEarthUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthUpload.swift; sourceTree = ""; }; 9D562D6628B81D5400B66716 /* ProtectEarthIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthIdentifier.swift; sourceTree = ""; }; - 9D562D7128C4129000B66716 /* CloudinarySessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudinarySessionFactory.swift; sourceTree = ""; }; 9D5CDBD627BBC080007D4F0A /* ExportOptions.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = ExportOptions.plist; sourceTree = ""; }; 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeciesService.swift; sourceTree = ""; }; 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupervisorService.swift; sourceTree = ""; }; @@ -290,6 +294,8 @@ 85A0EF7F25A226D9003CE744 /* GRDB in Frameworks */, 85B83A0D25B87C0D0008E167 /* BSImagePicker in Frameworks */, 9DCC548C28073F0A00CF67AA /* Resolver in Frameworks */, + 9D375F932AC8873C009AF2D2 /* AWSS3 in Frameworks */, + 9D375F912AC8873C009AF2D2 /* AWSCore in Frameworks */, 9D01D566285CD2E50009F753 /* RollbarSwift in Frameworks */, 9D01D564285CD2E50009F753 /* RollbarNotifier in Frameworks */, 85A0EF8325A2271C003CE744 /* Alamofire in Frameworks */, @@ -415,6 +421,8 @@ 85B839BE25B492550008E167 /* PHImageLoader.swift */, 851DAC1F262C55FD0087E1D4 /* RecentSpeciesManager.swift */, 85C781A025CC744E0034292D /* ScreenLockManager.swift */, + 9D3C323A29F5BDEA00462558 /* UploadCompletionHolder.swift */, + 9D36C7EA2A22950B00E04552 /* AWSS3Configuration.swift */, ); path = Utilities; sourceTree = ""; @@ -525,7 +533,6 @@ 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */, 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */, 9DA8470E28844E9000B0BB3E /* TreeService.swift */, - 9D562D7128C4129000B66716 /* CloudinarySessionFactory.swift */, ); path = Services; sourceTree = ""; @@ -659,6 +666,8 @@ 9D01D561285CD2E50009F753 /* RollbarCommon */, 9D01D563285CD2E50009F753 /* RollbarNotifier */, 9D01D565285CD2E50009F753 /* RollbarSwift */, + 9D375F902AC8873C009AF2D2 /* AWSCore */, + 9D375F922AC8873C009AF2D2 /* AWSS3 */, ); productName = "Tree Tracker"; productReference = 853ABD522596144900144B0D /* Tree Tracker.app */; @@ -699,6 +708,7 @@ 85B83A0B25B87C0D0008E167 /* XCRemoteSwiftPackageReference "BSImagePicker" */, 9DCC548A28073F0A00CF67AA /* XCRemoteSwiftPackageReference "Resolver" */, 9D01D560285CD2E50009F753 /* XCRemoteSwiftPackageReference "rollbar-apple" */, + 9D375F8F2AC8873C009AF2D2 /* XCRemoteSwiftPackageReference "aws-sdk-ios-spm" */, ); productRefGroup = 853ABD532596144900144B0D /* Products */; projectDirPath = ""; @@ -793,7 +803,6 @@ 859F62E425C22D6C005E61F7 /* SelectionsKeyboardView.swift in Sources */, 85763A9425E29CE300CB4ED3 /* Logger.swift in Sources */, 9D562D6528B81BDE00B66716 /* ProtectEarthUpload.swift in Sources */, - 9D562D7228C4129000B66716 /* CloudinarySessionFactory.swift in Sources */, 859F62D625C22140005E61F7 /* Species.swift in Sources */, 85B83A2325B9C1BC0008E167 /* NavigationViewController.swift in Sources */, 9DFF45C428C69AAD00D45C73 /* CloudinaryUploadResponse.swift in Sources */, @@ -832,6 +841,7 @@ 85DC213C25E0FC8F003F0721 /* ImageCacheInfo.swift in Sources */, 858A0F2725D8156100E12C2B /* TreeDetailsFlowViewController.swift in Sources */, 859F62DC25C2218B005E61F7 /* Supervisor.swift in Sources */, + 9D36C7EB2A22950B00E04552 /* AWSS3Configuration.swift in Sources */, 85B839FB25B86F1A0008E167 /* ImageLoader.swift in Sources */, 85B839EF25B866370008E167 /* GridCollectionViewLayout.swift in Sources */, 9DA8470D28844E5100B0BB3E /* ProtectEarthTreeService.swift in Sources */, @@ -852,6 +862,7 @@ 85CC02E9261B6E820016E618 /* TableListItem.swift in Sources */, 85CC02EC261B6EA90016E618 /* TextTableViewCell.swift in Sources */, 9DA8470F28844E9000B0BB3E /* TreeService.swift in Sources */, + 9D3C323B29F5BDEA00462558 /* UploadCompletionHolder.swift in Sources */, 9DFF6277282E63100008AEEF /* SupervisorsController.swift in Sources */, 85763A9D25E2B0AB00CB4ED3 /* RotatingUIImagePickerController.swift in Sources */, 9D5F061B286F348F00C8D4A6 /* ProtectEarthSessionFactory.swift in Sources */, @@ -1062,7 +1073,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = K5RUKV288P; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K5RUKV288P; INFOPLIST_FILE = "Tree Tracker/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -1174,6 +1185,14 @@ minimumVersion = 2.0.0; }; }; + 9D375F8F2AC8873C009AF2D2 /* XCRemoteSwiftPackageReference "aws-sdk-ios-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/aws-amplify/aws-sdk-ios-spm"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 2.33.0; + }; + }; 9DCC548A28073F0A00CF67AA /* XCRemoteSwiftPackageReference "Resolver" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/hmlongco/Resolver.git"; @@ -1215,6 +1234,16 @@ package = 9D01D560285CD2E50009F753 /* XCRemoteSwiftPackageReference "rollbar-apple" */; productName = RollbarSwift; }; + 9D375F902AC8873C009AF2D2 /* AWSCore */ = { + isa = XCSwiftPackageProductDependency; + package = 9D375F8F2AC8873C009AF2D2 /* XCRemoteSwiftPackageReference "aws-sdk-ios-spm" */; + productName = AWSCore; + }; + 9D375F922AC8873C009AF2D2 /* AWSS3 */ = { + isa = XCSwiftPackageProductDependency; + package = 9D375F8F2AC8873C009AF2D2 /* XCRemoteSwiftPackageReference "aws-sdk-ios-spm" */; + productName = AWSS3; + }; 9D5CDBD427BBB7D2007D4F0A /* Alamofire */ = { isa = XCSwiftPackageProductDependency; package = 85A0EF8125A2271C003CE744 /* XCRemoteSwiftPackageReference "Alamofire" */; diff --git a/Tree Tracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tree Tracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 98bd33c..0a0e528 100644 --- a/Tree Tracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tree Tracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "5.5.0" } }, + { + "identity" : "aws-sdk-ios-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aws-amplify/aws-sdk-ios-spm", + "state" : { + "revision" : "ca31418963a90bac80538e13f6b7af87ea14d279", + "version" : "2.33.4" + } + }, { "identity" : "bsimagepicker", "kind" : "remoteSourceControl", diff --git a/Tree Tracker/AppDelegate+Injection.swift b/Tree Tracker/AppDelegate+Injection.swift index 0fac83d..8db0675 100644 --- a/Tree Tracker/AppDelegate+Injection.swift +++ b/Tree Tracker/AppDelegate+Injection.swift @@ -1,6 +1,8 @@ import Resolver import Photos import UIKit +import AWSS3 +import AWSCore extension Resolver: ResolverRegistering { @@ -19,6 +21,9 @@ extension Resolver: ResolverRegistering { register { UIScreenLockManager() } register { PHCachingImageManager() } register { RecentSpeciesManager(defaults: resolve(), strategy: .todayUsedSpecies) } + register { AWSS3Configuration(accessKey: Secrets.awsAccessKey, + secretKey: Secrets.awsSecretKey, + region: Secrets.awsBucketRegion.aws_regionTypeValue()) } // MARK: Services register { ProtectEarthSessionFactory(baseUrl: Constants.Http.protectEarthApiBaseUrl, @@ -32,10 +37,6 @@ extension Resolver: ResolverRegistering { register { ProtectEarthSiteService() as SiteService } register { ProtectEarthSpeciesService() as SpeciesService } register { ProtectEarthTreeService() as TreeService } - register { CloudinarySessionFactory(httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, - httpWaitsForConnectivity: true, - httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, - httpRetryLimit: Constants.Http.requestRetryLimit) } // MARK: Controllers register { SitesController() } diff --git a/Tree Tracker/Constants.swift b/Tree Tracker/Constants.swift index e4a4ca9..87be271 100644 --- a/Tree Tracker/Constants.swift +++ b/Tree Tracker/Constants.swift @@ -1,10 +1,6 @@ import Foundation enum Constants { - enum Cloudinary { - static let cloudName = Secrets.cloudinaryCloudName - static let uploadPresetName = Secrets.cloudinaryUploadPresetName - } enum Http { static let requestWaitsForConnectivity = true static let requestTimeoutSeconds: TimeInterval = 30 diff --git a/Tree Tracker/Info.plist b/Tree Tracker/Info.plist index 6a6ba7f..7d636d3 100644 --- a/Tree Tracker/Info.plist +++ b/Tree Tracker/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.10.12 + 0.11.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/Tree Tracker/Screens/Settings/SettingsController.swift b/Tree Tracker/Screens/Settings/SettingsController.swift index 6a09595..865d943 100644 --- a/Tree Tracker/Screens/Settings/SettingsController.swift +++ b/Tree Tracker/Screens/Settings/SettingsController.swift @@ -13,7 +13,10 @@ class SettingsController: UITableViewController { private var entityTypes = ["Sites", "Supervisors", "Species"] private var apiProperties = [Constants.Http.protectEarthApiBaseUrl, - Constants.Http.protectEarthEnvironmentName] + Constants.Http.protectEarthEnvironmentName, + Secrets.awsBucketName, + "\(Secrets.awsAccessKey.prefix(4))************\(Secrets.awsAccessKey.suffix(4))", + Secrets.awsBucketRegion] override func viewDidLoad() { super.viewDidLoad() diff --git a/Tree Tracker/Screens/Upload/UploadViewModel.swift b/Tree Tracker/Screens/Upload/UploadViewModel.swift index cc3b583..4067388 100644 --- a/Tree Tracker/Screens/Upload/UploadViewModel.swift +++ b/Tree Tracker/Screens/Upload/UploadViewModel.swift @@ -69,17 +69,19 @@ final class UploadViewModel: CollectionViewModel { } private func presentUploadButton(isUploading: Bool) { - self.actionButton = ButtonModel( - title: .text(isUploading ? "Stop uploading" : "Upload"), - action: { [weak self] in - if isUploading { - self?.cancelUploading() - } else { - self?.upload() - } - }, - isEnabled: true - ) + DispatchQueue.main.async { + self.actionButton = ButtonModel( + title: .text(isUploading ? "Stop uploading" : "Upload"), + action: { [weak self] in + if isUploading { + self?.cancelUploading() + } else { + self?.upload() + } + }, + isEnabled: true + ) + } } private func presentNavigationButtons(isUploading: Bool) { diff --git a/Tree Tracker/Services/CloudinarySessionFactory.swift b/Tree Tracker/Services/CloudinarySessionFactory.swift deleted file mode 100644 index dcc9ae0..0000000 --- a/Tree Tracker/Services/CloudinarySessionFactory.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import Alamofire - -class CloudinarySessionFactory { - - private var session: Session? - private var httpRequestTimeoutSeconds: TimeInterval - private var httpWaitsForConnectivity: Bool - private var httpRetryDelaySeconds: Int - private var httpRetryLimit: Int - - init(httpRequestTimeoutSeconds: TimeInterval, - httpWaitsForConnectivity: Bool, - httpRetryDelaySeconds: Int, - httpRetryLimit: Int) { - self.httpRequestTimeoutSeconds = httpRequestTimeoutSeconds - self.httpWaitsForConnectivity = httpWaitsForConnectivity - self.httpRetryDelaySeconds = httpRetryDelaySeconds - self.httpRetryLimit = httpRetryLimit - } - - func get() -> Session { - if session == nil { - let sessionConfig = URLSessionConfiguration.af.default - sessionConfig.timeoutIntervalForRequest = httpRequestTimeoutSeconds - sessionConfig.waitsForConnectivity = httpWaitsForConnectivity - - let interceptor = Interceptor(adapter: NoOpAdapter(), - retrier: RetryingRequestInterceptor(retryDelaySecs: httpRetryDelaySeconds, - maxRetries: httpRetryLimit)) - - session = Session(configuration: sessionConfig, - interceptor: interceptor) - } - return session! - } -} - -private class NoOpAdapter: RequestAdapter { - func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { - completion(.success(urlRequest)) - } -} diff --git a/Tree Tracker/Services/ProtectEarth/ProtectEarthTreeService.swift b/Tree Tracker/Services/ProtectEarth/ProtectEarthTreeService.swift index 343fc63..b0615f2 100644 --- a/Tree Tracker/Services/ProtectEarth/ProtectEarthTreeService.swift +++ b/Tree Tracker/Services/ProtectEarth/ProtectEarthTreeService.swift @@ -3,12 +3,14 @@ import Resolver import Alamofire import UIKit import RollbarNotifier +import AWSS3 class ProtectEarthTreeService: TreeService { @Injected private var database: Database - @Injected private var sessionFactory: AlamofireSessionFactory - @Injected private var cloudinarySessionFactory: CloudinarySessionFactory + @Injected private var awsS3Configuration: AWSS3Configuration + + private var completionHolders: [UploadCompletionHolder] = [] // Complete any local tidy up following an upload session func tidyUp() { @@ -48,7 +50,7 @@ class ProtectEarthTreeService: TreeService { func publish(tree: LocalTree, progress: @escaping (Double) -> Void, completion: @escaping (Result) -> Void) { // Step 1: retrieve image at appropriate resolution - prepareImageForUpload(tree: tree) { [weak self] (image: UIImage?) in + prepareImageForUpload(tree: tree) { [weak self] image in guard let self = self else { return } @@ -59,28 +61,55 @@ class ProtectEarthTreeService: TreeService { progress(0.1) - // Step 2: upload image to cloudinary - self.uploadImageToImageStore(image: image, progress: progress) { imageUploadResult in - - switch imageUploadResult { - case let .success((url, md5)): - var newTree = tree - newTree.imageMd5 = md5 - - // Step 3: post tree details to API and remove tree from queue - self.postMetadata(tree: newTree, imageStoreUrl: url, completion: completion) + // Step 2: Upload the image to storage, with appropriate metadata + guard let data = image.jpegData(compressionQuality: 0.8) else { + Rollbar.errorMessage("No jpeg for image, upload will be skipped") + completion(.failure(.localError(errorCode: 100, errorMessage: "Unable to fetch jpeg image data"))) + return + } + let md5 = data.md5() + +// guard let plantedDate = tree.createDate else { return } + guard let coordinates: [String] = tree.coordinates?.components(separatedBy: ", ") else { return } + + var latitude = "" + var longitude = "" + + if (coordinates.count == 2) { + latitude = coordinates[0] + longitude = coordinates[1] + } + + let expression = AWSS3TransferUtilityUploadExpression() + expression.progressBlock = {(task, taskProgress) in + progress(0.1 + taskProgress.fractionCompleted * 0.9) + } + expression.setValue(tree.createDate?.ISO8601Format(), forRequestHeader: "x-amz-meta-planted-at") + expression.setValue(tree.supervisor, forRequestHeader: "x-amz-meta-supervisor") + expression.setValue(latitude, forRequestHeader: "x-amz-meta-latitude") + expression.setValue(longitude, forRequestHeader: "x-amz-meta-longitude") + expression.setValue(tree.site, forRequestHeader: "x-amz-meta-site") + expression.setValue(tree.species, forRequestHeader: "x-amz-meta-species") + expression.setValue(tree.phImageId, forRequestHeader: "x-amz-meta-phimageid") + expression.setValue(md5, forRequestHeader: "x-amz-meta-md5") +// expression.contentMD5 = md5 // uncommenting this leads to a HTTP 400 error - case let .failure(error): - Rollbar.errorError(error, - data: ["md5": tree.imageMd5 ?? "", - "phImageId": tree.phImageId, - "coordinates": tree.coordinates ?? "", - "supervisor": tree.supervisor, - "site": tree.site], - context: "Fetching upload image for tree") - completion(.failure(ProtectEarthError.remoteError(errorCode: error.responseCode ?? -1, - errorMessage: error.errorDescription ?? ""))) - } + let transferUtility = AWSS3TransferUtility.default() + transferUtility.shouldRemoveCompletedTasks = true + + let completionHolder = UploadCompletionHolder(tree: tree, database: self.database, completion: completion) + self.completionHolders.append(completionHolder) + + transferUtility.uploadData(data, + bucket: Secrets.awsBucketName, + key: "\(Secrets.awsBucketPrefix)/\(tree.treeId)", + contentType: "image/jpeg", + expression: expression, + completionHandler: completionHolder.completionHandler + ) + .continueWith { (task) -> AnyObject? in + // stuff we want to do once the task is *STARTED* + return nil } } } @@ -90,110 +119,4 @@ class ProtectEarthTreeService: TreeService { imageLoader.loadUploadImage(completion: completion) } - private func uploadImageToImageStore(image: UIImage, - progress: @escaping (Double) -> Void, - completion: @escaping (Result<(String, String), AFError>) -> Void) { - let cloudinaryUploadUrl = URL(string: "https://api.cloudinary.com/v1_1/\(Constants.Cloudinary.cloudName)/image/upload")! - let cloudinarySession = cloudinarySessionFactory.get() - - guard let data = image.jpegData(compressionQuality: 0.8) else { - Rollbar.errorMessage("No pngData for image, upload will be skipped") - completion(.failure(.explicitlyCancelled)) - return - } - - let md5 = data.md5() - let request = cloudinarySession - .upload( - multipartFormData: { formData in - formData.append(data, withName: "file", fileName: "image.jpg", mimeType: "image/jpg") - formData.append(Constants.Cloudinary.uploadPresetName.data(using: .utf8)!, withName: "upload_preset") - }, - to: cloudinaryUploadUrl, - method: .post - ) - .uploadProgress { uploadProgress in - // as we expect uploading of the image to be our most network intensive - // phase of the upload process we allow this operation to consume the - // 10-90% complete segment of our total progress - progress(0.1 + 0.8 * uploadProgress.fractionCompleted) - } - - request - .validate(statusCode: [200]) - .cURLDescription { desc in - print(desc) - } - .responseDecodable(of: CloudinaryUploadResponse.self) { response in - switch response.result { - case let .failure(error): - Rollbar.errorError(error, - data: [:], - context: response.dataAsUTF8String()) - completion(.failure(error)) - case let.success(response): - guard let url = response.secureUrl else { fallthrough } - completion(.success((url, md5))) - default: - Rollbar.errorMessage("Error while parsing JSON", - data: [:], - context: response.dataAsUTF8String()) - completion(.failure(.explicitlyCancelled)) - } - } - } - - private func postMetadata(tree: LocalTree, imageStoreUrl: String, completion: @escaping (Result) -> Void) { - guard let plantedDate = tree.createDate else { return } - guard let coordinates: [String] = tree.coordinates?.components(separatedBy: ", ") else { return } - guard let latitude = Decimal(string: coordinates[0]) else { return } - guard let longitude = Decimal(string: coordinates[1]) else { return } - - let treeMeta = ProtectEarthUpload(imageUrl: imageStoreUrl, - latitude: latitude, - longitude: longitude, - plantedAt: plantedDate, - supervisor: ProtectEarthIdentifier(id: tree.supervisor), - site: ProtectEarthIdentifier(id: tree.site), - species: ProtectEarthIdentifier(id: tree.species)) - - let encoder = JSONEncoder() - // php api doesn't like escaped slashes - encoder.outputFormatting = .init(arrayLiteral: [.sortedKeys, .withoutEscapingSlashes]) - encoder.dateEncodingStrategy = .iso8601 - - let headers : HTTPHeaders = ["Idempotency-Key": tree.treeId] - let request = getSession().request(sessionFactory.getTreeUrl(), - method: .post, - parameters: treeMeta, - encoder: JSONParameterEncoder(encoder: encoder), - headers: headers) - - request - .cURLDescription { description in - print(description) - } - .validate(statusCode: [201]) - .response { response in - switch response.result { - case .success: - let upload = UploadedTree.fromTree(tree) - self.database.save([upload]) - self.database.remove(tree: tree) { - Rollbar.infoMessage("Successfully uploaded tree", data: ["id": tree.treeId, - "md5": tree.imageMd5 ?? "", - "imageUrl": treeMeta.imageUrl]) - completion(.success(true)) - } - case let .failure(error): - completion(.failure(ProtectEarthError.remoteError(errorCode: error.responseCode ?? -1, - errorMessage: error.errorDescription ?? "No description"))) - } - } - } - - private func getSession() -> Session { - sessionFactory.get() - } - } diff --git a/Tree Tracker/Utilities/AWSS3Configuration.swift b/Tree Tracker/Utilities/AWSS3Configuration.swift new file mode 100644 index 0000000..b849a6a --- /dev/null +++ b/Tree Tracker/Utilities/AWSS3Configuration.swift @@ -0,0 +1,12 @@ +import Foundation +import AWSS3 + +class AWSS3Configuration { + + init(accessKey: String, secretKey: String, region: AWSRegionType = .EUWest1) { + let credentialsProvider = AWSStaticCredentialsProvider(accessKey: accessKey, secretKey: secretKey) + let configuration = AWSServiceConfiguration(region: region, credentialsProvider: credentialsProvider) + AWSServiceManager.default().defaultServiceConfiguration = configuration + } + +} diff --git a/Tree Tracker/Utilities/UploadCompletionHolder.swift b/Tree Tracker/Utilities/UploadCompletionHolder.swift new file mode 100644 index 0000000..fd40756 --- /dev/null +++ b/Tree Tracker/Utilities/UploadCompletionHolder.swift @@ -0,0 +1,38 @@ +import Foundation +import AWSS3 +import RollbarNotifier + +class UploadCompletionHolder { + + @objc var completionHandler: AWSS3TransferUtilityUploadCompletionHandlerBlock? + + private var database: Database + private var tree: LocalTree + + init(tree: LocalTree, database: Database, completion: @escaping (Result) -> Void) { + + self.database = database + self.tree = tree + + self.completionHandler = { (task, error) -> Void in + if let error = error { + let responseCode = task.response?.statusCode.description ?? "???" + completion(.failure(.remoteError(errorCode: 243, errorMessage: "Error in S3 upload - [\(responseCode)] \(error.localizedDescription)"))) + } + else if task.status != .completed { + completion(.failure(.remoteError(errorCode: 245, errorMessage: "Unknown S3 error"))) + } + else { + // save uploaded tree, remove from queue and post rollbar success message + let upload = UploadedTree.fromTree(tree) + database.save([upload]) + database.remove(tree: tree) { + Rollbar.infoMessage("Successfully uploaded tree", data: ["id": tree.treeId, + "md5": tree.imageMd5 ?? ""]) + completion(.success(true)) + } + } + } + } + +} diff --git a/Unit Tests/Info.plist b/Unit Tests/Info.plist index 88609f0..d9cc540 100644 --- a/Unit Tests/Info.plist +++ b/Unit Tests/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.10.12 + 0.11.0 CFBundleVersion 1